diff --git a/.pi/test-config.json b/.pi/test-config.json index 61d379d25..947d968d5 100644 --- a/.pi/test-config.json +++ b/.pi/test-config.json @@ -2,5 +2,6 @@ "suiteCommands": [ "npm --silent test -- tests/cli", "npm --silent test -- tests/unit" - ] + ], + "timeoutPerCommand": 1800 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 421131a67..fdd5157b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## v1.0.21 (2026-10-13) +> **Release focus:** This release focuses on making the worklist more reliable and complete, ensuring blocked, in-progress, and reworked items always appear and that data refreshes instantly. It also fixes several crashes and data-loss issues that could occur when saving changes during conflicts or busy periods, and improves recovery behaviour when AI providers or long outages interrupt queued jobs. +### Features +- The README now clearly explains this is a Herdr extension for managing teams of AI agents, with Worklog as shared project memory. (WL-0MV16PXJ6003FEYT) +- In-progress tasks now always appear in your work list, even when many items await review. (WL-0MV6KV22S004NYIU) +- The work items list now updates within seconds when an agent makes changes. (WL-0MV02VA29009WBEN) +- Release notes can now include a custom call-to-action message. (WL-0MUWEPN6X0044R43) +- Long outages now retry less often, so recovery is smoother and less disruptive. (WL-0MUY9GDPG0062NWF) +- Queued jobs now resume automatically when AI providers recover, instead of piling on more requests. (WL-0MV54ZP3Y00514Y9) +- Blocked tasks now appear in the worklist so you can see what's holding up your backlog. (WL-0MV6TFX0600145M2) +- Stale in-progress tasks now automatically return to the right queue. (WL-0MTF5WO2Q008B8US) +- Worklist data now refreshes instantly when you return to the tab, so you always see up-to-date info. (WL-0MTF5WO1N0088P5P) +- Finished items no longer stay marked critical, so urgent lists show only work that still needs attention. (WL-0MSJM4EIV001A0V9) +### Bug Fixes +- Your item updates now stay safe even when another session syncs at the same time. (WL-0MUVSQ4EO00092E7) +- Fixed a crash that could strand your committed work when saving while others changed the same files. (WL-0MV533YSW004D1E1) +- Fixed a crash that could lose your progress when saving changes during busy periods. (WL-0MV5FAZCQ006EHWE) +- Fixed a crash that could lose your work when saving changes during a conflict. (WL-0MV569DDI003CCF1) +- Prevents a crash when your changes are pushed after someone else updates the same branch. (WL-0MV5FDJE30088GGY) +- Blocked items now always appear in your default worklist, so release-blocking tasks can't be hidden. (WL-0MV71WBHB007D8ZE) +- Blocked items now appear in the review queue so nothing release-blocking goes unseen. (WL-0MV70XY6D008013C) +- Rejecting an item for rework no longer changes its priority. (WL-0MUUAEDAU001GT1L) +- Test runs no longer send fake release notifications to Discord. (WL-0MUYA98Z8003HU8X) +- Your completed work is no longer lost when another change lands at the same time. (WL-0MUYTCF4L000FX3M) +- High-priority items now appear first in the In Review list, so you can triage the most important work first. (WL-0MUWDW0N6003KF6G) +- Tests now finish reliably instead of hanging when output is piped. (WL-0MUYTCF4D000WVKR) +- Finishing work no longer fails when your branch is behind, so your progress stays safe. (WL-0MV5LY0J8005FKTZ) +- Items in review that are being reworked now always appear in your list. (WL-0MV6JGVPH009YDXB) +### Other +- Removed duplicate tests to keep the game running smoothly and reliably. (WL-0MV28X3BT0079JM3) +- Audits now run faster in off-peak hours by using spare capacity. (WL-0MUFD33FT0080OD5) +- Tests are now more reliable, so bugs get caught faster and fixes ship sooner. (WL-0MV28X3SL005R5KX) +- Improves game stability and performance through ongoing code cleanup. (WL-0MSS3380K0002ZVO) +- Removed a leftover plugin that caused warnings, so your plugin list is now clean. (WL-0MSRG4828007ITI4) +- Clearer docs help operators keep worklists accurate and stable. (WL-0MTF5WNZV003JKMJ) + ## v1.0.20 (2026-10-09) > **Release focus:** This release focuses on reliability and resilience across agent sessions, fixing issues that could cause stalled work, shared slots, and interrupted sessions while adding automatic retries for connection errors and unavailable AI providers. It also improves day-to-day usability with multi-item deletion, a dedicated work item tab, an interview "Thinking…" indicator, and clearer guidance from the review queue and doctor. ### Features diff --git a/CLI.md b/CLI.md index e3599b07a..b76b73582 100644 --- a/CLI.md +++ b/CLI.md @@ -180,6 +180,12 @@ Priority downgrade cascade: - When an item's priority is downgraded away from `critical` (to `high`, `medium`, or `low`), any direct children still at `critical` priority are automatically downgraded to `high` so that a non-critical parent has no critical subtasks. Children already at `high` or below are left untouched. - In JSON mode, the downgraded children are reported in a `downgradedChildren` array. In human-readable mode, a summary line like `[Downgraded 2 child(ren) from critical to high]` is printed. +Terminal priority auto-downgrade (WL-0MSJM4EIV001A0V9): + +- When an item with `critical` priority transitions to a terminal state — `status` `completed` or `stage` `in_review` — its priority is automatically downgraded to `high`, so finished work stops competing with genuinely urgent open items. Closing via `wl close` is also a terminal transition and downgrades `critical` to `high`. +- Only an actual transition triggers the downgrade: editing an item that is already terminal does not rewrite its priority. Non-critical priorities (`high`, `medium`, `low`) are never modified, and non-terminal transitions (e.g. `--stage plan_complete`) leave `critical` intact. +- In JSON mode the downgraded item is reported in a `downgradedItem` field. In human-readable mode a summary line like `[Downgraded priority of WL-ABC123 from critical to high]` is printed. + Example: ```sh @@ -437,6 +443,13 @@ This is an audit gate leak; re-audit it or record a deliberate exception with `wl close --force` bypasses the warning and records a durable waiver on an uncovered `in_review` root, so the bypass is auditable. +**Terminal priority auto-downgrade (WL-0MSJM4EIV001A0V9):** Closing is a terminal +transition, so a `critical` item is automatically downgraded to `high`. When a subtree is +closed recursively or with `--force`, every closed descendant that was `critical` is +downgraded too; non-critical priorities are left untouched. The JSON result object +reports the downgraded items in a `downgradedItems` array, and human-readable mode prints +a summary line like `[Downgraded 1 item from critical to high]`. + **Output format (recursive close):** When the audit-gated recursive close path is triggered: - **Human-readable output** reports the count of successfully closed descendants: diff --git a/README.md b/README.md index 7a4def253..7c09934ec 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ # Worklog -A lightweight, Git-friendly issue tracker designed for AI agents and development teams. Track hierarchical work items with a CLI or REST API -- all backed by SQLite with JSONL-based Git syncing. +A [Herdr](https://herdr.dev) extension that assists with the management and orchestration of teams of AI agents. It browses and selects work from the backlog, dispatches agents into panes, tracks their live status, and releases claims abandoned by agents that have gone away. + +At the heart of that orchestration is **Worklog**: a lightweight, Git-friendly issue tracker that also serves as the project's history. Every work item records its description, acceptance criteria, and comment trail, so each agent gets the relevant context for the job it is given. Track hierarchical work items with a CLI or REST API — all backed by SQLite with JSONL-based Git syncing. ## Features -- **CLI + API**: Manage work items from the command line or a REST API -- **Git-Friendly Syncing**: JSONL format enables seamless team collaboration via Git with automatic conflict resolution -- **Hierarchical Work Items**: Parent-child relationships for organizing epics, features, and tasks -- **Plugin System**: Extend the CLI with custom commands (see [Plugin Guide](PLUGIN_GUIDE.md)) +- **Herdr Orchestration**: Browse and select work items, dispatch Pi agent panes, track live agent status, and self-heal abandoned `in_progress` claims from a Herdr pane (see [Herdr plugin](packages/herdr/README.md)) - **AI Agent Integration**: Pi agent plugin modules that auto-load into every session — activity indicator, session health, model/provider display, guardrails, error recovery (`/retry`), and skill-path tool (see [Pi extension](packages/tui/extensions/README.md)). - **Heartbeat Skill**: Automated work item monitoring via the Pi agent — run `/skill:heartbeat` to flag items needing producer review or to audit completed items for closure readiness (see [skill/heartbeat/SKILL.md](skill/heartbeat/SKILL.md)). +- **Hierarchical Work Items**: Parent-child relationships for organizing epics, features, and tasks +- **Plugin System**: Extend the CLI with custom commands (see [Plugin Guide](PLUGIN_GUIDE.md)) +- **CLI + API**: Manage work items from the command line or a REST API +- **Git-Friendly Syncing**: JSONL format enables seamless team collaboration via Git with automatic conflict resolution - **Multi-Project Support**: Custom prefixes for issue IDs per project ## Installation @@ -163,6 +166,7 @@ You can get a lot of value from using Worklog as a memory for your agents. But y | [docs/dev/wl-process-healthcheck.md](docs/dev/wl-process-healthcheck.md) | wl process healthcheck watchdog usage (cron/systemd, thresholds) | | [docs/dev/audit-debug-log-retention.md](docs/dev/audit-debug-log-retention.md) | Audit debug log retention policy + runbook: sweep locations/thresholds, dry-run/`--apply`/`--check`, cron install/removal, safety guarantees | | [docs/dev/downtime-dispatcher.md](docs/dev/downtime-dispatcher.md) | Leader-election + shared-coordination downtime dispatcher architecture (herdr); critical-first dispatch tier + freeze split-by-skill; automated per-project tabs (one tab per work-item prefix) and interactive item-ID-tab placement (invoking workspace, fail-open) | +| [docs/dev/test-suite-baseline.md](docs/dev/test-suite-baseline.md) | Pre-change test file count and suite runtime baseline for the legacy `test/` deduplication epic (reproducible commands + metrics) | | [docs/pane-triage-skill.md](docs/pane-triage-skill.md) | Approval-gated pane-triage skill and the cross-language pane-close bridge that exposes the shared classifier to Python skills | ## Tutorials diff --git a/bench/herdr-refresh-perf.ts b/bench/herdr-refresh-perf.ts new file mode 100644 index 000000000..b71ceee34 --- /dev/null +++ b/bench/herdr-refresh-perf.ts @@ -0,0 +1,385 @@ +/** + * Benchmark: end-to-end latency and `wl` spawn count of a default-view + * Herdr refresh (F1 of parent WL-0MV02VA29009WBEN). + * + * Seeds a real temp worklog (≥ 1000 root items + ≥ 20 expanded parents with + * children), then measures the refresh DATA path exactly as `doRefresh` drives + * it: `wl next` (+ mandatory subsets), the actionable count, the review-queue + * state, and the expanded children (batched). It reports: + * + * - warm default-view refresh latency (read cache hot), + * - cold default-view refresh latency (state counter bumped → cache cold), + * - the number of `wl` child processes spawned per refresh, + * - how many of those spawns did real read work vs served a cache hit. + * + * The ≤ 2 s target is host-dependent, so the benchmark REPORTS `withinTarget` + * rather than failing the process on it (the paired spawn-count metric is the + * regression guard). + * + * Usage: + * npx tsx bench/herdr-refresh-perf.ts # text output + * npx tsx bench/herdr-refresh-perf.ts --json # machine-readable output + * + * Options: + * --items N root items to create (default 1000) + * --parents N expanded parent items (default 20) + * --children-per-parent children created under each parent (default 3) + * --browse N browseItemCount used by `wl next` (default 30) + * --iterations N measured warm/cold pairs (default 2) + * --keep keep temp artifacts (default false) + * --json JSON output for agent consumption + * + * A `wl` binary must be resolvable on PATH (the same requirement as the + * running plugin). This is a repeatable, spawn-count-observable harness, not a + * substitute for the manual wall-clock note recorded by F4 on a real dataset. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { pathToFileURL } from 'url'; +import { WorklogDatabase } from '../src/database.js'; +import { bumpStateCounter } from '../src/read-cache.js'; +import { countSpawnRecords } from '../src/spawn-counter.js'; +import { + setExecFileAsync, + resetExecFileAsync, + setWorklogDir, + resetWorklogDir, + fetchNextItems, + fetchActionableCount, + fetchReviewQueueState, + fetchChildrenForItems, +} from '../packages/herdr/src/fetcher.js'; + +// ── Configuration ──────────────────────────────────────────────────── + +const TARGET_MS = 2000; + +export interface HerdrRefreshPerfOptions { + items: number; + parents: number; + childrenPerParent: number; + browse: number; + iterations: number; + keep: boolean; + json: boolean; +} + +const DEFAULTS: HerdrRefreshPerfOptions = { + items: 1000, + parents: 20, + childrenPerParent: 3, + browse: 30, + iterations: 2, + keep: false, + json: false, +}; + +export interface HerdrRefreshPerfResult { + itemCount: number; + expandedParents: number; + childrenPerParent: number; + browseCount: number; + iterations: number; + /** Median warm default-view refresh latency (ms). */ + warmMs: number; + /** Median cold default-view refresh latency (ms). */ + coldMs: number; + /** `wl` processes spawned during the last cold refresh. */ + coldSpawns: number; + /** `wl` processes spawned during the last warm refresh. */ + warmSpawns: number; + /** Cold spawns that performed real read work (read-cache miss). */ + coldReadWorkSpawns: number; + /** Warm spawns served from the read cache (no DB work). */ + warmCacheHitSpawns: number; + targetMs: number; + withinTarget: boolean; + dbPath: string; +} + +// ── Arg parsing ────────────────────────────────────────────────────── + +export function parseArgs(argv: string[]): HerdrRefreshPerfOptions { + const options = { ...DEFAULTS }; + const args = argv.slice(2); + const readValue = (index: number): string | undefined => { + const value = args[index + 1]; + if (!value || value.startsWith('--')) return undefined; + return value; + }; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case '--items': { + const v = readValue(i); + if (v) options.items = Number(v); + break; + } + case '--parents': { + const v = readValue(i); + if (v) options.parents = Number(v); + break; + } + case '--children-per-parent': { + const v = readValue(i); + if (v) options.childrenPerParent = Number(v); + break; + } + case '--browse': { + const v = readValue(i); + if (v) options.browse = Number(v); + break; + } + case '--iterations': { + const v = readValue(i); + if (v) options.iterations = Number(v); + break; + } + case '--keep': + options.keep = true; + break; + case '--json': + options.json = true; + break; + default: + break; + } + } + return options; +} + +// ── Helpers ────────────────────────────────────────────────────────── + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; +} + +const realExecFile = promisify(execFile); + +/** + * Seed a real temp worklog with root items plus expanded parents and + * children, returning the worklog dir and the expanded parent ids. + */ +function seedWorklog( + rootDir: string, + options: HerdrRefreshPerfOptions, +): { worklogDir: string; expandedParentIds: string[] } { + const worklogDir = path.join(rootDir, '.worklog'); + fs.mkdirSync(worklogDir, { recursive: true }); + + const db = new WorklogDatabase( + 'BENCH', + path.join(worklogDir, 'worklog.db'), + path.join(worklogDir, 'worklog-data.jsonl'), + true, + false, + ); + + const priorities = ['critical', 'high', 'medium', 'low'] as const; + + try { + for (let i = 0; i < options.items; i += 1) { + db.create({ + title: `Refresh perf item ${i}`, + priority: priorities[i % priorities.length], + description: `Synthetic description for refresh benchmark item ${i}.`, + }); + } + + const expandedParentIds: string[] = []; + for (let p = 0; p < options.parents; p += 1) { + const parent = db.create({ + title: `Refresh perf expanded parent ${p}`, + priority: priorities[p % priorities.length], + description: `Parent ${p} used to measure the expanded-children fetch cost.`, + }); + expandedParentIds.push(parent.id); + for (let c = 0; c < options.childrenPerParent; c += 1) { + db.create({ + title: `Refresh perf child ${p}.${c}`, + priority: priorities[c % priorities.length], + parentId: parent.id, + description: `Child ${c} of parent ${p}.`, + }); + } + } + + return { worklogDir, expandedParentIds }; + } finally { + db.close(); + } +} + +// ── Benchmark runner ───────────────────────────────────────────────── + +/** + * Run the benchmark and return its summary. Restores every piece of global + * state it touches (env, fetcher worklog dir, exec seam) on the way out, so + * it is safe to call from the vitest contract test in-process. + */ +export async function runBenchmark(argv: string[] = process.argv): Promise { + const options = parseArgs(argv); + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'herdr-refresh-perf-')); + const cacheDir = path.join(rootDir, 'cache'); + const spawnFile = path.join(rootDir, 'spawns.log'); + + const savedCacheDir = process.env.WL_CACHE_DIR; + const savedSpawnFile = process.env.WL_SPAWN_COUNT_FILE; + + let spawnCount = 0; + + try { + const { worklogDir, expandedParentIds } = seedWorklog(rootDir, options); + + // `wl` refuses to run unless the init semaphore is present. + fs.writeFileSync( + path.join(worklogDir, 'initialized'), + JSON.stringify({ version: '1.0.20', initializedAt: new Date().toISOString() }), + 'utf-8', + ); + + process.env.WL_CACHE_DIR = cacheDir; + process.env.WL_SPAWN_COUNT_FILE = spawnFile; + setWorklogDir(worklogDir); + setExecFileAsync((async (file: any, args: any, execOptions: any) => { + spawnCount += 1; + return realExecFile(file, args, execOptions); + }) as any); + + const refresh = async (): Promise => { + // Mirror the real default-view data path (worklist.doRefresh): the item + // fetch, the batched expanded-children read, the review-queue query and + // the actionable count all run CONCURRENTLY, and the children/review/ + // count reads are consolidated (in-process SQLite reads and in-flight + // memo dedupe) so a cold refresh spawns only `wl next` plus the three + // mandatory-subset queries (WL-0MV5CBQ6L005SPAG; the actively-worked + // in-progress subset was added by WL-0MV6KV22S004NYIU). + await Promise.all([ + fetchNextItems(options.browse), + fetchChildrenForItems(expandedParentIds), + fetchReviewQueueState(), + fetchActionableCount(), + ]); + }; + + const resetSpawnFile = (): void => { + try { + fs.rmSync(spawnFile, { force: true }); + } catch { + // ignore + } + }; + + const measure = async (): Promise<{ ms: number; spawns: number; readWork: number; cacheHits: number }> => { + resetSpawnFile(); + spawnCount = 0; + const start = performance.now(); + await refresh(); + const ms = performance.now() - start; + return { + ms, + spawns: spawnCount, + readWork: countSpawnRecords(spawnFile, 'read-work'), + cacheHits: countSpawnRecords(spawnFile, 'cache-hit'), + }; + }; + + // Prime the read cache so the first "warm" measurement is genuinely warm. + await refresh(); + + const warmRuns: number[] = []; + const coldRuns: number[] = []; + let coldSpawns = 0; + let warmSpawns = 0; + let coldReadWorkSpawns = 0; + let warmCacheHitSpawns = 0; + + for (let i = 0; i < options.iterations; i += 1) { + const warm = await measure(); + warmRuns.push(warm.ms); + warmSpawns = warm.spawns; + warmCacheHitSpawns = warm.cacheHits; + + // Invalidate the read cache exactly as a `wl` write does. + bumpStateCounter(cacheDir, worklogDir); + const cold = await measure(); + coldRuns.push(cold.ms); + coldSpawns = cold.spawns; + coldReadWorkSpawns = cold.readWork; + } + + const warmMs = median(warmRuns); + const coldMs = median(coldRuns); + + return { + itemCount: options.items, + expandedParents: expandedParentIds.length, + childrenPerParent: options.childrenPerParent, + browseCount: options.browse, + iterations: options.iterations, + warmMs, + coldMs, + coldSpawns, + warmSpawns, + coldReadWorkSpawns, + warmCacheHitSpawns, + targetMs: TARGET_MS, + withinTarget: coldMs <= TARGET_MS, + dbPath: path.join(worklogDir, 'worklog.db'), + }; + } finally { + resetExecFileAsync(); + resetWorklogDir(); + if (savedCacheDir === undefined) delete process.env.WL_CACHE_DIR; + else process.env.WL_CACHE_DIR = savedCacheDir; + if (savedSpawnFile === undefined) delete process.env.WL_SPAWN_COUNT_FILE; + else process.env.WL_SPAWN_COUNT_FILE = savedSpawnFile; + + if (!options.keep) { + try { + fs.rmSync(rootDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + } +} + +// ── Main ───────────────────────────────────────────────────────────── + +async function main(): Promise { + const options = parseArgs(process.argv); + const summary = await runBenchmark(process.argv); + + if (options.json) { + console.log(JSON.stringify({ ...summary, timestamp: new Date().toISOString() }, null, 2)); + return; + } + + console.log('\n========================================'); + console.log(' Herdr default-view refresh benchmark'); + console.log('========================================\n'); + console.log(` dataset: ${summary.itemCount} items, ${summary.expandedParents} expanded parents x ${summary.childrenPerParent} children`); + console.log(` browseItemCount: ${summary.browseCount}`); + console.log(` iterations: ${summary.iterations}`); + console.log(` warm refresh: ${summary.warmMs.toFixed(1)} ms (${summary.warmSpawns} wl spawns, ${summary.warmCacheHitSpawns} cache hits)`); + console.log(` cold refresh: ${summary.coldMs.toFixed(1)} ms (${summary.coldSpawns} wl spawns, ${summary.coldReadWorkSpawns} read-work)`); + console.log(` target: ${summary.targetMs} ms`); + console.log(` within target: ${summary.withinTarget ? 'yes' : 'no (regression guard: spawn count)'}`); + console.log(` db: ${summary.dbPath}`); + console.log(''); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error('benchmark failed:', err); + process.exitCode = 1; + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8d4486993..a0ced3488 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -195,6 +195,26 @@ wl migrate jsonl --delete - **Push failure**: JSONL retained for retry - **Import errors**: SQLite transaction rollback +### Concurrent access & the store mutation lock + +`wl sync` holds the per-store advisory file lock (`withFileLock`, +`getLockPathForJsonl`) for its entire fetch → merge → write-back → push +operation. Every DB-writing command acquires that same lock around its +read-modify-write via `withStoreMutationLock(dataPath, fn)` +(`src/mutation-lock.ts`), so a mutation and a sync can never interleave and a +mutation can never be silently reverted by the sync's stale snapshot +(WL-0MUV2U9QF002S9J1, WL-0MUVSQ4EO00092E7). `withFileLock` is reentrant per +process, so a mutation that internally triggers a sync (e.g. `wl delete`'s +auto-sync) does not deadlock. + +The locked commands are: `wl update`; `wl comment add/update/delete`; +`wl audit-set`; `wl close`; `wl delete`; `wl dep add/rm`; `wl create`; +`wl reviewed`; `wl interview`; `wl audit-waive/unwaive`; `wl re-sort`; the +writing `wl doctor` subcommands; `wl github import`; `wl github push`; +`wl import`; `wl init`; and `wl migrate`. See +[docs/design/incremental-sync.md § 9](design/incremental-sync.md#9-concurrency--existing-protections) +and `tests/cli/sync-vs-mutation.test.ts`. + ## Worklog Root Resolution The project root that owns a `.worklog/` directory is resolved by a single diff --git a/docs/design/incremental-sync.md b/docs/design/incremental-sync.md index 9e34dc9bd..77e60f6a6 100644 --- a/docs/design/incremental-sync.md +++ b/docs/design/incremental-sync.md @@ -293,12 +293,22 @@ the records changed since the last full snapshot — not unbounded history. The incremental path runs entirely inside the existing sync flow, so it inherits unchanged: -- the file lock (`withFileLock`, `getLockPathForJsonl`) — also acquired by the - mutating commands `wl update` / `wl comment add` / `wl audit-set` around - their read-modify-write, so a sync can never overwrite a mutation that - landed after its local read (no lost update; see - [DATA_SYNCING.md § Concurrent Writes](../DATA_SYNCING.md#concurrent-writes-sync-vs-mutating-commands) and - `tests/cli/sync-vs-mutation.test.ts`), +- the file lock (`withFileLock`, `getLockPathForJsonl`) — held by `wl sync` + for its whole fetch → merge → write-back → push, and acquired by **every** + DB-writing command around its read-modify-write via + `withStoreMutationLock` (`src/mutation-lock.ts`). The locked commands are: + `wl update`; `wl comment add`, `wl comment update`, `wl comment delete`; + `wl audit-set`; `wl close`; `wl delete` (including its reentrant + auto-sync); `wl dep add` and `wl dep rm`; `wl create`; `wl reviewed`; + `wl interview` (per persisted answer/flag update); `wl audit-waive` and + `wl audit-unwaive`; `wl re-sort`; the writing `wl doctor` subcommands + (`stage-sync`, `prune`, `foreign-items --apply`, `dangling-parents`, + `priority`, `file-paths`, `migrate`, the main `--fix` path, and `upgrade` + migrations); `wl github import`; and the whole-store commands `wl import`, + `wl github push`, `wl init` and `wl migrate`. Because a mutation and a sync + contend on the same lock, a sync can never overwrite a mutation that + landed after its local read (no lost update); see + `tests/cli/sync-vs-mutation.test.ts`. - the single-flight guard and `--if-idle` skip semantics (WL-0MSAB7ZUC004SK7E), - the ephemeral JSONL pattern (SQLite → JSONL → push → delete). diff --git a/docs/design/mode-switch-worker.md b/docs/design/mode-switch-worker.md index 99aaebebb..9d996bfe9 100644 --- a/docs/design/mode-switch-worker.md +++ b/docs/design/mode-switch-worker.md @@ -75,6 +75,73 @@ The mode-switch worker reuses the machine-wide downtime leader - **Fail-closed:** if the shared file cannot be read or written, the worker falls back to its local clock (never a premature cheap switch). +### Model-cooldown gate (WL-0MV5638ZS005I5XQ) + +A pane blocked on provider exhaustion (the in-pane recovery loop classifies +"all providers exhausted" / "all providers unavailable" / "no provider is +available during the current scheduled time window" 503s as `SERVER_ERROR`) +sits in proxy backoff holding **no** slot, so the proxy looks idle. Without a +gate the leader would switch the shared proxy to cheap — which *adds* +concurrency (cheap has more slots than fast) during a known +provider-exhaustion window. + +- Before the cheap decision, the leader reads the machine-wide cooldown + signal (`readModelCooldowns` from `@worklog/shared/model-cooldown`, the same + coordination dir as `mode-activity.json`). While **any** entry has + `cooldownUntil` in the future the leader holds **fast**: no cheap POST. +- **Bounded, no latch:** the reader returns only *active* entries, so the hold + ends exactly at `cooldownUntil` and the first tick at/after expiry resumes + the normal decision. The worker persists no cooldown state of its own. +- **Machine-wide (AC4):** the signal lives in the coordination directory, not + in per-root state, so a cooldown recorded for any worklog root suppresses + the shared cheap switch. +- **Fail-closed:** an unreadable/corrupt signal (`null`) holds fast rather + than switching on an unreadable signal, and logs the hold so it is not + silent. A *missing* signal (nothing was ever recorded) is `[]` and does not + suppress the switch. +- **Fast path unaffected:** `onOperatorCommand` never reads the cooldown + signal, so a cooldown can never suppress the fast-on-command switch. +- **Contract of record.** The on-disk format — the file + (`model-cooldowns.json`), `schemaVersion: 1`, the entry fields (`paneId` / + `sessionId` key, best-effort `itemId` / `kind`, `reason`, `createdAt`, + `cooldownUntil`) and the tolerant/fail-closed read rules — is documented + once, in the header of `packages/shared/src/model-cooldown.ts`. This + document, `packages/herdr/README.md` and the TUI recovery-loop code comments + cross-reference that header instead of restating the field list. +- **Bounded default (WL-0MV5639X70029BOI).** An absent or malformed + `Retry-After` hint yields a *bounded* default cooldown + (`DEFAULT_RECOVERY_CONFIG.cooldown`, clamped into `[floorMs, ceilingMs]` — + `1000 ms`–`6 h`, default `10 min`) rather than a zero or unbounded hold, so + the gate can neither open prematurely nor pin the proxy in fast mode. +- **Fail-closed direction (explicit).** Unreadable ⇒ hold fast; absent ⇒ normal + decision. The two never collapse: `readModelCooldowns` returns `null` for + unreadable/corrupt and `[]` for absent/empty. + +### Withdrawn client drain — must not regress (WL-0MUWIHUL2000JEFR) + +The earlier "drain before switching to cheap" design (WL-0MUL0KO7Q003O7YJ) was +withdrawn. It introduced a **client-side** drain whose cheap-switch completion +gate (`freeSlots >= 2`) was unsatisfiable with the live pool sizes (fast = 1 +slot, cheap = 3 slots — `WL-0MUWF763Q003MGUM`), and whose drain was unbounded, +so it latched and paused the dispatcher. The proxy's own **server-side bounded +drain** is unchanged and stays authoritative. + +A future change **must not** reintroduce any part of the withdrawn client +drain: + +- **No client-side slot-count completion gate** — the mode-switch worker must + not gate the cheap switch on a free-slot count (`MODE_SWITCH_DRAIN_FREE_SLOTS` + / `DOWNTIME_AUDIT_MIN_FREE_SLOTS`); the only cheap path is "operator idle + + proxy idle + no active model cooldown". +- **No dispatch pause** — the downtime dispatcher must not carry a + `drainPaused` config field or refuse new dispatch while a drain is in + progress. +- **No unbounded latch** — the cheap-switch hold is bounded solely by + `cooldownUntil`; there is no persistent "draining" state that can get stuck. + +The model-cooldown gate exists to *avoid adding concurrency* during known +provider exhaustion; it is not a resumption of the withdrawn drain. + ## 3. Settings (re-read every tick — apply without plugin restart) | Setting | Default | Clamp | @@ -97,11 +164,16 @@ time-based plan. ## 5. Key files - `packages/herdr/src/mode-switch-worker.ts` — worker core (idle clock, - leader-gated tick, shared `mode-activity.json` read/write, admin API - client, clamps) + leader-gated tick, shared `mode-activity.json` read/write, machine-wide + model-cooldown gate, admin API client, clamps) +- `packages/shared/src/model-cooldown.ts` — versioned machine-wide cooldown + contract (`readModelCooldowns` tolerant/fail-closed reader) shared with the + TUI recovery loop - `packages/herdr/src/mode-switch-worker.test.ts` — worker core tests + (including the model-cooldown gate matrix) - `packages/herdr/src/mode-switch-integration.test.ts` — wiring tests - (settings → worker, route classification, scheduler interval constants) + (settings → worker, route classification, scheduler interval constants, + cross-root cooldown gate) - `packages/herdr/src/settings.ts` — settings schema + validation/clamps - `packages/herdr/src/index.ts` — agent-route hook wiring - `packages/herdr/src/worklist.ts` — scheduler task registration \ No newline at end of file diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index f071574f1..1127c74a4 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -536,6 +536,7 @@ machine-readable reason: | `agent-blocked` | The agent status is `blocked` (never hijacked). | | `kind-stale` | The item has already advanced past the pane's dispatched kind. | | `cooldown-active` | The non-terminal pane-close cooldown holds the `(item, kind)`. | +| `model-cooldown-active` | A machine-wide model cooldown is still active (`cooldownUntil` in the future); the pane is never woken early. | | `attempt-cap-exhausted` | The per-item/per-kind dispatch-attempt cap is reached. | | `last-activity-unknown` | No parseable activity timestamp to measure a stall. | | `stall-threshold-not-elapsed` | Not-`working` for less than the threshold. | @@ -580,6 +581,48 @@ applies to resumes too; the cap is enforced up front and escalates via `(paneId, itemId)` are prevented by an in-process in-flight guard (a second attempt is the neutral `concurrent-resume` skip). +#### Model-cooldown wake (WL-0MV5639GG00085Q7; parent WL-0MV54ZP3Y00514Y9) + +A pane whose in-pane recovery loop hit provider exhaustion writes a durable +machine-wide model-cooldown marker (`model-cooldowns.json` in the coordination +dir; `@worklog/shared/model-cooldown`). The scan reads **every** stored entry +once per cycle — active *and* expired/due — and matches it to a listed pane by +`paneId` (the recovery loop writes `HERDR_PANE_ID` when available; a +session-only entry cannot be mapped to a pane record and is ignored): + +- **Active** (`cooldownUntil > now`) — the pane is known to be waiting on a + throttled provider: it is **never** woken, and the active cooldown overrides + even an elapsed generic stall threshold (reason `model-cooldown-active`). + This is the strictness guarantee. +- **Due** (`cooldownUntil <= now`) — the marker is the WAKE TRIGGER: the pane + becomes a candidate **without** waiting for the generic stall threshold (the + `last-activity`/`stall-threshold` checks are bypassed). Every other safety + gate still applies — the non-terminal pane-close cooldown, the + per-item/per-kind attempt cap, the `needsProducerReview` flag and the + kind-vs-stage (`kind-stale`) check. + +A due cooldown candidate is ordered **before** generic stall candidates and is +resumed through the **unchanged** stalled-resume path (the literal `continue` +to a live pane, or a pi `--continue` relaunch in an exited pane) with the same +attempt accounting. The resume loop still resumes **at most one** candidate per +cycle, so at most one cooled-down job is woken per dispatcher cycle, and a +resumed pane makes the cycle short-circuit with `stalled-resume` rather than +opening a new pane. + +A **successful** resume consumes the pane's marker (`clearModelCooldown`) so the +same pane is not re-woken on the next cycle; a **failed** resume does not clear +the marker and records no attempt, so it stays bounded by `cooldownUntil` and +the attempt cap. An unreadable/absent cooldown signal is fail-safe — the scan +simply has no cooldown candidates and the generic path is unchanged. + +> **Must not regress — no client drain (WL-0MUWIHUL2000JEFR).** This wake is +> **not** a resurrection of the withdrawn "drain before switching to cheap" +> design: there is **no** client-side slot-count completion gate, **no** +> `drainPaused` dispatch pause and **no** unbounded latch. The scan is bounded +> by `cooldownUntil`, resumes at most one pane per cycle, and the proxy's own +> server-side bounded drain remains authoritative. See the "Withdrawn client +> drain — must not regress" section of `docs/design/mode-switch-worker.md`. + #### Neutral outcome vocabulary and fail-safety (parent AC2/AC6) Every not-resumed outcome is neutral — never a strike, never a crash, never a diff --git a/docs/dev/herdr-event-driven-refresh.md b/docs/dev/herdr-event-driven-refresh.md new file mode 100644 index 000000000..130b15916 --- /dev/null +++ b/docs/dev/herdr-event-driven-refresh.md @@ -0,0 +1,122 @@ +# Herdr event-driven refresh (fast, non-blocking refresh after agent writes) + +Parent work item: `WL-0MV02VA29009WBEN` — *Make Herdr UI refresh snappy after +agent work-item updates*. + +## Problem + +The Herdr Work Items pane relied on a 30 s auto-refresh tick, so an agent's +`wl` write could take up to 30 s to appear. When the tick did run, the write +had just invalidated the on-disk read cache, so the pane spawned ~5 cold `wl` +processes (plus one `wl list --parent` per expanded parent) for a multi-second +update — precisely when the operator is watching an agent work. + +## Design + +### 1. Event-driven detection (`db-change.ts` → `watchDbChanges`) + +`watchDbChanges(cacheDir, worklogDir, onChange, opts)` watches the **same +cross-process signal** the existing `DbChangeTracker` reads: + +- the per-worklog state counter `/state/.json` + (bumped by every `wl` write and by post-sync cache invalidation — + write-only from `wl`'s perspective). + +The SQLite `worklog.db` / `worklog.db-wal` / `worklog.db-shm` files are +**deliberately NOT watched**. An early design watched them too, which created +a self-sustaining refresh loop: the pane's own refresh path +(`fetchActionableCount` / `fetchChildrenForItems`) opens a fresh +`WorklogDatabase` connection for in-process reads, and that open/close +checkpoints the WAL and touches `-shm` — i.e. it modifies exactly the files +being watched, re-firing the watch → another refresh → more file churn. The +pane never writes the state counter (it only reads it), so the counter is the +loop-free authoritative signal (producer-rejection fix for +`WL-0MV02VA29009WBEN`). + +A burst of events is coalesced into **at most one trailing** `onChange()` after +`debounceMs` (default 150 ms). The watch is **fail-open**: a target whose +`fs.watch` throws is skipped, and when no target can be established the handle +reports `active === false` so the caller keeps the 30 s polling tick. An +asynchronous `fs.FSWatcher` `'error'` event (inotify limits, network FS) and a +throwing `onChange` are both swallowed through the `onError` seam so nothing +escapes into the TUI event loop. `dispose()`/`close()` closes every handle and +clears any pending timer; teardown happens on pane exit. + +### 2. Wire-through (`worklist.ts`) + +The watch callback dispatches asynchronously (`void requestWatchRefresh()`), +which: + +1. skips when a text-input overlay is open (`inputActive()`) or the pane tab is + hidden (`paneGate.visible()`) — the change is deliberately **left + unconsumed** so the fallback tick refreshes once the gate clears; +2. consumes the `DbChangeTracker` baseline (`tracker.dbChanged()`) so the next + 30 s tick sees the change as already handled and stays a no-op + (`WL-0MSJ1OLTL009N4IQ`, idle zero-spawn preserved); +3. calls the existing gated `doRefresh` (awaited), preserving its + **single-flight + trailing-coalescing** semantics + (`WL-0MSBVYBMD004007C`, `WL-0MTIB7JAN004MQ8N`) — the watch never bypasses + those guards, so a burst cannot cause a refresh storm. Because the state + counter is bumped *before* a `wl` write commits (pre-action cache + invalidation), the counter is re-checked after the refresh and, if it + moved again (the write settled mid-refresh), one final trailing refresh + runs so the pane never shows a pre-commit snapshot. + +### 3. Cold-refresh cost (`fetcher.ts`) + +The default-view refresh now spawns far fewer `wl` processes: + +- **In-process SQLite reads** — `fetchActionableCount` and + `fetchChildrenForItems` read a **fresh** `WorklogDatabase` in-process when a + worklog dir is resolved and `better-sqlite3` is available. The store is + loaded lazily (kept out of the plugin import graph until needed); the + in-memory query cache is never trusted across a refresh boundary. +- **Concurrent base + mandatory** — the base `wl next` and the mandatory + subsets run under `Promise.all`; the review-queue query and the mandatory + `in_review` subset share an identical argument vector, so the in-flight fetch + memo (`WL-0MSGAEPOJ002824S`) deduplicates them into one spawn. +- **One read for all expanded parents** — `fetchChildrenForItems()` resolves + every expanded parent in one in-process query; per-parent CLI calls remain + only as the fallback. + +Measured on the benchmark harness (≥ 1000 items, ≥ 20 expanded parents), the +cold default-view refresh dropped from **25 → 3** `wl` spawns (~88 %). + +## Verification + +### Automated + +- `packages/herdr/src/db-change.test.ts` — `watchDbChanges` contract: trigger, + debounce/coalescing, per-file filtering (state counter only — SQLite + `-wal`/`-shm`/db churn never fires), fail-open, teardown, async error, + throwing callback, real-`fs.watch` write, and a real-`fs.watch` regression + that SQLite-file churn alone never fires the watch. +- `packages/herdr/src/worklist-watch.test.ts` — integration: a state-counter + write (the same signal a `wl` write bumps) triggers a refresh within 2 s, + before the 30 s tick; unrelated writes do not; fail-open + idle no-spawn; the + typing/visibility gates; trail-coalescing; the watch consumes the signal so + the next tick is a no-op; and an event-loop-delay responsiveness guard + (≤ 100 ms p95) across watch-triggered refreshes. +- `packages/herdr/src/hydrator-responsiveness.test.ts` — event-loop delay + guard for the real watch primitive under a burst of writes. +- `bench/herdr-refresh-perf.ts` (+ `tests/bench/herdr-refresh-perf.test.ts`) — + repeatable warm/cold latency and `wl` spawn-count benchmark. + +### Manual real-agent-write note (F6) + +A real `wl comment add` against the shared `.worklog` was performed while a +`watchDbChanges` handle watched the real cache/DB signals. Three consecutive +runs measured the write → watch-callback latency at **116 ms / 924 ms / +1175 ms** — all within the ≤ 2 s target (the spread reflects the 150 ms +debounce plus machine load). The integration test +`worklist-watch.test.ts` asserts the same < 2 s bound deterministically with an +emulated write. + +## References + +- Sibling Pi-TUI precedents: `WL-0MKXN75CZ0QNBUJJ`, `WL-0MQ3LYSPS006V60H`. +- Read cache: `src/read-cache.ts` / `src/read-cache-cli.ts` + (`WL-0MSAZQEQB008O7H3`). +- Fetch memo: `WL-0MSGAEPOJ002824S`. In-flight refresh guard: + `WL-0MSBVYBMD004007C`. Trail-coalescing: `WL-0MTIB7JAN004MQ8N`. +- Responsiveness precedent: `WL-0MUY0RDBQ001H74P`, `WL-0MUY1CZES0099A04`. diff --git a/docs/dev/herdr-hydrator.md b/docs/dev/herdr-hydrator.md index 09bfc50be..1bc1b6a90 100644 --- a/docs/dev/herdr-hydrator.md +++ b/docs/dev/herdr-hydrator.md @@ -6,9 +6,9 @@ Folded-in scope: WL-0MTTSWE0G008M1VA (no-activity / claim-age timeout, 2026-09-1 ## Problem Work items claimed as `in_progress` are correctly excluded from downtime -dispatch (single-flight — an active pane owns them). But there is no +dispatch (single-flight — an active claim owns them). But there is no automatic mechanism to detect claims that are **stuck**: status `in-progress` -with no live agent pane (pane closed, session died, claim abandoned). Such +with no matching agent tab (tab closed, session died, claim abandoned). Such items linger at the top of the queue and are never re-selected — they block themselves and top the Herdr selection list as non-dispatchable wall items (RCA `WL-0MTTS9FY00055QJV`, root cause E; evidence @@ -21,20 +21,20 @@ like the downtime worker: 1. **Every 30 s** — `wl list --status in-progress --json` (all in-progress items for the pane's worklog root). -2. **Fetch live panes** — `herdr pane list`; keep panes in the **current - workspace** (`HERDR_WORKSPACE_ID`; panes without a workspace id are - always included — fail-open toward "active"). -3. **Match** — a pane whose title contains the item's work-item ID (e.g. - `WL-…`) counts as active. The shared pane-title builders - (`packages/herdr/src/pane-title.ts`) preserve the ` - ` suffix when - truncating long titles, so every spawned pane carries its ID. -4. **Demote (release)** any item with no matching pane: +2. **Fetch live tabs** — `herdr tab list --workspace `; keep tabs in the + **current workspace** (`HERDR_WORKSPACE_ID`; tabs without a workspace id + are always included — fail-open toward "active"). +3. **Match** — a tab whose label **exactly equals** the item's work-item ID + (e.g. `WL-…`) counts as active; a label that merely *contains* the ID does + not. Tabs are labelled with the exact work-item ID by the shared + dispatcher-anchor anchors (`getItemTabAnchor`/`findTabByLabel`). +4. **Demote (release)** any item with no matching tab: - stage `in_review` → `status=completed`, stage `in_review` - an active outbound dependency blocker → `status=blocked` (stage repaired to a compatible value, e.g. `plan_complete` when the claim sat at `in_progress`) - otherwise → `status=open` at the claimed stage (the folded-in - no-activity/claim-age timeout release: a no-pane claim is released + no-activity/claim-age timeout release: a no-tab claim is released regardless of activity age, reusing the existing 2 h dispatch stale window `DOWNTIME_AUDIT_STALE_WINDOW_MS` as the activity bound) 5. **Focus-resume** — the hidden→visible transition runs the check @@ -42,10 +42,10 @@ like the downtime worker: ### Safety (fail-open, never crash, never false-demote) -- An unavailable / unparseable `wl list` or `herdr pane list` **aborts the +- An unavailable / unparseable `wl list` or `herdr tab list` **aborts the cycle without demoting** (no demotion on ambiguous evidence). -- A live-pane item is never touched (the dep query is skipped entirely for - it). +- An item with a live tab is never touched (the dep query is skipped entirely + for it). - A dependency lookup failure treats the item as not-blocked and still releases it to `open`. - A failed `wl update` is logged and left unchanged (counted as skipped). @@ -54,31 +54,71 @@ like the downtime worker: - Single-flight + scheduler watchdog (`HYDRATOR_RUN_TIMEOUT_MS`): a hung run is abandoned and retried on the next tick, never wedging the task. +## Selection-list in-progress marker (WL-0MV1BWETT005GSUJ) + +Demotion alone is not enough for the selection list: an item whose worklog +status is still `open` (e.g. a claim released before its tab was re-opened) +would show no marker even though a work-item tab is open. To make the marker +reflect tab existence directly, the same exact-label tab lookup also drives a +display overlay: + +- `markItemsActiveByTab(items, activeIds)` recursively stamps + `status = 'in-progress'` on every item whose ID is in the active tab set, so + the row renders the in-progress icon and the item is placed in Group N. It + is **idempotent**: each item's pre-marker status is captured on first sight + (a non-enumerable `Symbol` property) and restored on every later pass, so a + **closed** tab clears the marker back to the true worklog status without a + `wl` re-fetch. +- `applyActiveTabMarkers(items, listTabs, workspaceId)` fetches the workspace + tabs (fail-open) and applies the marker; the production wiring injects it + into `runWorklistTui`'s `applyActiveTabMarkers` seam from `index.ts` (the + same "inject from the caller" pattern as `mergeAgentStates`). +- **Update cadence.** Opening or closing a work-item tab does **not** touch the + worklog DB, so the DB-change-gated refresh tick can never update this marker + for it. The worklist therefore re-applies it on the initial load, on every + full refresh, on the `active-tab-marker` scheduler task (the hydrator + cadence, `HYDRATOR_INTERVAL_MS`), and on focus-resume. The marker task + spawns only `herdr tab list` (never `wl`), so the zero-`wl`-when-idle + guarantee holds; it is visibility- and typing-gated like the hydrator. +- Terminal `in_review`/`completed` items are deliberately left untouched: + they already carry the review/audit marker and belong in the In Review + group. + ## Files -- `packages/herdr/src/hydrator.ts` — module: ID extraction, active-pane - matching, demotion decision, compatibility repair, orchestration - (`runHydrationOnce`), visibility-gated runner +- `packages/herdr/src/hydrator.ts` — module: ID extraction, active-tab + matching, demotion decision, tab-based selection-list marker + (`markItemsActiveByTab` / `applyActiveTabMarkers`), compatibility repair, + orchestration (`runHydrationOnce`), visibility-gated runner (`createHydratorRunner`), production deps (`createProductionHydratorDeps`). -- `packages/herdr/src/hydrator.test.ts` — unit tests (AC6 map below). -- `packages/herdr/src/worklist.ts` — registers the `hydrate` scheduler task - and hooks the focus-resume path. +- `packages/herdr/src/hydrator.test.ts` — unit tests (AC6 map below), + including the pure tab-marker helper. +- `packages/herdr/src/worklist.ts` — registers the `hydrate` and + `active-tab-marker` scheduler tasks, hooks the focus-resume path, and + applies the injected `applyActiveTabMarkers` marker on the initial load, + each refresh, the marker cadence, and focus-resume. +- `packages/herdr/src/worklist-active-tab-marker.test.ts` — integration tests + for the selection-list marker wire-up (marker shown/hidden/scope). +- `packages/herdr/src/index.ts` — production wiring of the + `applyActiveTabMarkers` seam. - `packages/herdr/src/pane-title.ts` — `truncatePaneTitlePreservingSuffix` - keeps the work-item ID in truncated pane titles (prerequisite fix). + keeps the work-item ID visible in truncated pane titles (a human-readable + concern now — the hydrator matches exact tab labels, not pane titles). ## Test map (AC6) | Requirement | Test | |---|---| | ID extraction | `extractWorkItemIdsFromText` | -| active-pane matching across tabs/workspace | `active-pane matching` | +| active-tab matching (exact label, workspace-scoped) | `active-tab matching` | | in_review → completed | `decideDemotion` + `runHydrationOnce` | | dependency-blocked → blocked | `decideDemotion` + `runHydrationOnce` | | else → open | `decideDemotion` + `runHydrationOnce` | | stage/status compatibility | `compatibleStage`, blocked-at-`in_progress` case | | focus-resume immediate run | `createHydratorRunner` (visibility flip) | -| fail-open error paths | `runHydrationOnce` (missing list, missing panes, failed demotion) | +| selection-list marker (tab → in-progress) | `tab-based active marker (selection list)` + `worklist-active-tab-marker.test.ts` | +| fail-open error paths | `runHydrationOnce` (missing list, missing tabs, failed demotion) | ## Related diff --git a/docs/dev/test-suite-baseline.md b/docs/dev/test-suite-baseline.md new file mode 100644 index 000000000..b0f13b42c --- /dev/null +++ b/docs/dev/test-suite-baseline.md @@ -0,0 +1,342 @@ +# Test Suite Baseline — Legacy `test/` Deduplication + +**Work item:** WL-0MV4UZ6C4007XDGI (child of WL-0MV28X3BT0079JM3) +**Baseline commit:** `85679713a678b54417d4e994fc7f305c3ff75d38` (dev) +**Measured:** 2026-10-12 + +## Purpose + +Record the pre-change baseline (test file count and suite runtime) for the +legacy `test/` vs `tests/` deduplication epic, so the post-change child +(`WL-0MV4V0IF9008214R`) can report a meaningful delta. + +## Test file count (baseline) + +Tracked test files matching the vitest include pattern: + +```bash +git ls-files | grep -E '\.(test|spec)\.(ts|tsx|js|mjs|cjs)$' | wc -l +``` + +| Location | Test files | +|---|---| +| `packages/` | 115 | +| `test/` (legacy) | 9 | +| `tests/` | 234 | +| **Total** | **358** | + +Vitest discovers 358 test files (357 passed, 1 skipped — +`tests/github-sync-load.long.test.ts`). + +## Suite runtime (baseline) + +### Full vitest suite (all packages) + +Command (run from the worktree root, after `npm run build`): + +```bash +npm run build # build step +npm --silent test # vitest run — the complete suite +``` + +Result: + +| Metric | Value | +|---|---| +| Test files | 357 passed, 1 skipped (358) | +| Tests | 7851 passed, 5 skipped (7856) | +| Vitest duration | 243.30 s | +| Wall clock (`/usr/bin/time -v`) | 4:04.27 | + +### Framework-declared canonical suite (`.pi/test-config.json`) + +The implement/test/pre-push gates run the suite declared in +`.pi/test-config.json`: + +```json +{ + "suiteCommands": [ + "npm --silent test -- tests/cli", + "npm --silent test -- tests/unit" + ], + "timeoutPerCommand": 1800 +} +``` + +Command (via the test skill, which records the run in the per-repo cache): + +```bash +python3 /home/rgardler/.pi/agent/skills/test/scripts/run_tests.py --force --type full --json +``` + +Result: + +| Suite | Test files | Tests | Vitest duration | +|---|---|---|---| +| `tests/cli` | 95 | 852 passed, 4 skipped (856) | 181.70 s | +| `tests/unit` | 31 | 519 passed (519) | 6.67 s | +| **Canonical total** | **126** | **1371 passed, 4 skipped (1375)** | **~188.4 s** | + +> Note: the canonical suite is a subset of the full vitest suite. It does not +> include the legacy `test/` files at the repo root, nor most of `tests/` +> (only `tests/cli` and `tests/unit`), nor `packages/`, so the deduplication +> delta is most visible in the **full** suite numbers above. + +## Environment + +- Node/npm via the repo-managed `node_modules` (shared with the main checkout + via symlink in the worktree). +- Machine load and CPU count affect wall-clock; treat durations as indicative, + not exact. Both baseline and post-change runs must use the same commands + and host for a like-for-like comparison. + +## Reproduce + +```bash +git checkout 85679713a678b54417d4e994fc7f305c3ff75d38 +npm run build +npm --silent test # full suite +python3 /scripts/run_tests.py --force --type full --json # canonical subset +``` + +## Post-change measurement (2026-10-12) + +Measured on the worktree HEAD `467b4f4e` (dev) after all six deduplication +children landed, using the same commands as the baseline. + +### Test file count (post-change) + +| Location | Baseline | Post-change | Δ | +|---|---|---|---| +| `packages/` | 115 | 128 | +13 | +| `test/` (legacy) | 9 | 0 | -9 | +| `tests/` | 234 | 243 | +9 | +| **Total** | **358** | **371** | **+13** | + +### Full vitest suite (all packages) + +```bash +npm run build +/usr/bin/time -v npm --silent test +``` + +| Metric | Baseline | Post-change | +|---|---|---| +| Test files | 357 passed, 1 skipped (358) | 370 passed, 1 skipped (371) | +| Tests | 7851 passed, 5 skipped (7856) | 7952 passed, 5 skipped (7957) | +| Vitest duration | 243.30 s | 643.68 s | +| Wall clock (`/usr/bin/time -v`) | 4:04.27 | 10:45.65 | + +### Canonical suite (`.pi/test-config.json` — `tests/cli` + `tests/unit`) + +```bash +python3 /scripts/run_tests.py --force --type full --json +``` + +| Suite | Baseline | Post-change | +|---|---|---| +| `tests/cli` | 95 files, 852 passed + 4 skipped (856), 181.70 s | 98 files, 859 passed + 4 skipped (863), 297.81 s | +| `tests/unit` | 31 files, 519 passed (519), 6.67 s | 31 files, 519 passed (519), 7.20 s | +| **Canonical total** | **126 files, 1371 passed + 4 skipped (1375), ~188.4 s** | **129 files, 1378 passed + 4 skipped (1382), ~305 s** (runner elapsed 355.4 s) | + +### Isolated contribution of this epic + +The raw before/after deltas above are confounded by unrelated concurrent +work (see below). Isolating this epic's changes: + +- Legacy `test/` directory: 9 files → 0. Six files were **relocated** into + `tests/` (still in the suite); three were deleted after their unique + coverage was migrated (`validator`, `migrations`, `throttler`). +- `tests/validator.test.ts` (verbatim subset) deleted. +- Two new `tests/` files added to preserve migrated coverage + (`tests/api-needs-producer-review-filter.test.ts`, + `tests/cli/validate-cli-md.test.ts`). +- **Net: -8 test files** attributable to the epic (the six relocations remain + in the suite). +- **Test cases: net -7**, all redundant — 1 verbatim subset (WL-0MV4UTKU5008IFLH), + 3 throttler cases already covered (WL-0MV4UWH28002OTNJ), and 3 duplicate + `iconsEnabled` cases (WL-0MV4UY7IV001OCHG). Meaningful coverage is unchanged; + every removal is accounted for in the child work-item comments. + +### Confounders + +- **Machine load.** The post-change run executed while the host was heavily + loaded (load average ~38 on 16 cores) because several unrelated + `implement.py drive` operations were running concurrently. The absolute + runtime increase (243 s → 644 s) is therefore **not** attributable to this + epic — the epic strictly removes test executions. +- **Unrelated concurrent work.** Independent commits added test files to + `packages/` (+13) and `tests/` (read-cache and dispatch-tier splits), which + inflate the raw post-change file and test counts. +- A like-for-like runtime comparison would require re-measuring the baseline + commit on an idle host with the same command set. + +### Conclusion + +Full suite green after deduplication (`370 passed / 1 skipped` files, +`7952 passed / 5 skipped` tests). No meaningful coverage reduction; all +removed assertions are accounted for. + +## Model-cooldown epic baseline (WL-0MV54ZP3Y00514Y9) + +**Work item:** WL-0MV563AU2007FM56 (child of the model-cooldown epic — +"Cooldown regression guard and full-suite verification") +**Measured:** 2026-10-12, from the epic worktree with `dist/` built +(`npm run build`). +**Command:** `npm --silent test` (vitest run — the complete suite, including +`packages/`). + +The implement/test gates resolve the canonical subset from +`.pi/test-config.json` (`tests/cli` + `tests/unit`); this record uses the +**full** vitest suite so the epic's `packages/` changes are covered. + +### Before and after the regression guard + +The "before" state is the post-cooldown dev base (regression test absent); +the "after" state adds the withdrawn-drain regression guard +(`packages/herdr/src/drain-regression-guard.test.ts`, three tests). The only +functional change in this child is the test file, so the delta is exactly +the guard. + +| Metric | Before | After | Δ | +|---|---|---|---| +| Test files (passed / skipped / total) | 373 / 1 / 374 | 374 / 1 / 375 | +1 file | +| Tests (passed / skipped / total) | 8004 / 5 / 8009 | 8007 / 5 / 8012 | +3 tests | +| Vitest duration | 256.88 s | 240.13 s | −16.75 s (host load) | +| Wall clock (`/usr/bin/time -v`) | 4:17.85 | 4:01.10 | −16.75 s | + +Both runs are green; the runtime difference is host-load noise (several +concurrent `implement.py drive` runs were active), not a coverage change. + +### Pre-epic context (static) + +Tracked test files at the pre-epic commit (`a58d8d6b`, parent of the first +cooldown child commit `f505eb1f`), counted with `git ls-tree` (no full-suite +run at that commit): **365** test files (121 under `packages/`, 235 under +`tests/`, 9 legacy `test/`). The pre-this-child HEAD has **374** (131 +`packages/`, 243 `tests/`). The +9-file delta spans the cooldown epic plus +unrelated work merged between those commits and is **not** attributable to +the cooldown epic alone. + +### Conclusion + +Full suite green after the cooldown epic and its regression guard (374 passed +/ 1 skipped files, 8007 passed / 5 skipped tests). The withdrawn client-side +drain is pinned as not reintroduced (see +`packages/herdr/src/drain-regression-guard.test.ts`). + +## `downtime-worker` test-split runtime (WL-0MV4VTFYE0073POG) + +**Work item:** WL-0MV4VTFYE0073POG (child of WL-0MV28X3SL005R5KX — "Split +`downtime-worker.test.ts` and cap mocks per test") +**Measured:** 2026-10-12, from the child worktree at dev HEAD `1f701621` +**Scope:** the `downtime-worker` test module only (not the full suite) +**Host:** 16 cores; load average 12–19 during runs (several concurrent +`implement.py` operations) + +### Purpose + +Parent AC6 requires the `downtime-worker` test-suite runtime to be measured +before and after the split. This section records the method and the results. + +### Method + +The "before" state is the last pre-split revision of the monolith, +`daa7d2a9:packages/herdr/src/downtime-worker.test.ts` (13,103 lines, 715 +tests, 129 describe suites). The "after" state is all 13 +`packages/herdr/src/downtime-worker*.test.ts` files (13,697 lines, 725 tests, +146 describe suites). + +```bash +# Pre-split baseline (reconstructed from git into a temporary test file) +git show daa7d2a9:packages/herdr/src/downtime-worker.test.ts \ + > packages/herdr/src/downtime-worker.presplit.test.ts +npx vitest run --config vitest.measure.config.ts \ + packages/herdr/src/downtime-worker.presplit.test.ts + +# Post-split (all split files) +npx vitest run --config vitest.measure.config.ts \ + packages/herdr/src/downtime-worker*.test.ts +``` + +Five alternating iterations of each state were run; the **median** is quoted +(the host was too loaded for a single run to be meaningful). + +> **Isolation note.** `vitest.measure.config.ts` extends `vitest.config.ts` +> and aliases `@worklog/shared` (and its subpaths) to the worktree's own +> freshly built `packages/shared/dist`. This was required because concurrent +> `implement.py` builds in other worktrees repeatedly overwrite the main +> checkout's `packages/herdr/node_modules/@worklog/shared` copy (via +> `install-herdr.sh`), causing intermittent `ERR_MODULE_NOT_FOUND` collection +> failures. The alias does not change any test semantics. + +### Results + +| Metric | Pre-split | Post-split | Δ | +|---|---|---|---| +| Test files | 1 | 13 | +12 | +| Tests (all passing) | 715 | 725 | +10 | +| Describe suites | 129 | 146 | +17 | +| Wall clock (median of 5) | **5.80 s** | **5.83 s** | **+0.03 s (+0.5%)** | +| Wall clock (min of 5) | 5.19 s | 5.59 s | +0.40 s | +| Vitest duration (median) | 3.90 s | 4.19 s | +0.29 s (+7.4%) | +| `tests` time (median) | 1.65 s | 2.54 s | +0.89 s (+53.9%) | +| `transform` aggregate (median) | 3.27 s | 8.05 s | +4.78 s (+146%) | +| `import` aggregate (median) | 1.81 s | 6.65 s | +4.84 s (+267%) | +| `setup` aggregate (median) | ~0.12 s | ~0.77 s | ~+540% | + +Post-split test counts per file: + +| File | Tests | +|---|---| +| `downtime-worker.audit-tier.test.ts` | 55 | +| `downtime-worker.critical-first.test.ts` | 117 | +| `downtime-worker.dispatch.test.ts` | 40 | +| `downtime-worker.fixtures.test.ts` | 8 | +| `downtime-worker.gates.test.ts` | 16 | +| `downtime-worker.idle.test.ts` | 50 | +| `downtime-worker.orchestrator.test.ts` | 72 | +| `downtime-worker.pane.test.ts` | 68 | +| `downtime-worker.parse.test.ts` | 34 | +| `downtime-worker.settings.test.ts` | 57 | +| `downtime-worker.sprint-view.test.ts` | 57 | +| `downtime-worker.test.ts` (residual) | 49 | +| `downtime-worker.tiers.test.ts` | 102 | +| **Total** | **725** | + +### Interpretation + +- **Wall clock is effectively neutral** (+0.5% median, within host-load + noise). Vitest's own reported duration rises ~7%. +- The split replaces one module graph with 13 isolated ones + (`singleFork: true`, `isolate: true`), so the **aggregate** + transform/import/setup work rises 2.5–6.5×. On this loaded host, warm + caches largely absorb that overhead, so it barely surfaces in wall clock; + on an idle host or a cold CI cache the per-file isolation overhead may be + more visible. +- **No meaningful runtime improvement or regression.** The split's benefit is + maintainability (≤2,000-line files, bounded mocks per test), not speed. + +### Caveats + +- The pre-split baseline is reconstructed from commit `daa7d2a9` using the + current (HEAD) production code. Production code is unchanged by the split + apart from a JSDoc-only edit to `downtime-worker.ts`, so the comparison is + like-for-like. +- The host was shared and heavily loaded (load average 12–19 on 16 cores; a + `tce-main-street` vitest run, other `ContextHub` worktrees, and pytest were + active). Wall-clock figures are noisy; medians and minima of 5 alternating + runs are reported rather than a single sample. +- Vitest's `transform`/`import`/`setup` totals are aggregates across all test + files in the run. + +## Related + +- Parent epic: WL-0MV28X3BT0079JM3 — Deduplicate legacy `test/` vs `tests/` +- `downtime-worker` test split: WL-0MV28X3SL005R5KX +- Runtime measurement child: WL-0MV4VTFYE0073POG + files and delete verbatim subsets +- Post-change measurement: WL-0MV4V0IF9008214R +- Model-cooldown epic: WL-0MV54ZP3Y00514Y9 — Resume model-exhausted jobs + instead of switching to cheap +- Regression guard + full-suite verification: WL-0MV563AU2007FM56 diff --git a/package.json b/package.json index ea92a61a9..69cbcc7c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "1.0.20", + "version": "1.0.21", "description": "A simple experimental issue tracker for AI agents", "main": "dist/index.js", "type": "module", diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 224261510..1442422c0 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -16,12 +16,12 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b - **Fold indicators** — When the worklist has more items than fit the visible list area, the list shows dim `▼ more` / `▲ more` markers so you always know when items are hidden below the fold or above the current scroll position (WL-0MSG8YXYJ008PWJJ). See [Selection List Behaviour](#selection-list-behaviour). - **Pi agent pane dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are automatically dispatched to a new pi agent pane opened to the right, where pi receives the command as its initial prompt. Free-form prompts use the `/prompt:` prefix: the routing prefix is stripped so pi receives only the prompt text. The agent pane opens **without stealing focus** from the selection list (see [Design decisions](#design-decisions)). When the command carries a work-item ID, the pane is placed in that item's work-item-ID tab in the current workspace (reused if it already exists) — see [Interactive pane placement](#interactive-pane-placement-work-item-id-tabs). - **Downtime worker (local-LLM idle dispatch)** — During operator idle time the plugin dispatches pi agent panes to run audits/refactors of completed items against the local llama-server (see [Downtime worker](#downtime-worker-local-llm-idle-dispatch)) -- **Hydrator (self-healing `in_progress` claims)** — Every 30 seconds the plugin fetches all `in_progress` work items, matches each against live agent panes in the current workspace (the pane title carries the work-item ID), and releases any claim with no matching pane so it re-enters the dispatchable pool: `in_review` items complete, dependency-blocked items are marked `blocked`, everything else returns to `open` at its claimed stage. The check also runs immediately when the worklog tab regains focus, and is fully visibility-gated (a hidden tab spawns zero `wl`/`herdr` processes). See [Hydrator](#hydrator-self-healing-in_progress-claims). +- **Hydrator (self-healing `in_progress` claims)** — Every 30 seconds the plugin fetches all `in_progress` work items, matches each against the tabs of the current workspace (the tab label exactly equals the work-item ID), and releases any claim with no matching tab so it re-enters the dispatchable pool: `in_review` items complete, dependency-blocked items are marked `blocked`, everything else returns to `open` at its claimed stage. The check also runs immediately when the worklog tab regains focus, and is fully visibility-gated (a hidden tab spawns zero `wl`/`herdr` processes). The same exact-label tab set drives the selection list's **in-progress marker**: an item with an exact-match tab is shown as `in-progress` (marker + Group N) even when its worklog status is still `open`, and loses the marker when the tab closes. See [Hydrator](#hydrator-self-healing-in_progress-claims-wl-0msojlzd9004p8pi). - **Mode-switch worker (activity-gated proxy mode switching)** — Automatically switches the llama-proxy between fast (cloud) and cheap (local) modes: agent-route commands fire an immediate fast switch (fail-open), while a full operator-idle window plus a proxy-idle check triggers the cheap switch (fail-closed). The proxy URL reuses `downtimeProxyUrl` and the plugin's switches are manual overrides that the proxy's own time schedule reclaims. See [Mode-switch worker](#mode-switch-worker-activity-gated-proxy-mode-switching-wl-0msn3fwv5008kqe9). - **Agent status tracking** — When an agent command carrying a work-item ID is dispatched, the worklist records which pi agent pane is attached to that item — including the dispatched command — persisted to the gitignored `.worklog/agent-panes.json`, shared across worklist panes. The list shows a live agent-status icon at the start of each row's icon prefix: 🟢 working, ⛔ blocked, ⚪ idle. Done/closed items (and items without an agent) show no icon. The icon is a fixed-width slot so the item-ID column never shifts. Hovering a pane-associated row shows a tooltip with the work-item metadata (including the recorded command and pane start time). See [Agent status icons](#agent-status-icons). - **Open Pi Agent action** — The plugin provides an action to open a fresh interactive pi session pane - **Tab-based opening** — The worklist opens in a new tab in the current workspace, providing full-screen access without reducing space for existing panes -- **Worklog tab** — The `open-podcast-editor-tab` action (bound to `prefix+l`) opens the worklist pane in a new tab and renames that tab to **`Worklog`** so it is instantly recognisable in the tab row among other open tabs. See [Worklog tab](#worklog-tab). +- **Worklog tab** — The `open-worklog-tab` action (bound to `prefix+l`) opens the worklist pane in a new tab and renames that tab to **`Worklog`** so it is instantly recognisable in the tab row among other open tabs. See [Worklog tab](#worklog-tab). - **Quit** — Press `q` to exit - **Metadata panel** — The selected item's metadata (ID, title, status, stage, priority, type, risk, effort, tags, audit info, and more, plus a **description preview** — first up-to-3 lines of the item's description) is shown in a panel below the list. The list takes as much vertical space as its content needs, up to the full pane height; the metadata panel fills whatever space remains — expanding when the list is short, and sitting at the bottom (minimum 3 rows) when the list is long. The panel scrolls independently with `m`/`M` (down/up) so long metadata never affects list navigation. See [Metadata panel](#metadata-panel). - **Command log** — Every plugin-dispatched command that targets a work item (via `` substitution or an explicit item ID) is recorded to a local JSON log. For `in_progress` items the panel shows the **last command** at the bottom, so you can see exactly what was last dispatched against the item. See [Command log](#command-log). @@ -55,8 +55,8 @@ cd packages/herdr && npm run build `npm run build` runs `scripts/install-herdr.sh` via the root `postbuild` hook. The script: - Links the plugin from the **main checkout** with `herdr plugin link /packages/herdr/herdr-plugin.toml`. When the build runs inside a linked git worktree (as happens during `/skill:implement`), the main checkout is resolved via `git worktree list` so the global herdr plugin is never registered against a transient worktree path — a worktree-based link would dangle once the worktree is cleaned up and silently break `prefix+l` (WL-0MSRG481O007QVEA). `herdr plugin link` updates an existing link in place, so a stale link from an older build is corrected automatically. -- Inserts the `prefix+l` → `worklog-selection-list.open-podcast-editor-tab` keybinding into your herdr config (`~/.config/herdr/config.toml`, or `$HERDR_CONFIG_PATH` when set) **only if** it is not already present — re-running the build never creates duplicate keybindings. -- Migrates a legacy `prefix+l` → `worklog-selection-list.open-worklist` binding in-place to the new action, so the Worklog tab name takes effect without a manual config edit. +- Inserts the `prefix+l` → `worklog-selection-list.open-worklog-tab` keybinding into your herdr config (`~/.config/herdr/config.toml`, or `$HERDR_CONFIG_PATH` when set) **only if** it is not already present — re-running the build never creates duplicate keybindings. +- Migrates a legacy `prefix+l` → `worklog-selection-list.open-worklist` (or intermediate `open-podcast-editor-tab`) binding in-place to the new action, so the Worklog tab name takes effect without a manual config edit. When the new binding is already present, any leftover legacy block is purged instead, so a stale duplicate `prefix+l` key cannot survive a partially-migrated config (WL-0MSRG4828007ITI4). - Warns (without failing the build) when `herdr` is not on PATH or the config cannot be written, so `npm run build` succeeds in CI/offline environments. Alternatively, `npm run install` (or `npm run install:plugin` for the herdr step alone) runs the same install script outside of a build context. @@ -70,7 +70,7 @@ herdr plugin link packages/herdr/herdr-plugin.toml # Add the keybinding to ~/.config/herdr/config.toml if not already present # [[keys.command]] # key = "prefix+l" -# command = "herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab" +# command = "herdr plugin action invoke worklog-selection-list.open-worklog-tab" # description = "Open the Worklog tab (Worklog work item selection pane)." ``` @@ -243,14 +243,14 @@ Pressing `Esc` dismisses the tooltip until the mouse leaves and re-enters the li herdr plugin action run worklog-selection-list open-worklist # Open the Worklog tab (worklist pane renamed to "Worklog") -herdr plugin action run worklog-selection-list open-podcast-editor-tab +herdr plugin action run worklog-selection-list open-worklog-tab ``` ### Worklog tab -`herdr plugin pane open` has no tab-title option, so a worklist pane opened in a tab gets a generated numeric label. The `open-podcast-editor-tab` action (bound to `prefix+l`) fixes that: it opens the worklist pane in a new tab exactly like `open-worklist` (same flags and working-directory resolution) and then renames the created tab to **`Worklog`** via `herdr tab rename`, so the worklog pane is instantly recognisable among several open tabs. Each press opens a new tab — there is deliberately no focus-if-open toggle. +`herdr plugin pane open` has no tab-title option, so a worklist pane opened in a tab gets a generated numeric label. The `open-worklog-tab` action (bound to `prefix+l`) fixes that: it opens the worklist pane in a new tab exactly like `open-worklist` (same flags and working-directory resolution) and then renames the created tab to **`Worklog`** via `herdr tab rename`, so the worklog pane is instantly recognisable among several open tabs. Each press opens a new tab — there is deliberately no focus-if-open toggle. -The underlying script (`scripts/open-podcast-editor-tab.sh`) fails fast with a clear error and non-zero exit when the herdr CLI is unavailable, the pane open fails, or the created `tab_id` cannot be parsed — a missing rename is never silently skipped. The tab label can be overridden with the `TAB_LABEL` environment variable. +The underlying script (`scripts/open-worklog-tab.sh`) fails fast with a clear error and non-zero exit when the herdr CLI is unavailable, the pane open fails, or the created `tab_id` cannot be parsed — a missing rename is never silently skipped. The tab label can be overridden with the `TAB_LABEL` environment variable. ### Configuration @@ -277,26 +277,50 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in ### Hydrator (self-healing `in_progress` claims, WL-0MSOJLZD9004P8PI) **Self-heals the work queue.** Work items claimed as `in_progress` are -correctly excluded from downtime dispatch (single-flight — an active pane -owns them), but a claim with no live pane (pane closed, session died, +correctly excluded from downtime dispatch (single-flight — an active claim +owns them), but a claim with no matching tab (tab closed, session died, claim abandoned) lingers at the top of the queue and is never re-selected. The hydrator detects those claims and releases them: - **Cadence** — every 30 s (scheduler task `hydrate`, single-flight + watchdog `HYDRATOR_RUN_TIMEOUT_MS`); the check also runs **immediately** when the worklog tab regains focus (same runner as the tick). -- **Signal** — fetch `wl list --status in-progress`, fetch `herdr pane list`, - and match each item against a live pane in the **current workspace** whose - title contains the work-item ID (the `pane-title.ts` builders preserve the - ID under truncation). No matching pane → the claim is stale. +- **Signal** — fetch `wl list --status in-progress`, fetch + `herdr tab list --workspace `, and match each item against a tab in + the **current workspace** whose label **exactly equals** the work-item ID; a + label that merely contains the ID does not count. No matching tab → the + claim is stale. +- **Pane-title ID survival (F0)** — matching is keyed on the work-item ID, so + the ID must survive wherever it appears. Every pane spawned for an item + gets a title ending in ` - WL-` (manual, shell, and downtime builders in + `pane-title.ts`), and the 60-char truncation always preserves that trailing + suffix (`truncatePaneTitlePreservingSuffix` — the pane-title amendment + WL-0MTF5WNRB009RHRU), so the ID stays visible and attributable in every + pane title. The hydrator itself matches on **exact tab labels** — item-ID + tabs are labelled with the exact ID (never truncated) by the shared + dispatcher-anchor builders — so a long pane title can never break the + match; the suffix-preservation rule is what keeps the ID as the + association key an operator scans for. - **Release semantics** — `in_review` items → `completed`; items with an active outbound dependency blocker → `blocked`; otherwise → `open` at the claimed stage (folded-in no-activity/claim-age timeout, WL-0MTTSWE0G008M1VA; - a no-pane claim is released regardless of activity age). Stage is repaired + a no-tab claim is released regardless of activity age). Stage is repaired to a status-compatible value when needed. - **Safety** — the cycle is fail-open: an unavailable/unparseable - `wl`/`herdr` call aborts WITHOUT demoting; a live-pane item is never + `wl`/`herdr` call aborts WITHOUT demoting; an item with a live tab is never touched; a hidden tab spawns zero processes. +- **Selection-list marker** (WL-0MV1BWETT005GSUJ) — the same exact-label tab + set drives the selection list's in-progress marker: an item with an + exact-match tab is rendered `in-progress` (marker + Group N) even when its + worklog status is still `open`, and loses the marker when the tab closes. + Terminal `in_review`/`completed` items keep their own review marker. Because + opening/closing a work-item tab does not touch the worklog DB (so the + DB-change-gated refresh tick cannot update the marker for it), the marker is + applied on the initial load and every full refresh, and re-applied by the + dedicated `active-tab-marker` scheduler task on the hydrator cadence + (`HYDRATOR_INTERVAL_MS`) and on focus-resume. That task spawns only + `herdr tab list` (never `wl`), preserving the zero-`wl`-when-idle guarantee; + the helper is idempotent, so a closed tab clears the marker. See [`docs/dev/herdr-hydrator.md`](../../docs/dev/herdr-hydrator.md) for the design and test map. @@ -1013,6 +1037,42 @@ proxy idle state: independent scheduler task, so an item dispatched during downtime is served by the cheap pool. The scheduler task remains as the fallback when the downtime worker is not polling (disabled, non-leader, or paused). +- **Model-cooldown gate (WL-0MV5638ZS005I5XQ, WL-0MV5639X70029BOI)** — before + the cheap decision the leader reads the machine-wide cooldown signal + (`readModelCooldowns` from `@worklog/shared/model-cooldown`, the same + coordination directory as `mode-activity.json`). While any entry is active + (`cooldownUntil` in the future) the worker holds **fast**: a pane sitting in + proxy backoff holds no slot, so the proxy looks idle and the cheap switch + would add concurrency (cheap has more slots than fast) during a known + provider-exhaustion window. The hold is bounded by `cooldownUntil` (no latch) + and machine-wide, so a cooldown recorded in any worklog root suppresses the + shared switch. + - **Fail-closed, logged:** an unreadable or corrupt signal (`null`) holds + fast rather than switching on an unverifiable signal, and logs the + condition (`[mode-switch] model-cooldown signal unreadable/corrupt — + holding fast (fail-closed)`). + - **Absent/empty is not fail-closed:** a missing file or an empty document + (`[]`) means "nothing recorded", so the normal cheap decision runs. + - **Bounded default (WL-0MV5639X70029BOI):** the marker's `cooldownUntil` is + derived from the server `Retry-After` hint when available; when the hint is + absent or malformed the TUI recovery loop uses a bounded default + (`DEFAULT_RECOVERY_CONFIG.cooldown`, documented in + `packages/tui/extensions/Worklog/lib/recovery/error-patterns.ts`) clamped + into `[floorMs, ceilingMs]` (`1000 ms` – `6 h`, default `10 min`). The + default is never zero (so the gate can never open prematurely) and never + unbounded (so the proxy can never be pinned in fast mode). + - **Fast path unaffected:** `onOperatorCommand` never reads the cooldown + signal, so a cooldown can never suppress the fast-on-command switch. + - **Contract of record:** the on-disk format (file `model-cooldowns.json`, + `schemaVersion: 1`, entry fields, tolerant/fail-closed read rules) is + documented once in the header of + `packages/shared/src/model-cooldown.ts`; the recovery-loop code comments and + `docs/design/mode-switch-worker.md` cross-reference it rather than restating + the field list. + - **Must not regress (WL-0MUWIHUL2000JEFR):** this gate is **not** the + withdrawn client drain — no client-side slot-count completion gate, no + `drainPaused` dispatch pause, no unbounded latch. See + `docs/design/mode-switch-worker.md`. New settings (all optional): @@ -1371,6 +1431,113 @@ process churn and memory pressure (WL-0MSB1N0HB0007N6N). the visibility gate (approved plan, WL-0MSJNJPRM009RM35). - **No settings toggle** — pause-when-hidden is always on. +### Event-driven DB-change watch (fast refresh after agent writes) + +When an agent writes to a work item (`wl update`, `wl close`, `wl comment`, +…), the pane reflects the change within ~2 s instead of waiting for the 30 s +auto-refresh tick (parent `WL-0MV02VA29009WBEN`). + +- **Watch target** — `db-change.ts` `watchDbChanges()` watches the same + cross-process signal the `DbChangeTracker` reads: the per-worklog state + counter (`/state/.json`), which `wl` bumps on + every write and after sync (write-only from `wl`'s perspective). The + SQLite `worklog.db` / `-wal` / `-shm` files are deliberately NOT watched: + the pane's own in-process SQLite reads touch those files, so watching them + created a self-sustaining refresh loop (producer-rejection fix for + `WL-0MV02VA29009WBEN`). +- **Debounced, single-flight** — a burst of write events is coalesced into at + most one trailing callback, which feeds the existing gated `doRefresh` + (typing + visibility gates, then `doRefresh`'s own single-flight + trailing + coalescing). The watch never bypasses those guards, so a burst cannot cause + a refresh storm. On a successful trigger it also consumes the + `DbChangeTracker` baseline, so the next periodic tick sees the change as + already handled and stays a no-op (`WL-0MSJ1OLTL009N4IQ`). When a gate + skips the refresh (overlay open / tab hidden) the change is deliberately + left unconsumed so the fallback tick refreshes once the gate clears. +- **Fail-open** — if no watch target can be established (`fs.watch` throws), + the watch reports `active === false`, the pane keeps running, and the 30 s + tick remains the fallback. The zero-spawn-when-idle guarantee is preserved + because the tick is still gated by `isDbUnchanged`. +- **Teardown** — the watch is disposed on pane exit, closing every + `fs.watch` handle and clearing any pending debounce timer. + +#### Verification harness + +The event-driven path is guarded by automated tests: + +- `packages/herdr/src/db-change.test.ts` — `watchDbChanges` unit contract: + debounce/coalescing, per-file filtering, fail-open, teardown, and a real + `fs.watch` write test. +- `packages/herdr/src/worklist-watch.test.ts` — integration: a state-counter + write (the same signal a `wl` write bumps) triggers a refresh within 2 s, + well before the 30 s tick; unrelated writes do not; an unavailable watch + fails open and the idle no-spawn guarantee holds; the watch is disposed on + exit. It also pins the gated-watch contract (`WL-0MV5CBPI400508MK`): a + watch-triggered refresh consumes the DB-change signal so the next 30 s tick + is a no-op, the typing and visibility gates skip watch refreshes, and a + burst of watch callbacks during an in-flight refresh coalesces into one + trailing refresh. + +Run them directly: + +```bash +npx vitest run packages/herdr/src/db-change.test.ts packages/herdr/src/worklist-watch.test.ts +``` + +#### Refresh benchmark + +`bench/herdr-refresh-perf.ts` seeds a real temp worklog (≥ 1000 items and +≥ 20 expanded parents), drives the default-view refresh data path (`wl next` + +mandatory subsets, actionable count, review-queue state, and one `wl list +--parent` per expanded parent), and reports warm/cold latency plus the number +of `wl` child processes spawned: + +```bash +npm run benchmark:herdr-refresh # text output +npm run benchmark:herdr-refresh:json # JSON for agents + +# smaller/faster run +npx tsx bench/herdr-refresh-perf.ts --items 200 --parents 10 --iterations 1 +``` + +The ≤ 2 s target is host-dependent, so the benchmark reports `withinTarget` +rather than exiting non-zero on it; the paired `wl` spawn count is the +regression guard. The harness contract is covered by +`tests/bench/herdr-refresh-perf.test.ts`. + +#### Cold-refresh cost (fewer `wl` spawns) + +The default-view refresh once spawned ~5 `wl` processes per cycle (`wl next` + +two mandatory subsets + actionable count + review-queue state) plus one +`wl list --parent` per expanded parent. `fetcher.ts` now cuts that to the +minimum (`WL-0MV5CBQ6L005SPAG`): + +- **In-process SQLite reads** — when a worklog dir is resolved and the SQLite + store is available (`better-sqlite3` via `@worklog/shared`), the actionable + count (`fetchActionableCount`) and every expanded parent's children + (`fetchChildrenForItems`) are read in-process from a **fresh** + `WorklogDatabase`. The store is loaded lazily, so it stays out of the plugin + import graph until a refresh actually needs it, and the in-memory query cache + is never trusted across a refresh boundary. When no in-process read is + possible the `wl` CLI spawn is the fallback, so behaviour is unchanged on + hosts without the store. +- **Concurrent base + mandatory** — the base `wl next` selection and the + mandatory subsets are requested concurrently (`Promise.all`). When a count + window applies, the review-queue query and the mandatory `in_review` subset + build an **identical** argument vector (`reviewQueueArgs()`), so the + in-flight fetch memo (`WL-0MSGAEPOJ002824S`) deduplicates them into one + spawn. +- **One read for all expanded parents** — `fetchChildrenForItems()` resolves + every expanded parent in a single in-process query instead of one + `wl list --parent` per parent; the per-parent CLI calls remain only as the + fallback. + +Measured on the benchmark harness (≥ 1000 items, ≥ 20 expanded parents), the +cold default-view refresh dropped from **25 → 3** `wl` spawns (~88 %), which +brings the representative end-to-end refresh inside the ≤ 2 s target (see the +`WL-0MV5CBQ6L005SPAG` verification note and +[docs/dev/herdr-event-driven-refresh.md](../../docs/dev/herdr-event-driven-refresh.md)). + ### Typing gate (pause background work while a form is open) While any text-input overlay is open, the auto-refresh and auto-sync timer @@ -1439,9 +1606,19 @@ regardless of the `browseItemCount` setting: - Items with `priority=critical` are always included. - Items with `status=completed` **and** `stage=in_review` are always included. +- Items with `status=blocked` at any non-done stage are always included + (WL-0MV71WBHB007D8ZE): a blocked in_review item is release-blocking (the + ship release gate queries `wl list --stage in_review` with no status filter) + and is fetched as a mandatory subset (`wl list --status blocked --root-only`) + so it is never cut by the `browseItemCount` window or a low `-n` ranking. +- Dependency-blocked items are **not filtered out** of the default list + (WL-0MV6TFX0600145M2): `fetchNextItems` passes `--include-blocked` to + `wl next`, so blocked backlog work stays visible (rendered with the ⛔ + status icon). The implement-tier downtime dispatch lookup deliberately keeps + `wl next`'s default exclusion. - The `browseItemCount` limit applies only to the remaining "other" items. The number of "other" slots is `browseItemCount − (critical count) − - (completed/in_review count)`, floored at zero. + (completed/in_review count) − (blocked count)`, floored at zero. - When critical + completed/in_review items alone meet or exceed `browseItemCount`, all of them are shown anyway (no hard cap on the mandatory set) — the total may exceed the configured count. @@ -1463,11 +1640,16 @@ status rule (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW): `blocked`, `in-progress`, or `completed` are excluded even when their stage matches. - The **in_review** stage (press `f` + `s` + `r`, or `/wl review`) is the - exception: it shows items with status `completed`, `in-progress`, or - `open`. Per the project workflow, advancing an item to `in_review` sets - its status to `completed` (or leaves it `in-progress` while being + exception: it shows items with status `completed`, `in-progress`, `open`, + or `blocked`. Per the project workflow, advancing an item to `in_review` + sets its status to `completed` (or leaves it `in-progress` while being re-worked after review feedback) — restricting the filter to - `status=open` would empty the review queue (WL-0MSKCRX730052IIW). + `status=open` would empty the review queue (WL-0MSKCRX730052IIW). A + `status=blocked` in_review item is included (WL-0MV70XY6D008013C) because + the ship release gate queries `wl list --stage in_review` with **no** + status filter and treats such an item as release-blocking: the review + filter must show it, or the selection list and the release gate disagree + about what is in the review queue. - Children stay hidden in the top-level list and remain reachable via expand (Tab), exactly as in the unfiltered view. - Results follow the standard list order (sortIndex), matching the @@ -1624,8 +1806,8 @@ the file-path-partitioned `Group N` lists alongside unknown/custom stages (including the removed `in_progress`/`completed` stage values), so legacy rows still render deterministically. -**In Review** items are sorted within the group using a deterministic -**6-bucket predicate** (WL-0MSLPM5ZB003TADT): +**In Review** items are sorted within the group by priority first, then by a +deterministic **6-bucket audit predicate** (WL-0MSLPM5ZB003TADT): | Bucket | Meaning | |--------|------------------------------------| @@ -1638,10 +1820,11 @@ stage values), so legacy rows still render deterministically. A "fresh" audit satisfies `auditedAt > updatedAt - 60 s` (one-sided, via the shared `isAuditFresh` predicate); otherwise it is stale. -Within the same bucket items are ordered by priority (high → medium → low), -then by `updatedAt` (older first), then by `id` as a tie-break. The same -predicate is shared by the Herdr worklist, `wl next --groups`, and -`wl list --stage in_review`. +**Priority is the primary sort key**: all high-priority items are listed +before medium-priority items, then low. Within each priority band the six +buckets above order the items, then by `updatedAt` (older first), then by `id` +as a tie-break (WL-0MUWDW0N6003KF6G). The same predicate is shared by the +Herdr worklist, `wl next --groups`, and `wl list --stage in_review`. ## Markdown viewer @@ -1859,7 +2042,7 @@ packages/herdr/ │ └── worklist.ts # List state, rendering, keyboard handling, command output ├── scripts/ │ ├── open.sh # Open the worklist pane -│ ├── open-podcast-editor-tab.sh # Open the worklist pane in a tab renamed "Worklog" +│ ├── open-worklog-tab.sh # Open the worklist pane in a tab renamed "Worklog" │ ├── toggle.sh # Toggle the worklist pane │ ├── send-to-pi.sh # Split pane to right, launch pi with agent command (via run-pi-agent.sh) │ ├── run-in-pane.sh # Run a shell command visibly in a new pane (stays open for inspection) @@ -1882,7 +2065,7 @@ packages/herdr/ - **No-pane dispatch via `open_pane: false`** — A shortcut entry may carry an optional `open_pane: false` flag (WL-0MSJLD1I70045ZUL) to run its command **in the background without opening a pane**: shell (`!!`/`!`) commands execute via detached `bash -c` and agent commands run headless (`pi -p --mode json`, honoring the entry's `model`), with stdout/stderr captured to a per-run log file under `/herdr-background-logs/` (the path is written to stderr so it can be located for inspection). No pane is created, so the work-item ↔ pane association is skipped for agent commands. The bundled quiet state-change shortcuts use it: `a-y` audit approve, `a-r` audit reject, `u-p-*` priority updates, and `x-c`/`x-d` close/delete — they complete silently and the worklist refresh shows the updated state. If a background command exits non-zero, a **failure toast** is shown with the command, its exit code (or termination signal) and a short excerpt of its output (WL-0MUEBQLRD00288VV); the full output remains in the per-run log file. Shortcuts without the flag (all other bundled entries) open a pane exactly as today. - **Focused shortcuts via `focus: true`** — A shortcut entry may carry an optional `focus: true` flag (WL-0MT70LC6B009TL3Q) to **focus the newly opened pane** immediately after it spawns. When absent or `false` (the default) the selection list keeps focus (the behaviour above). The flag is orthogonal to `open_pane: false` (no pane → focus is moot) and invalid values are logged and treated as absent (no-focus). Focus is applied to the pane's **tab** as well as the pane itself (`run-in-pane.sh` resolves the new pane's tab via `herdr pane get` and runs `herdr tab focus` before the pane zoom, WL-0MURJ0LFH002O95I): a focused ID-carrying dispatch is anchored to the work-item-ID tab (see [Interactive pane placement](#interactive-pane-placement-work-item-id-tabs)), and `pane zoom` alone cannot switch tabs — without the tab focus the new pane would open in a background tab and the selection list would keep focus. The bundled entries with the flag are `P n` (new Pi session — blank `/prompt:`) and `r i` (producer interview — `wl interview`): `P n` puts the cursor straight in the new Pi pane for immediate typing, and `r i` puts the cursor straight in the interview pane so the producer can answer outstanding questions without an extra `prefix+o` step. Any future shortcut can opt in with the same field without a schema change. - **Model lease release on pane close** — Pi agent panes launched by `send-to-pi.sh` or `open-pi-agent.sh` run pi via `shared/run-pi-agent.sh`, which gives the session a deterministic id (`pi --session-id herdr---`) and registers EXIT/TERM/HUP/INT traps. When the pi session ends — normal exit or pane close (`prefix+x`) — the wrapper runs `shared/release-lease-on-exit.mjs`, which posts to the Local Proxy's `POST {baseUrl}/leases/release` using the **same shared implementation** as the Pi extension (`@worklog/shared/lease-release`), so the proxy's dispatch lease is reclaimed promptly instead of lingering until timeout. The release is strictly best-effort: failures (unreachable proxy, missing `~/.pi/agent/models.json`, unconfigured provider) are silently discarded, a 5s request timeout bounds the pane-close path, and the wrapper always propagates pi's exit status (WL-0MSGI7UIH008USVB). -- **Worklog tab naming** — `herdr plugin pane open` creates tabs with generated numeric labels. The `open-podcast-editor-tab` action wraps the same pane-open command and renames the created tab to "Worklog" via `herdr tab rename` (socket API, not session-state editing), so the worklog pane is instantly recognisable in the tab row. Each press still opens a new tab; only the label changes. +- **Worklog tab naming** — `herdr plugin pane open` creates tabs with generated numeric labels. The `open-worklog-tab` action wraps the same pane-open command and renames the created tab to "Worklog" via `herdr tab rename` (socket API, not session-state editing), so the worklog pane is instantly recognisable in the tab row. Each press still opens a new tab; only the label changes. - **Model selection per shortcut** — Each LLM-bound shortcut entry in `shortcuts.json` may carry an optional `model` field (a pi model pattern such as `plan`, `code`, or `author`). When the command is dispatched to the agent channel, `--model ` is forwarded to the spawned `pi` CLI (e.g. `pi --model code '/skill:implement '`), so every workflow runs on an appropriately specialised model without manual model switching. Agent-bound entries without a `model` field default to `plan`; shell (`!!`) and `/wl` filter entries never carry a model and never receive a `--model` flag. The default mapping in `src/shortcuts.json`: `/plan`, `/intake`, `/skill:audit`, `/prompt:` → `plan`; `/skill:implement` → `code`. - **Free-form prompts via `/prompt:`** — Commands starting with `/prompt:` are also routed to the agent pane, but the `/prompt:` routing prefix is stripped before `send-to-pi.sh` runs, so pi receives only the bare prompt text (e.g. `pi "What are the audit gaps reported in the most recent audit for WL-123"`). This lets a chord shortcut open a new pi instance with an arbitrary injected prompt, not just a skill/workflow invocation. The `P-p` chord opens the command input form so you can type any free-form prompt, `P-a` opens pi with `What are the audit gaps reported in the most recent audit for ` (the selected item's ID is substituted automatically), and `P-n` opens a brand-new blank session (`/prompt:` with an empty prompt — no form dialog, no injected text, and no work-item association; the new Pi pane is **focused** so you can start typing immediately — see **Focused shortcuts via `focus: true`** above). Edit `src/shortcuts.json` to bind your own prompt text to any free chord. - **Correct project directory for new panes** — Panes created by `send-to-pi.sh`, `open-pi-agent.sh`, and `run-in-pane.sh` are started in the correct project root. Herdr's `follow` CWD policy would otherwise inherit the source pane's CWD (the plugin directory), so each script resolves a target CWD (`--cwd` arg > `HERDR_RESOLVED_CWD` > `$PWD`) and applies it in both launch modes: `--no-resize` passes it to `herdr pane split --cwd`, and the default resize mode forwards it to `grid.py --cwd` which includes it in the `pane.split` RPC params. The entry point passes the resolved worklog root (`wlRoot`) so skills, `wl` commands, and relative paths operate on the user's project rather than the plugin's installation directory. diff --git a/packages/herdr/herdr-plugin.toml b/packages/herdr/herdr-plugin.toml index 5f9b80120..f78a9a9c2 100644 --- a/packages/herdr/herdr-plugin.toml +++ b/packages/herdr/herdr-plugin.toml @@ -37,10 +37,10 @@ command = ["bash", "scripts/open.sh"] # Action: open the worklog tab (worklist pane renamed to "Worklog") [[actions]] -id = "open-podcast-editor-tab" +id = "open-worklog-tab" title = "Open Worklog tab" description = "Open the Worklog work item selection pane in a new tab renamed to 'Worklog'." -command = ["bash", "scripts/open-podcast-editor-tab.sh"] +command = ["bash", "scripts/open-worklog-tab.sh"] # Action: open pi agent pane [[actions]] diff --git a/packages/herdr/scripts/open-podcast-editor-tab.sh b/packages/herdr/scripts/open-worklog-tab.sh similarity index 98% rename from packages/herdr/scripts/open-podcast-editor-tab.sh rename to packages/herdr/scripts/open-worklog-tab.sh index f058308eb..710d63c3f 100755 --- a/packages/herdr/scripts/open-podcast-editor-tab.sh +++ b/packages/herdr/scripts/open-worklog-tab.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# open-podcast-editor-tab.sh — Open the Worklog tab +# open-worklog-tab.sh — Open the Worklog tab # # Opens the Worklog work item selection pane in a NEW TAB and renames # that tab to "Worklog" so it is instantly recognisable in the tab row @@ -13,7 +13,7 @@ # Each invocation opens a new tab — there is deliberately no # focus-if-open toggle; only the tab label changes. # -# Usage: open-podcast-editor-tab.sh [cwd] +# Usage: open-worklog-tab.sh [cwd] # cwd - Optional working directory for the pane (default: resolved from # pane metadata / $PWD, same priority order as open.sh) # diff --git a/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh b/packages/herdr/scripts/tests/test_open_worklog_tab.sh similarity index 91% rename from packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh rename to packages/herdr/scripts/tests/test_open_worklog_tab.sh index 7524ea474..9a9b8d0f9 100755 --- a/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh +++ b/packages/herdr/scripts/tests/test_open_worklog_tab.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# test_open_podcast_editor_tab.sh — tests for open-podcast-editor-tab.sh +# test_open_worklog_tab.sh — tests for open-worklog-tab.sh # # Covers the parse-and-rename wiring with a mock herdr CLI: # - happy path: pane opened, tab_id parsed from the pane-open JSON, @@ -9,12 +9,12 @@ # - `plugin pane open` failure → clear error, non-zero exit # # Run from the repo root (or anywhere): -# bash packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh +# bash packages/herdr/scripts/tests/test_open_worklog_tab.sh set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -OPEN_PODCAST_EDITOR="$SCRIPT_DIR/open-podcast-editor-tab.sh" +OPEN_WORKLOG_TAB="$SCRIPT_DIR/open-worklog-tab.sh" PASS=0 FAIL=0 @@ -119,12 +119,12 @@ chmod +x "$MOCK_HERDR_OPEN_FAILS" run_script() { rm -f "$HERDR_LOG" - HERDR_BIN_PATH="$1" bash "$OPEN_PODCAST_EDITOR" 2>&1 + HERDR_BIN_PATH="$1" bash "$OPEN_WORKLOG_TAB" 2>&1 } echo "=== Test: script exists and is executable ===" -[ -f "$OPEN_PODCAST_EDITOR" ] && pass "open-podcast-editor-tab.sh exists" || fail "open-podcast-editor-tab.sh missing" -[ -x "$OPEN_PODCAST_EDITOR" ] && pass "open-podcast-editor-tab.sh executable" || fail "open-podcast-editor-tab.sh not executable" +[ -f "$OPEN_WORKLOG_TAB" ] && pass "open-worklog-tab.sh exists" || fail "open-worklog-tab.sh missing" +[ -x "$OPEN_WORKLOG_TAB" ] && pass "open-worklog-tab.sh executable" || fail "open-worklog-tab.sh not executable" echo "" echo "=== Test: happy path — opens pane, parses tab_id, renames tab ===" @@ -178,7 +178,7 @@ fi echo "" echo "=== Test: unparseable tab_id → clear error, non-zero exit, no rename ===" rm -f "$HERDR_LOG" -out="$(HERDR_BIN_PATH="$MOCK_HERDR_BAD_JSON" bash "$OPEN_PODCAST_EDITOR" 2>&1)" +out="$(HERDR_BIN_PATH="$MOCK_HERDR_BAD_JSON" bash "$OPEN_WORKLOG_TAB" 2>&1)" RC=$? if [ "$RC" -ne 0 ]; then diff --git a/packages/herdr/scripts/tests/test_run_in_pane.sh b/packages/herdr/scripts/tests/test_run_in_pane.sh index ceadd648d..fe5761507 100755 --- a/packages/herdr/scripts/tests/test_run_in_pane.sh +++ b/packages/herdr/scripts/tests/test_run_in_pane.sh @@ -18,6 +18,7 @@ # bash packages/herdr/scripts/tests/test_run_in_pane.sh set -uo pipefail +set -m # Enable job control so background processes get their own process group SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" RUN_IN_PANE="$SCRIPT_DIR/run-in-pane.sh" @@ -27,20 +28,56 @@ FAIL=0 pass() { PASS=$((PASS+1)); echo " PASS: $1"; } fail() { FAIL=$((FAIL+1)); echo " FAIL: $1"; } +# Number of live (non-zombie) processes in process group $1. `set -m` makes +# every background wrapper its own process-group leader (PGID == PID), so the +# group is the unit the test cleans up. +group_member_count() { + ps -eo pgid=,stat= 2>/dev/null \ + | awk -v g="$1" '$1 == g && $2 !~ /Z/ { n++ } END { print n + 0 }' +} + +# Wait up to ~1s for every member of process group $1 to exit. Returns 0 once +# the group is empty, 1 otherwise. +wait_for_group_exit() { + local i + for i in 1 2 3 4 5; do + [ "$(group_member_count "$1")" -eq 0 ] && return 0 + sleep 0.2 + done + [ "$(group_member_count "$1")" -eq 0 ] +} + cleanup() { - # Kill any remaining background test processes + # Kill entire process groups (parent + grandchildren like sleep infinity). + # The `run-in-pane.sh --exec` wrapper spawns `sleep infinity` to keep its + # pane alive; killing only the wrapper PID reparents that grandchild to + # init and it keeps the test's stdout open forever. A negative-PID kill + # reaches the whole group, so the grandchild dies with the wrapper + # (WL-0MUYTCF4D000WVKR). + local pid for pid in "${TEST_PIDS[@]:-}"; do - kill "$pid" 2>/dev/null + [ -n "$pid" ] || continue + kill -TERM -- "-$pid" 2>/dev/null || true + done + # Reap the group leaders and force-kill any survivor (e.g. a `sleep + # infinity` that outlived the TERM), so no orphan outlives the test. + for pid in "${TEST_PIDS[@]:-}"; do + [ -n "$pid" ] || continue wait "$pid" 2>/dev/null || true + kill -KILL -- "-$pid" 2>/dev/null || true done } trap cleanup EXIT +# External termination (e.g. `timeout 10 bash test_run_in_pane.sh`) must also +# run cleanup, otherwise the background wrapper group leaks (AC3). +trap 'exit 130' INT +trap 'exit 143' TERM TEST_PIDS=() # ── Test: non-TTY + HERDR_PANE_ID stays alive with ~0% CPU ────────────── echo "=== Test: --exec with HERDR_PANE_ID blocks (0% CPU, stays alive) ===" -HERDR_PANE_ID="test-pane-1" bash "$RUN_IN_PANE" --exec "echo test_output" "test-pane-1" < /dev/null & +HERDR_PANE_ID="test-pane-1" bash "$RUN_IN_PANE" --exec "echo test_output" "test-pane-1" < /dev/null > /dev/null 2>&1 & PID=$! TEST_PIDS+=($PID) @@ -61,10 +98,20 @@ else fail "CPU usage too high ($CPU%) — likely busy-looping" fi -# Kill the background process -kill "$PID" 2>/dev/null +# Kill the wrapper AND its whole process group so the `sleep infinity` +# grandchild does not leak (WL-0MUYTCF4D000WVKR). `$PID` is the group +# leader under `set -m`. The entry stays in TEST_PIDS so the EXIT trap can +# force-kill any survivor as a backstop. +kill -TERM -- "-$PID" 2>/dev/null || true wait "$PID" 2>/dev/null || true -TEST_PIDS=() + +# AC1 regression: no process — in particular no reparented `sleep infinity` — +# may outlive the process-group kill. +if wait_for_group_exit "$PID"; then + pass "no orphaned process survives the wrapper process-group kill" +else + fail "orphaned process(es) survived the wrapper process-group kill (PGID $PID)" +fi # ── Test: non-TTY + no HERDR_PANE_ID exits immediately ────────────────── echo "" diff --git a/packages/herdr/shared/README.md b/packages/herdr/shared/README.md index f6f4e8946..edc26369d 100644 --- a/packages/herdr/shared/README.md +++ b/packages/herdr/shared/README.md @@ -12,6 +12,8 @@ repositories (e.g., ContextHub, open_source_llm). | `open-pi-agent.sh` | Open an interactive Pi session in a new pane. Generalized with `--pane-name`, `--focus`/`--no-focus`, `--resize`/`--no-resize`, and `--cwd` options. | | `grid.py` | Python grid-rebalance helper: grows an even-completion 2-column grid to the right of the anchor pane (validated against herdr 0.7.5). Accepts an optional `--cwd ` forwarded to the `pane.split` RPC. | | `herdr-agent-state-protocol.md` | Specification for Herdr Unix socket agent state reporting protocol. | +| `run-pi-agent.sh` | Run a pi agent session in a Herdr pane with automatic Local Proxy model lease release on exit. | +| `release-lease-on-exit.mjs` | Release the Local Proxy model lease for a terminated pi session (best-effort, fire-and-forget). | ## Pane-launch modes: `--resize` (default) / `--no-resize` diff --git a/packages/herdr/shared/herdr-agent-state-protocol.md b/packages/herdr/shared/herdr-agent-state-protocol.md index 80a294a1f..57d8592fd 100644 --- a/packages/herdr/shared/herdr-agent-state-protocol.md +++ b/packages/herdr/shared/herdr-agent-state-protocol.md @@ -2,8 +2,8 @@ A shared specification for how Herdr plugins report agent identity and state transitions via a Unix domain socket. This protocol is used by multiple Herdr -plugins across different repositories (e.g., `herdr-podcast-editor` in -open_source_llm, and planned ContextHub Rust migration). +plugins across different repositories (e.g., `herdr-notes` and the ContextHub +`worklog-selection-list` plugin). ## Overview @@ -81,8 +81,8 @@ Sent to report an agent's presence and current state. | Field | Type | Required | Description | |---|---|---|---| | `pane_id` | string | yes | The pane identifier from `HERDR_PANE_ID` | -| `source` | string | yes | Integration tracking identifier, e.g. `"herdr:herdr-podcast-editor"` | -| `agent` | string | yes | Agent name, e.g. `"herdr-podcast-editor"` | +| `source` | string | yes | Integration tracking identifier, e.g. `"herdr:herdr-notes"` | +| `agent` | string | yes | Agent name, e.g. `"herdr-notes"` | | `state` | string | yes | One of `"idle"`, `"working"`, `"blocked"` | | `seq` | integer | yes | Monotonically increasing sequence number | | `message` | string | no | Optional human-readable status (e.g. `"Generating TTS..."`) | @@ -139,7 +139,7 @@ herdr::: Where: -- `` is integration tracking identifier (e.g., `herdr:herdr-podcast-editor`) +- `` is integration tracking identifier (e.g., `herdr:herdr-notes`) - `` is the current system time in milliseconds since Unix epoch - `` is a thread-local integer counter, incremented with each ID generation, formatted in hexadecimal @@ -245,8 +245,7 @@ sequenceDiagram ### Rust -The Rust implementation lives in `herdr.rs` and is used by the -`herdr-podcast-editor`. Key design decisions: +The Rust implementation lives in `herdr.rs`. Key design decisions: - **`HerdrAgent::new()`** returns `Option` — `None` when env vars are missing (no-op outside Herdr pane) @@ -334,6 +333,6 @@ Event types include `pane_agent_status_changed`, `pane_focused`, ## Related -- Reference Rust implementation: `herdr.rs` in `herdr-podcast-editor` +- Reference Rust implementation: `herdr.rs` - Shared scripts: `packages/herdr/shared/` in ContextHub - Git submodule consumption: `packages/ContextHub/packages/herdr/shared/` diff --git a/packages/herdr/src/db-change.test.ts b/packages/herdr/src/db-change.test.ts index cb09e1781..8229a65aa 100644 --- a/packages/herdr/src/db-change.test.ts +++ b/packages/herdr/src/db-change.test.ts @@ -44,6 +44,8 @@ import { readStateCounter, DbChangeTracker, stateCounterFilePath, + watchDbChanges, + type DbChangeWatch, } from './db-change.js'; // Re-import fs for real mkdtempSync @@ -299,3 +301,371 @@ describe('DbChangeTracker', () => { expect(tracker.dbChanged()).toBe(false); // still 0, no change }); }); + +// --------------------------------------------------------------------------- +// watchDbChanges (event-driven companion) +// --------------------------------------------------------------------------- + +/** + * A controllable fake `fs.watch`: records listeners in target order and + * returns closeable handles so dispose can be asserted. + */ +function fakeWatch(): { + watch: any; + listeners: Array<(event: string, filename: string | Buffer | null) => void>; + closes: Array>; + dirs: string[]; +} { + const listeners: Array<(event: string, filename: string | Buffer | null) => void> = []; + const closes: Array> = []; + const dirs: string[] = []; + const watch = vi.fn((dir: string, listener: any) => { + dirs.push(dir); + listeners.push(listener); + const close = vi.fn(); + closes.push(close); + return { close }; + }); + return { watch, listeners, closes, dirs }; +} + +/** A controllable timer seam for deterministic debounce assertions. */ +function fakeDebounceTimers(): { + setTimeoutFn: any; + clearTimeoutFn: any; + run: () => void; + pending: () => boolean; +} { + let scheduled: (() => void) | null = null; + const setTimeoutFn = vi.fn((fn: () => void) => { + scheduled = fn; + return 1 as unknown as ReturnType; + }); + const clearTimeoutFn = vi.fn(() => { + scheduled = null; + }); + return { + setTimeoutFn, + clearTimeoutFn, + run: () => scheduled?.(), + pending: () => scheduled !== null, + }; +} + +/** + * A controllable fake `fs.watch` whose handles expose an async `'error'` + * emitter, mirroring `fs.FSWatcher` (which can fail after creation). + */ +function fakeErrorWatch(): { + watch: any; + handles: Array<{ + close: ReturnType; + on: ReturnType; + emitError: (error: unknown) => void; + }>; +} { + const handles: Array<{ + close: ReturnType; + on: ReturnType; + emitError: (error: unknown) => void; + }> = []; + const watch = vi.fn((_dir: string, _listener: any) => { + let errorListener: ((error: unknown) => void) | null = null; + const handle = { + close: vi.fn(), + on: vi.fn((event: string, listener: (error: unknown) => void) => { + if (event === 'error') errorListener = listener; + }), + emitError: (error: unknown) => errorListener?.(error), + }; + handles.push(handle); + return handle; + }); + return { watch, handles }; +} + +describe('watchDbChanges', () => { + it('watches the state-counter dir ONLY — the SQLite files are deliberately not watched', () => { + const { watch, dirs } = fakeWatch(); + const w = watchDbChanges('/cache', '/worklog', vi.fn(), { watch }); + expect(w.active).toBe(true); + // One watch handle: the state-counter directory. The SQLite worklog.db / + // -wal / -shm files are deliberately NOT watched: the pane's own + // in-process SQLite reads (fetchActionableCount / fetchChildrenForItems + // open a fresh WorklogDatabase) checkpoint the WAL and touch -shm, so + // watching them re-fires the watch on every refresh — a self-sustaining + // refresh loop that saturated the event loop and made keyboard input + // unresponsive (WL-0MV02VA29009WBEN producer-rejection fix). + expect(dirs).toEqual(['/cache/state']); + w.dispose(); + }); + + it('the worklog dir is never watched (no extra handles beyond the state dir)', () => { + const { watch, dirs } = fakeWatch(); + const w = watchDbChanges('/cache', '/worklog', vi.fn(), { watch }); + expect(dirs).not.toContain('/worklog'); + w.dispose(); + }); + + it('debounces a burst of matching events into ONE trailing callback', () => { + const { watch, listeners } = fakeWatch(); + const on = vi.fn(); + const timers = fakeDebounceTimers(); + const w = watchDbChanges('/cache', '/worklog', on, { + watch, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + const counterName = path.basename(stateCounterFile('/cache', '/worklog')); + // listener[0] is the state-counter dir watch. + listeners[0]('change', counterName); + listeners[0]('change', counterName); + listeners[0]('change', counterName); + + // Not fired until the debounce window elapses. + expect(on).not.toHaveBeenCalled(); + expect(timers.pending()).toBe(true); + + timers.run(); + expect(on).toHaveBeenCalledTimes(1); + + w.dispose(); + }); + + it('ignores events for unrelated files — INCLUDING SQLite -wal/-shm churn', () => { + const { watch, listeners } = fakeWatch(); + const on = vi.fn(); + const timers = fakeDebounceTimers(); + const w = watchDbChanges('/cache', '/worklog', on, { + watch, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + // Another worklog's counter: filtered out by name. + listeners[0]('change', 'some-other-counter.json'); + // SQLite-file churn — the pane's own in-process reads, WAL checkpoints, + // other panes' reads — must NOT schedule a refresh. Only the state + // counter (the write-only signal) counts; watching the DB files created + // a self-refresh loop (WL-0MV02VA29009WBEN producer-rejection fix). + listeners[0]('change', 'worklog.db'); + listeners[0]('change', 'worklog.db-wal'); + listeners[0]('change', 'worklog.db-shm'); + + expect(timers.setTimeoutFn).not.toHaveBeenCalled(); + expect(on).not.toHaveBeenCalled(); + w.dispose(); + }); + + it('fail-open: reacts when the platform omits the filename', () => { + const { watch, listeners } = fakeWatch(); + const on = vi.fn(); + const timers = fakeDebounceTimers(); + const w = watchDbChanges('/cache', '/worklog', on, { + watch, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + listeners[0]('rename', null); + expect(timers.setTimeoutFn).toHaveBeenCalledTimes(1); + timers.run(); + expect(on).toHaveBeenCalledTimes(1); + w.dispose(); + }); + + it('fail-open: reports inactive when the state-counter watch throws', () => { + const watch = vi.fn(() => { + throw new Error('ENOENT: no such directory'); + }); + const onError = vi.fn(); + const w = watchDbChanges('/cache', '/worklog', vi.fn(), { watch, onError }); + + expect(w.active).toBe(false); + expect(onError).toHaveBeenCalledTimes(1); + // Disposing an inactive watch is safe and idempotent. + expect(() => w.dispose()).not.toThrow(); + }); + + it('dispose closes every handle, clears the pending timer, and ignores later events', () => { + const { watch, listeners, closes } = fakeWatch(); + const on = vi.fn(); + const timers = fakeDebounceTimers(); + const w = watchDbChanges('/cache', '/worklog', on, { + watch, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + }); + + const counterName = path.basename(stateCounterFile('/cache', '/worklog')); + listeners[0]('change', counterName); + + w.dispose(); + expect(timers.clearTimeoutFn).toHaveBeenCalled(); + expect(closes[0]).toHaveBeenCalledTimes(1); + expect(w.active).toBe(false); + + // A stale listener firing after dispose must not invoke onChange. + listeners[0]('change', counterName); + timers.run(); + expect(on).not.toHaveBeenCalled(); + + // Idempotent. + w.dispose(); + expect(closes[0]).toHaveBeenCalledTimes(1); + }); + + it('close() is an alias for dispose() (idempotent, closes every handle)', () => { + const { watch, closes } = fakeWatch(); + const w = watchDbChanges('/cache', '/worklog', vi.fn(), { watch }); + w.close(); + expect(closes[0]).toHaveBeenCalledTimes(1); + expect(w.active).toBe(false); + + // Idempotent: dispose after close is a no-op. + w.dispose(); + expect(closes[0]).toHaveBeenCalledTimes(1); + }); + + it('fail-open: an async fs.watch error retires the target and is reported', () => { + const { watch, handles } = fakeErrorWatch(); + const onError = vi.fn(); + const w = watchDbChanges('/cache', '/worklog', vi.fn(), { watch, onError }); + + expect(handles).toHaveLength(1); + expect(w.active).toBe(true); + + // A post-creation failure (e.g. EPERM on a network FS) must be reported + // and the target retired; with no other target the watch reports + // inactive so the caller falls back to the 30 s polling tick. + handles[0].emitError(new Error('EPERM: operation not permitted')); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); + expect(handles[0].close).toHaveBeenCalledTimes(1); + expect(w.active).toBe(false); + }); + + it('fail-open: repeated async errors on the same handle are safe', () => { + const { watch, handles } = fakeErrorWatch(); + const onError = vi.fn(); + const w = watchDbChanges('/cache', '/worklog', vi.fn(), { watch, onError }); + + handles[0].emitError(new Error('first')); + handles[0].emitError(new Error('second')); + expect(onError).toHaveBeenCalledTimes(2); + // The single handle is already retired; the watch reports inactive. + expect(w.active).toBe(false); + w.dispose(); + }); + + it('fail-open: a throwing onError handler never breaks watch setup', () => { + const watch = vi.fn(() => { + throw new Error('ENOENT: no state dir'); + }); + const onError = vi.fn(() => { + throw new Error('handler exploded'); + }); + + let w: DbChangeWatch | undefined; + expect(() => { + w = watchDbChanges('/cache', '/worklog', vi.fn(), { watch, onError }); + }).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + // The only target failed to start, so the watch is inactive (fail-open: + // the caller keeps its 30 s polling tick). + expect(w!.active).toBe(false); + w!.dispose(); + }); + + it('fail-open: a throwing onChange callback is contained (never crashes the loop)', () => { + const { watch, listeners } = fakeWatch(); + const onError = vi.fn(); + const boom = vi.fn(() => { + throw new Error('pane callback exploded'); + }); + const timers = fakeDebounceTimers(); + const w = watchDbChanges('/cache', '/worklog', boom, { + watch, + setTimeoutFn: timers.setTimeoutFn, + clearTimeoutFn: timers.clearTimeoutFn, + onError, + }); + + const counterName = path.basename(stateCounterFile('/cache', '/worklog')); + listeners[0]('change', counterName); + expect(() => timers.run()).not.toThrow(); + expect(boom).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledTimes(1); + + // The watch stays armed and observes the next write. + listeners[0]('change', counterName); + timers.run(); + expect(boom).toHaveBeenCalledTimes(2); + w.dispose(); + }); + + it('really fires onChange when the state-counter file is written (real fs.watch)', async () => { + const cacheDir = tempDir(); + const worklogDir = tempDir(); + const file = stateCounterFile(cacheDir, worklogDir); + fsReal.mkdirSync(path.dirname(file), { recursive: true }); + + let resolveCalled!: () => void; + const called = new Promise((resolve) => { + resolveCalled = resolve; + }); + const on = vi.fn(() => resolveCalled()); + + const w = watchDbChanges(cacheDir, worklogDir, on, { debounceMs: 20 }); + expect(w.active).toBe(true); + try { + // Emulate a wl write bumping the counter (the same atomic-ish write the + // CLI performs) and assert the watch observes it. + fsReal.writeFileSync(file, JSON.stringify({ state: 1 }), 'utf-8'); + await Promise.race([ + called, + new Promise((_, reject) => + setTimeout(() => reject(new Error('watch did not fire within 2s')), 2000), + ), + ]); + expect(on).toHaveBeenCalled(); + } finally { + w.dispose(); + } + }); + + it('REGRESSION: SQLite -wal/-shm/db churn alone never fires onChange (real fs.watch)', async () => { + // The pane's own refresh path opens a fresh WorklogDatabase for + // in-process reads, which checkpoints the WAL and touches -shm. If the + // watch reacted to those files it would re-fire on the pane's own reads + // — a self-sustaining refresh loop that saturated the event loop and + // made keyboard input unresponsive (WL-0MV02VA29009WBEN + // producer-rejection fix). Only the state counter may fire the watch. + const cacheDir = tempDir(); + const worklogDir = tempDir(); + const file = stateCounterFile(cacheDir, worklogDir); + fsReal.mkdirSync(path.dirname(file), { recursive: true }); + fsReal.writeFileSync(file, JSON.stringify({ state: 0 }), 'utf-8'); + + const on = vi.fn(); + const w = watchDbChanges(cacheDir, worklogDir, on, { debounceMs: 20 }); + expect(w.active).toBe(true); + try { + // Emulate the pane's own in-process SQLite reads touching the files + // that were previously watched: nothing may fire. + fsReal.writeFileSync(path.join(worklogDir, 'worklog.db'), 'x', 'utf-8'); + fsReal.writeFileSync(path.join(worklogDir, 'worklog.db-wal'), 'wal', 'utf-8'); + fsReal.writeFileSync(path.join(worklogDir, 'worklog.db-shm'), 'shm', 'utf-8'); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(on).not.toHaveBeenCalled(); + + // Sanity: the watch is alive — the counter write still fires it. + fsReal.writeFileSync(file, JSON.stringify({ state: 1 }), 'utf-8'); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(on).toHaveBeenCalledTimes(1); + } finally { + w.dispose(); + } + }); +}); diff --git a/packages/herdr/src/db-change.ts b/packages/herdr/src/db-change.ts index f9487e287..2c458f99e 100644 --- a/packages/herdr/src/db-change.ts +++ b/packages/herdr/src/db-change.ts @@ -8,6 +8,28 @@ * Fail-open: any read error returns `changed = true`, so the refresh/sync * cycle always runs when the signal is unavailable. * + * `watchDbChanges` is the event-driven companion: it watches `wl`'s + * cross-process write signal (the state-counter file) so the pane can react + * to an agent write promptly instead of waiting for the 30 s auto-refresh + * tick. The watch is debounced (one trailing callback per burst), fails open + * (an unusable watch is reported as inactive so the caller keeps polling), + * and is disposable (`dispose()`/`close()` closes every `fs.watch` handle + * and clears pending timers). Hardened against watch failures that surface + * *after* creation (an asynchronous `fs.FSWatcher` `'error'` event, e.g. + * inotify limits or a network filesystem dropping the watch) and against + * throwing callbacks so nothing ever escapes into the TUI loop. + * + * The watch targets the state-counter FILE ONLY — deliberately NOT the + * SQLite `worklog.db`/`-wal`/`-shm` files. Watching those created a + * self-sustaining refresh loop: the pane's own refresh path opens a fresh + * `WorklogDatabase` for in-process reads, and that open/close checkpoints + * the WAL and touches `-shm`, i.e. it modifies exactly the files being + * watched → the watch re-fired → another refresh → more file churn. The + * pane NEVER writes the state counter (it only reads it), so the counter is + * the loop-free authoritative signal: every `wl` write bumps it and every + * pane-side read leaves it untouched (WL-0MV02VA29009WBEN + * producer-rejection fix). + * * @module db-change */ @@ -123,3 +145,215 @@ export class DbChangeTracker { } } } + +// --------------------------------------------------------------------------- +// watchDbChanges — event-driven companion to DbChangeTracker +// --------------------------------------------------------------------------- + +/** Default debounce window for coalescing a burst of watch events. */ +export const DEFAULT_WATCH_DEBOUNCE_MS = 150; + +/** Minimal shape of an `fs.watch` handle (closeable, optionally error-emitting). */ +export interface WatchHandle { + close(): void; + /** + * Optional error subscription (a real `fs.FSWatcher` supports it). Used to + * fail open when a watch fails *after* creation — otherwise the emitted + * `'error'` event is unhandled and crashes the process. + */ + on?(event: 'error', listener: (error: unknown) => void): unknown; +} + +/** A filesystem watch listener, matching the `fs.watch` callback signature. */ +export type WatchListener = (eventType: string, filename: string | Buffer | null) => void; + +/** Injectable `fs.watch` seam (defaults to the real `node:fs` implementation). */ +export type WatchFn = (path: string, listener: WatchListener) => WatchHandle; + +/** Injectable timer seam so debounce behaviour is deterministic in tests. */ +export type WatchTimerFn = (fn: () => void, ms: number) => ReturnType; +export type WatchClearTimerFn = (handle: ReturnType) => void; + +export interface WatchDbChangesOptions { + /** Debounce window in ms (default {@link DEFAULT_WATCH_DEBOUNCE_MS}). */ + debounceMs?: number; + /** `fs.watch` implementation; defaults to the real one. */ + watch?: WatchFn; + /** `setTimeout` implementation; defaults to the global. */ + setTimeoutFn?: WatchTimerFn; + /** `clearTimeout` implementation; defaults to the global. */ + clearTimeoutFn?: WatchClearTimerFn; + /** Called for each failed watch target (fail-open; never throws). */ + onError?: (error: unknown) => void; +} + +/** A running DB-change watch. */ +export interface DbChangeWatch { + /** + * True while at least one underlying `fs.watch` handle is active. `false` + * means the watch could not be established (fail open): the caller must + * keep the 30 s polling tick as its fallback. + */ + readonly active: boolean; + /** Stop watching and clear any pending debounce timer. Idempotent. */ + dispose(): void; + /** Alias for {@link DbChangeWatch.dispose} (scope names both). */ + close(): void; +} + +interface WatchTarget { + dir: string; + matches: (filename: string) => boolean; +} + +/** + * Watch the cross-process DB-change signal for a worklog dir. + * + * Watches the state-counter directory (`/state/`), filtered to + * this worklog's counter file — the SAME monotonic write counter + * `DbChangeTracker` reads and the one `wl`'s write path bumps (pre-action + * cache invalidation and post-sync invalidation; write-only from `wl`'s + * perspective). A burst of events is coalesced into at most ONE trailing + * `onChange()` call after `debounceMs` of quiet. + * + * Why NOT the SQLite files: an early design also watched the worklog dir + * (`worklog.db`, `-wal`, `-shm`), which created a SELF-SUSTAINING refresh + * loop — the pane's own refresh path opens a fresh `WorklogDatabase` + * connection for in-process reads, and that open/close checkpoints the WAL + * and touches `-shm`, i.e. it modifies exactly the files being watched, + * re-firing the watch → another refresh → more file churn. The pane never + * writes the state counter (it only reads it), so the counter is the + * loop-free authoritative signal: every real `wl` write bumps it, every + * pane-side read leaves it untouched (WL-0MV02VA29009WBEN + * producer-rejection fix). The 30 s polling tick stays the fail-open + * fallback. + * + * Fail-open: a target whose `fs.watch` throws is skipped; when no target + * could be watched the returned watch reports `active === false` and the + * caller keeps its polling fallback. Errors are surfaced via `onError` and + * never thrown into the TUI event loop. + */ +export function watchDbChanges( + cacheDir: string, + worklogDir: string, + onChange: () => void, + opts: WatchDbChangesOptions = {}, +): DbChangeWatch { + const watchFn: WatchFn = opts.watch ?? ((target, listener) => fs.watch(target, listener)); + const setT: WatchTimerFn = opts.setTimeoutFn ?? ((fn, ms) => setTimeout(fn, ms)); + const clearT: WatchClearTimerFn = opts.clearTimeoutFn ?? ((handle) => clearTimeout(handle)); + const debounceMs = opts.debounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS; + + const counterFile = stateCounterFilePath(cacheDir, worklogDir); + const counterName = path.basename(counterFile); + + // The state-counter directory is watched (not the bare file): the CLI + // writes the counter via an atomic tmp + rename, which a direct file watch + // would silently stop following after the inode is replaced. The directory + // watch sees the rename and the per-worklog filename filter keeps events + // from other worklogs (the dir holds one counter file per worklog dir) out. + const targets: WatchTarget[] = [ + { dir: path.dirname(counterFile), matches: (name) => name === counterName }, + ]; + + const handles: WatchHandle[] = []; + let timer: ReturnType | null = null; + let disposed = false; + + // A throwing callback must never take down the watch (or the pane): report + // it through the error seam and carry on (fail open). + const reportError = (error: unknown): void => { + try { + opts.onError?.(error); + } catch { + // Ignore a throwing error handler — the watch keeps working. + } + }; + + const fire = (): void => { + timer = null; + if (disposed) return; + try { + onChange(); + } catch (error) { + reportError(error); + } + }; + + const schedule = (): void => { + if (disposed) return; + if (timer !== null) clearT(timer); + timer = setT(fire, debounceMs); + }; + + const dispose = (): void => { + if (disposed) return; + disposed = true; + if (timer !== null) { + clearT(timer); + timer = null; + } + for (const handle of handles) { + try { + handle.close(); + } catch { + // A close failure must never break teardown. + } + } + handles.length = 0; + }; + + for (const target of targets) { + let handle: WatchHandle; + try { + handle = watchFn(target.dir, (_eventType, filename) => { + if (disposed) return; + const name = typeof filename === 'string' + ? filename + : filename + ? filename.toString() + : ''; + // Some platforms/filesystems omit the filename; treat that as a + // possible match (fail-open toward reacting) rather than ignoring it. + if (name === '' || target.matches(name)) schedule(); + }); + } catch (error) { + // Fail-open: a target that cannot be watched is skipped. The caller + // still has the polling fallback (and any other target). + reportError(error); + continue; + } + + // Harden against a watch that fails AFTER creation: a real `fs.FSWatcher` + // emits `'error'` (e.g. inotify watch-limit exhaustion, or a network FS + // dropping the watch). Left unhandled, that event would crash the process, + // so subscribe and retire the failed target (fail open — `active` then + // reflects only the handles still alive). + const liveHandle = handle; + if (typeof liveHandle.on === 'function') { + try { + liveHandle.on('error', (error: unknown) => { + const index = handles.indexOf(liveHandle); + if (index !== -1) handles.splice(index, 1); + reportError(error); + try { + liveHandle.close(); + } catch { + // A close failure must never break teardown. + } + }); + } catch (error) { + reportError(error); + } + } + handles.push(liveHandle); + } + + return { + get active(): boolean { + return handles.length > 0; + }, + dispose, + close: dispose, + }; +} diff --git a/packages/herdr/src/dispatcher-anchor.ts b/packages/herdr/src/dispatcher-anchor.ts index 4bac219bc..45ac9d6b3 100644 --- a/packages/herdr/src/dispatcher-anchor.ts +++ b/packages/herdr/src/dispatcher-anchor.ts @@ -148,10 +148,12 @@ export interface DispatcherAnchorDeps { readProcEnviron?(shellPid: string): Promise; } -/** One parsed `herdr tab list` record (id + label). */ +/** One parsed `herdr tab list` record (id + label, plus agent liveness). */ export interface DispatcherTabInfo { tabId: string; label: string; + /** Raw `agent_status` from the tab record, when herdr reports it. */ + agentStatus?: string; } /** One parsed workspace-scoped `herdr pane list` record (tab routing). */ @@ -252,11 +254,14 @@ export function createDispatcherAnchorDeps( // ── herdr tab/pane CLI helpers (per-prefix tab anchors) ──────────────── /** - * Parse `herdr tab list` JSON output into `{ tabId, label }` records. + * Parse `herdr tab list` JSON output into `{ tabId, label, agentStatus? }` + * records (the agent status is carried for liveness-aware consumers such as + * the hydrator's running-pane check). * * Tolerates log lines before the JSON envelope (scan for the first `{`), the * `{ result: { tabs: [...] } }` envelope, a bare `{ tabs: [...] }` shape, and - * the `tab_id` / `tabId` / `id` key variants plus `label` / `title`. + * the `tab_id` / `tabId` / `id` key variants plus `label` / `title` and + * `agent_status` / `agentStatus`. * Returns `null` when no tab array can be found (caller fails closed — never * treat an unparseable list as "no tab exists", which would duplicate tabs). */ @@ -285,7 +290,9 @@ export function parseTabListOutput(raw: string): DispatcherTabInfo[] | null { const tabId = rec.tab_id ?? rec.tabId ?? rec.id; const label = rec.label ?? rec.title; if (typeof tabId === 'string' && tabId !== '' && typeof label === 'string') { - out.push({ tabId, label }); + const rawAgentStatus = rec.agent_status ?? rec.agentStatus; + const agentStatus = typeof rawAgentStatus === 'string' ? rawAgentStatus : undefined; + out.push(agentStatus !== undefined ? { tabId, label, agentStatus } : { tabId, label }); } } return out; diff --git a/packages/herdr/src/downtime-worker.audit-tier.test.ts b/packages/herdr/src/downtime-worker.audit-tier.test.ts new file mode 100644 index 000000000..5aedd0c38 --- /dev/null +++ b/packages/herdr/src/downtime-worker.audit-tier.test.ts @@ -0,0 +1,1228 @@ +/** + * packages/herdr/src/downtime-worker.audit-tier.test.ts — audit-tier tests + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4V6LJQ0050FZX) to keep each test file focused + * on one concern. + * + * Covers the audit dispatch tier and its fail-closed claim paths: + * - `dispatch audit tier` — tier priority, fall-through and wl-error strikes, + * - `audit tier interim freshness re-check` — the fresh-audit guard, + * - `dispatch active-audit single-flight` — per-worklog sequential audits, + * - `dispatch host-wide audit saturation` — machine-wide audit serialisation, + * - `dispatch tier free-slot minimums` — per-tier pane/free-slot gates, + * - `dispatch audit trail` — claim/marker/spawn ordering and rollback, and + * - `dispatch CAS claim race` — compare-and-swap claim races and recovery. + * + * Shared scaffolding (`makeDeps`, etc.) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + dispatchDowntimeWork, + type DowntimeCandidate, + type DowntimeActiveAuditResult, + type DowntimeWorkerDeps, +} from './downtime-worker.js'; +import { makeDeps } from './downtime-worker.fixtures.js'; + +// ── Audit-tier dispatch (WL-0MSI8H3HP000K0RG) ───────────────────────── + +describe('dispatch audit tier', () => { + const staleCandidate: DowntimeCandidate = { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }; + + it('dispatches /skill:audit on the audit candidate as the first tier', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: staleCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(outcome.candidate?.id).toBe('WL-AUD'); + expect(deps.getNextItem).not.toHaveBeenCalled(); + expect(deps.claimItem).toHaveBeenCalledWith('WL-AUD', { status: 'completed', stage: 'in_review' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:audit WL-AUD'), + { model: 'plan', cwd: '/repo', itemId: 'WL-AUD', itemTitle: 'Audit me' }, + ); + }); + + it('records the audit dispatch with kind audit', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: staleCandidate }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-AUD'); + expect(event.kind).toBe('audit'); + expect(event.cwd).toBe('/repo'); + }); + + it('when no audit candidate, falls back to audit → plan → intake in order', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('WL-IDE'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); + }); + + it('fails closed to no-candidate when the audit tier answers empty and no plan/intake candidate exists', async () => { + // The audit tier distinguishes a GENUINELY empty tier ({ok:true, + // candidate:null}) from a wl failure ({ok:false}, WL-0MSLWJ2KP0002SV0): + // only the former falls through — all tiers empty -> no dispatch, the + // no-candidate cooldown unchanged. + const deps = makeDeps(); // all three tiers empty + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('fails closed to wl-error when the audit-tier lookup errors (a strike, never a silent fall-through)', async () => { + // WL-0MSLWJ2KP0002SV0: a wl/parse failure in getNextAuditCandidate must + // NOT fall through to the implement/plan/intake tiers looking healthy — + // it is a CLI-error strike exactly like the plan/intake tiers' {ok:false}. + // The audit query state is UNKNOWN, so no lower tier is consulted this + // cycle (fail-closed to busy; the three-strike rule governs retries). + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: false }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ id: 'WL-IMP', title: 'Implement me', stage: 'implement' }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + // No fall-through: the implement/plan/intake tiers are never consulted. + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(deps.getNextItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('falls through to the implement tier when the audit tier answers a genuinely empty set', async () => { + // {ok:true, candidate:null} (genuinely empty audit tier) falls through to + // the implement tier — only {ok:false} (wl failure) strikes. + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ id: 'WL-IMP', title: 'Implement me', stage: 'implement' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + }); +}); + +// ── Interim freshness re-check (WL-0MT8KSTOE00871E7) ───────────────── + +describe('audit tier interim freshness re-check (WL-0MT8KSTOE00871E7)', () => { + const auditCandidate: DowntimeCandidate = { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }; + + it('downgrades to a no-op when a fresh audit was recorded in the interim (no comment, no marker, no spawn)', async () => { + // AC1: select a stale/un-audited candidate, then a valid audit lands + // between selection and dispatch — the re-check sees it and the + // dispatch becomes a no-op; behaviour falls through to the next tier + // without triggering the no-candidate cooldown. + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + // A fresh audit exists in the interim — dispatch must be skipped. + hasFreshAudit: vi.fn().mockResolvedValue(true), + // A plan candidate exists below — the audit tier skip falls through to it. + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Falls through to the next tier (plan), NOT the no-candidate cooldown. + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-PLN'); + // No AUDIT dispatch side-effects: the audit candidate is never claimed, + // no audit comment/marker, no audit spawn. + expect(deps.claimItem).toHaveBeenCalledWith('WL-PLN', { status: 'open', stage: 'intake_complete' }, '/repo'); + expect(deps.recordDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ itemId: 'WL-AUD', kind: 'audit' }), + ); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-PLN'), + expect.any(Object), + ); + // The re-check was performed exactly once on the candidate. + expect(deps.hasFreshAudit).toHaveBeenCalledWith('WL-AUD', '/repo'); + }); + + it('skips dispatch entirely and leaves the cooldown untouched when a fresh audit lands and no next-tier candidate exists', async () => { + // AC1 (no-candidate cooldown guard): a fresh-audit skip with an empty + // remaining backlog is NOT the no-candidate outcome — the worker keeps + // polling (the item is already audited; there is simply nothing to do). + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + hasFreshAudit: vi.fn().mockResolvedValue(true), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty + .mockResolvedValueOnce({ ok: true, candidate: null }), // idea empty + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + // Fresh-audit skip is treated as "already audited" — no cooldown, no strike. + expect(outcome.reason).toBe('fresh-audit-skip'); + expect(outcome.reason).not.toBe('no-candidate'); + expect(outcome.reason).not.toBe('wl-error'); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('records no spawn-failure trace when a fresh audit was recorded during the interim (AC2)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + // Selection re-check: no fresh audit → dispatch proceeds. Spawn-failure + // re-check: a fresh audit appeared in the interim → no failure trace. + hasFreshAudit: vi + .fn() + .mockResolvedValueOnce(false) // selection: not fresh → dispatch proceeds + .mockResolvedValueOnce(true), // spawn failure: fresh audit → skip trace + claimItem: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT', exitCode: 1 }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Spawn failed, but a valid audit exists — the push is moot, no failure trace. + expect(recordDispatchFailureCalled(deps)).toBe(false); + expect(deps.recordDispatch).toHaveBeenCalledTimes(1); // marker written (unchanged) + expect(outcome.dispatched).toBe(false); + }); + + it('still appends a spawn-failure trace when no fresh audit was recorded during the interim (unchanged path)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + hasFreshAudit: vi.fn().mockResolvedValue(false), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT', exitCode: 1 }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // No fresh audit — the failure trace is recorded exactly as before (AC3). + expect((deps.recordDispatchFailure as ReturnType).mock.calls.length).toBe(1); + expect(outcome.dispatched).toBe(false); + }); + + it('a genuinely unaudited item still dispatches exactly as today (comment + marker + spawn)', async () => { + // AC3: no fresh audit in the interim — the audit dispatch proceeds + // unchanged (comment + marker + spawn). + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + hasFreshAudit: vi.fn().mockResolvedValue(false), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-AUD', { status: 'completed', stage: 'in_review' }, '/repo'); + expect(deps.recordDispatch).toHaveBeenCalledTimes(1); // marker/comment written + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:audit WL-AUD'), + { model: 'plan', cwd: '/repo', itemId: 'WL-AUD', itemTitle: 'Audit me' }, + ); + }); + + it('a hasFreshAudit failure (wl error) is fail-open — the dispatch proceeds as today (conservative default)', async () => { + // The re-check must never block dispatch on an unanswerable check: a + // wl/CLI failure in hasFreshAudit fails open (proceed) — never a strike, + // never a no-candidate, never a silent skip. + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + // Fail-open: the re-check throws but the dispatching continues. + hasFreshAudit: vi.fn().mockRejectedValue(new Error('wl failed')), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(deps.claimItem).toHaveBeenCalled(); + expect(deps.spawnAgentPane).toHaveBeenCalled(); + }); +}); + +function recordDispatchFailureCalled(deps: DowntimeWorkerDeps): boolean { + const fn = deps.recordDispatchFailure as ReturnType; + return fn.mock.calls.length > 0; +} + +// ── Single-active-audit enforcement (WL-0MT3PHW4I002SNOV) ───────────── + +describe('dispatch active-audit single-flight (parent WL-0MT3PHW4I002SNOV)', () => { + const auditCandidate: DowntimeCandidate = { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }; + const implementCandidate: DowntimeCandidate = { + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }; + + // Contract of the injected active-audit check (implemented in F2, + // WL-0MT47BQAT00375VB): {ok:true, active:true} = a non-stale kind=audit + // dispatch marker maps to an item still in_progress (an audit is running + // — the audit tier must be skipped); {ok:true, active:false} = none + // (audit tier proceeds); {ok:false} = the check could not complete + // (fail-open — skip the audit tier, never block all dispatch). + const noActiveAudit: DowntimeActiveAuditResult = { ok: true, active: false }; + const activeAudit: DowntimeActiveAuditResult = { ok: true, active: true }; + const checkFailed: DowntimeActiveAuditResult = { ok: false }; + + it('dispatches /skill:audit when no non-stale audit marker maps to an in_progress item (AC1/AC3)', async () => { + // No active audit — including a marker older than the 2h stale window + // (treated as expired: the audit pane may have crashed without updating + // the work item) — the audit tier proceeds as today. The stale-window + // arithmetic itself is pinned in downtime-log.test.ts + // (recentAuditDispatchedItemIds); here the dep reports the resolved + // state (active:false). + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(noActiveAudit), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // The single-flight check runs first, then the candidate lookup. + expect(deps.getActiveAudit).toHaveBeenCalledWith('/repo'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(outcome.candidate?.id).toBe('WL-AUD'); + }); + + it('one active audit skips the audit tier and falls through to the implement tier (AC2/AC4)', async () => { + // A non-stale kind=audit marker maps to an in_progress item: an audit is + // running. The audit tier is skipped (audits strictly sequential — no + // fan-out), the candidate lookup is never consulted, and dispatch falls + // through to the implement tier. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getActiveAudit).toHaveBeenCalledTimes(1); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); + + it('an active-audit-check failure fails open: audit tier skipped, dispatch falls through to implement', async () => { + // Fail-safe (parent constraint): if the active-audit check cannot + // complete (e.g. worklog query fails) the audit tier is skipped and + // dispatch falls through to the next tier — it never blocks all + // dispatch. No audit candidate is consulted this cycle. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(checkFailed), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); + + it('an audit-in-flight skip with an empty remaining backlog reports reason audit-in-flight (never no-candidate)', async () => { + // Mirrors the code-freeze precedent (WL-0MSQ0RPQP00636JY): a skip that + // is NOT a genuine empty backlog must never enter the no-candidate + // cooldown — polling continues and the next idle tick re-checks while + // the audit is still running. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + // plan (intake_complete) and intake (idea) tiers answer genuinely empty. + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('audit-in-flight'); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('logs the audit-in-flight skip reason to stderr for observability (AC4/AC5)', async () => { + // The skip must be observable: the worker writes the skip reason to + // stderr (established observability pattern, cf. scheduler.test.ts + // 'logs an abandonment to stderr'), so operators can trace why the + // audit tier did not dispatch instead of seeing a silent fall-through. + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('implement'); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Downtime audit tier skipped: audit-in-flight'), + ); + } finally { + stderrSpy.mockRestore(); + } + }); + + it('a failed active-audit check with an empty remaining backlog is a wl-error strike, never no-candidate', async () => { + // Partial information must not pause the worker: when the check failed + // and every fallback tier answered empty, the backlog is NOT provably + // empty (an audit may be in flight we could not see) — fail closed to + // busy (a strike, three-strike rule), never the no-candidate cooldown. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(checkFailed), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('runs the active-audit check BEFORE the audit candidate lookup (single check per tick)', async () => { + // Ordering guard: the single-flight check gates the tier — a candidate + // is never selected while an active audit exists, and the check is + // consulted exactly once per tick. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(noActiveAudit), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const checkOrder = (deps.getActiveAudit as ReturnType).mock.invocationCallOrder[0]; + const lookupOrder = (deps.getNextAuditCandidate as ReturnType).mock.invocationCallOrder[0]; + expect(checkOrder).toBeLessThan(lookupOrder); + expect(deps.getActiveAudit).toHaveBeenCalledTimes(1); + }); + + it('during a code freeze the audit block is skipped without consulting the active-audit check (guard composition)', async () => { + // Existing guards preserved (AC6): the code-freeze gate (WL-0MSQ0RPQP00636JY) + // short-circuits the entire audit tier before the single-flight check — + // no audits run during a release, active or not. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getActiveAudit).not.toHaveBeenCalled(); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + // Empty plan/intake backlog during a freeze reports code-freeze (the + // freeze never enters the no-candidate cooldown — unchanged). + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); + }); + + it('is not consulted when the audit tier is ineligible (1 free slot < the 2-slot audit minimum)', async () => { + // Free-slot minimum preserved (parent WL-0MT32F90V008UAD2 AC3): below the + // audit tier's 2-slot minimum the whole audit block is skipped — the + // single-flight check is not even run; plan (≥1 slot) dispatches. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete', status: 'open' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(deps.getActiveAudit).not.toHaveBeenCalled(); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); +}); + +// ── Host-wide audit saturation (WL-0MUIVE0YG000UVIA) ────────────────── +// +// The per-worklog active-audit check (WL-0MT3PHW4I002SNOV) only sees audits +// dispatched by THIS project's dispatcher. The 2026-09-26 incident ran four +// downtime audits concurrently on one host — dispatched by DIFFERENT +// projects — saturating the shared Local Proxy and the audit runner's +// host-wide slot. These tests pin the host-wide serialisation gate: when a +// machine-wide active-audit marker is present (`source: 'host-wide'`), a +// second audit is NOT started; the candidate falls through/deferred. +describe('dispatch host-wide audit saturation (WL-0MUIVE0YG000UVIA)', () => { + const auditCandidate: DowntimeCandidate = { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }; + const implementCandidate: DowntimeCandidate = { + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }; + + const hostSaturated: DowntimeActiveAuditResult = { + ok: true, + active: true, + source: 'host-wide', + }; + const perWorklogActive: DowntimeActiveAuditResult = { + ok: true, + active: true, + source: 'per-worklog', + }; + + it('a host-wide active audit skips the audit tier and falls through to the implement tier (AC1/AC2)', async () => { + // A second instance/project on the same host sees the machine-wide + // marker and must NOT start another audit. The audit candidate lookup is + // never consulted and dispatch falls through to the next tier. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(hostSaturated), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getActiveAudit).toHaveBeenCalledWith('/repo'); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); + + it('host-wide saturation with an empty remaining backlog reports audit-host-saturated (never no-candidate)', async () => { + // A host-wide skip is NOT a genuine empty backlog: it must never enter + // the no-candidate cooldown — polling continues and the next idle tick + // re-checks while the remote audit is still running. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(hostSaturated), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('audit-host-saturated'); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('distinguishes host-wide from per-worklog saturation: a per-worklog active audit still reports audit-in-flight', async () => { + // The reason string must not regress the existing per-worklog semantics: + // only `source: 'host-wide'` produces 'audit-host-saturated'. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(perWorklogActive), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('audit-in-flight'); + }); + + it('logs audit-host-saturated with the concurrent-audit count and slot state (AC3)', async () => { + // Contention must be visible: the skip line names the condition and + // carries the concurrent-audit count (1 — host-wide single-flight) plus + // the live free-slot / contention queue state so an operator can tell + // infra saturation from a content problem. + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(hostSaturated), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd: '/repo', + freeSlots: 2, + contentionQueueDepth: 0, + }); + + expect(outcome.kind).toBe('implement'); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Downtime audit tier skipped: audit-host-saturated'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('concurrentAudits=1'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('contentionQueueDepth=0'), + ); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('freeSlots=2'), + ); + } finally { + stderrSpy.mockRestore(); + } + }); +}); + +describe('dispatch tier free-slot minimums (parent WL-0MT32F90V008UAD2 AC3)', () => { + const auditCandidate: DowntimeCandidate = { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }; + const planCandidate: DowntimeCandidate = { + id: 'WL-PLAN', + title: 'Prep task', + stage: 'intake_complete', + status: 'open', + }; + + it('audit tier is skipped below 2 free slots: with 1 free slot an audit candidate is NOT dispatched (AC3)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: planCandidate, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + // 1 free slot: audit needs ≥2 → skipped WITHOUT consulting the lookup + // (ineligible, not a strike); plan needs ≥1 → dispatches. + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-PLAN'); + }); + + it('audit tier dispatches with ≥2 free slots (AC3)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 2 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + }); + + it('with exactly 1 free slot the audit skip is ineligible — a genuinely empty plan tier is no-candidate, not a strike', async () => { + // Mirror of the code-freeze skip: an unmet tier minimum never produces + // a wl-error strike and never short-circuits the fallback tiers. + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); // plan tier answered empty + }); + + it('pane tiers (plan/intake) still dispatch with exactly 1 free slot (AC2)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + it('free-slots gate is independent of the idle gate: no freeSlots arg means no gating (backward compatible)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Legacy direct callers (undefined freeSlots) are not gated — existing + // dispatch behaviour unchanged. + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + }); + + it('scheduled-prompts tier requires the pane minimum ≥1 free slot: 0 free skips it and falls through', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: 'SP-1', + prompt: 'Run /skill:refactor', + frequency: '3d', + }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); + + // 0 free slots: the scheduled prompt (a pane) is ineligible; the plan + // tier also needs ≥1 — the outcome is a neutral no-candidate (defensive + // path — via the worker the idle gate already guarantees ≥1 free). + expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + }); + + it('scheduled-prompts tier dispatches with ≥1 free slot (AC3)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: 'SP-1', + prompt: 'Run /skill:refactor', + frequency: '3d', + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('scheduled'); + expect(deps.recordScheduledPromptTrigger).toHaveBeenCalled(); + }); + + it('implement tier is NOT dispatched with 0 free slots: pane minimum ≥1 honored at selection time (F3-fix AC1/AC4)', async () => { + // Regression for the audit finding (F3 AC2 partial): the implement tier + // had a 'Pane minimum' comment but no panesEligible guard — a direct + // dispatchDowntimeWork({freeSlots:0}) dispatched implement. It must be + // skipped exactly like the other pane tiers (ineligible, never a + // strike), falling through to the plan tier's defensive no-candidate. + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); + + // The implement lookup is never consulted at 0 free slots — ineligible, + // not a strike, and falls through to the plan tier's defensive + // no-candidate (via the worker the idle gate already guarantees ≥1). + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('implement tier still dispatches with ≥1 free slot (F3-fix AC2)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); + + it('implement tier is not gated when freeSlots is undefined: direct legacy callers unaffected (F3-fix AC3)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); +}); + + +// ── Dispatch audit trail (WL-0MSGPI4AR000YOK8) ──────────────────────── + +describe('dispatch audit trail', () => { + it('records a plan dispatch with item id, kind, timestamp and cwd', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(deps.recordDispatch).toHaveBeenCalledTimes(1); + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-ABC'); + expect(event.kind).toBe('plan'); + expect(event.cwd).toBe('/repo'); + expect(Number.isNaN(Date.parse(event.dispatchedAt))).toBe(false); + }); + + it('records an intake fallback dispatch', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-DEF', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('intake'); + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-DEF'); + expect(event.kind).toBe('intake'); + }); + + it('does not record when nothing is dispatched', async () => { + const deps = makeDeps(); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('rolls the claim back (claim-rolled-back) when recordDispatch fails — AC1', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + // A rejecting marker write (or a stub that throws) must abort BEFORE + // the pane spawns: an unmarked item is never dispatched (RCA + // WL-0MSRBFFLN005W3VT design point 2 — marker-before-spawn fail-closed). + // WL-0MT32F908002YFFA AC1: the successful CAS claim is then rolled + // back so the next idle period can re-select the item. + recordDispatch: vi.fn().mockRejectedValue(new Error('audit boom')), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('claim-rolled-back'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.rollbackClaim).toHaveBeenCalledWith( + 'WL-ABC', + { status: 'open', stage: 'intake_complete' }, + '/repo', + ); + }); + + it('rolls the claim back (claim-rolled-back) when recordDispatch resolves false — AC1', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + recordDispatch: vi.fn().mockResolvedValue(false), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('claim-rolled-back'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.rollbackClaim).toHaveBeenCalledWith( + 'WL-ABC', + { status: 'open', stage: 'intake_complete' }, + '/repo', + ); + }); + + it('falls back to marker-write-failed when the claim rollback itself fails (fail-closed)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + recordDispatch: vi.fn().mockRejectedValue(new Error('audit boom')), + // Concurrent agent already moved the item — nothing to roll back. + rollbackClaim: vi.fn().mockResolvedValue(false), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('marker-write-failed'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('never crashes when the claim rollback throws (fail-closed)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + recordDispatch: vi.fn().mockResolvedValue(false), + rollbackClaim: vi.fn().mockRejectedValue(new Error('wl update boom')), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('marker-write-failed'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('writes the marker BEFORE the pane spawns (marker-before-spawn ordering)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const markerOrder = (deps.recordDispatch as ReturnType).mock.invocationCallOrder[0]; + const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(markerOrder).toBeLessThan(spawnOrder); + }); + + it('records the dispatched-at stage on plan/intake markers (change-guard input)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.stage).toBe('intake_complete'); + }); +}); + + +// ── CAS claim race + fail-closed paths (RCA WL-0MSRBFFLN005W3VT) ─────── + +describe('dispatch CAS claim race', () => { + it('a losing pane (stale claim) aborts with no pane, no marker, no success record', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + // Another pane claimed the item first: the CAS guard fails stale. + claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'stale' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('claim-failed'); + // No pane, no marker, no success record — the loser aborts entirely. + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('a claim wl failure is a wl-error (a strike, never silently discarded)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'error' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('a stale audit-tier claim also aborts (no fall-through to other tiers)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }, + }), + claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'stale' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('claim-failed'); + // The loser does NOT fall through to plan/intake — exactly one dispatch + // per idle window across panes. + expect(deps.getNextItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('a spawn error makes the outcome not-success (spawn-failed) with the marker already written', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT: send-to-pi.sh' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('spawn-failed'); + expect(outcome.error).toBe('ENOENT: send-to-pi.sh'); + // The marker stands (fail-closed — no re-dispatch of the same item), but + // the outcome is NOT a success (WL-0MSLWJ3I70031Z8U absorbed). + expect(deps.recordDispatch).toHaveBeenCalledTimes(1); + // A failure trace is appended so the audit log distinguishes + // "attempted" from "opened" (AC2) — it never logs success for a pane + // that never appeared. + expect(deps.recordDispatchFailure).toHaveBeenCalledWith( + expect.objectContaining({ + itemId: 'WL-ABC', + kind: 'plan', + cwd: '/repo', + error: 'ENOENT: send-to-pi.sh', + }), + ); + }); + + it('a non-zero script exit makes the outcome not-success (spawn-failed) with the exit trace recorded', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, exitCode: 1 }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('spawn-failed'); + expect(outcome.exitCode).toBe(1); + // The failure trace carries the exit code for the audit log (AC2). + expect(deps.recordDispatchFailure).toHaveBeenCalledWith( + expect.objectContaining({ itemId: 'WL-ABC', kind: 'plan', exitCode: 1 }), + ); + }); + + it('rolls the plan claim back on spawn-failed so the item is re-selectable — AC2/AC4', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT: send-to-pi.sh' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('spawn-failed'); + // The failure trace is preserved AND the claim is rolled back to the + // tier's pre-claim state — the item returns to the selectable backlog. + expect(deps.recordDispatchFailure).toHaveBeenCalledWith( + expect.objectContaining({ itemId: 'WL-ABC', kind: 'plan', error: 'ENOENT: send-to-pi.sh' }), + ); + expect(deps.rollbackClaim).toHaveBeenCalledWith( + 'WL-ABC', + { status: 'open', stage: 'intake_complete' }, + '/repo', + ); + }); + + it('rolls an audit claim back to completed/in_review on spawn-failed (audit tier)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit', status: 'completed' }, + }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('spawn-failed'); + // The audit tier's pre-claim state is completed/in_review, NOT open — + // rolling back to `open` would silently move the item out of the audit + // queue (WL-0MT32F908002YFFA AC1/AC2). + expect(deps.rollbackClaim).toHaveBeenCalledWith( + 'WL-AUD', + { status: 'completed', stage: 'in_review' }, + '/repo', + ); + }); + + it('marker-write-failed recovery: claim → failure → rollback → re-selection next idle period (AC1/AC4)', async () => { + // A tiny in-memory item store: the claim flips it to in_progress, the + // rollback flips it back to open, and getNextItem only offers the item + // while it is open — so the SECOND idle window selects it again. + let status: 'open' | 'in_progress' = 'open'; + const recordDispatch = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true); + const deps = makeDeps({ + getNextItem: vi.fn().mockImplementation(async () => + status === 'open' + ? { + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + } + : { ok: true, candidate: null }, + ), + claimItem: vi.fn().mockImplementation(async () => { + status = 'in_progress'; + return { ok: true }; + }), + recordDispatch, + rollbackClaim: vi.fn().mockImplementation(async () => { + status = 'open'; + return true; + }), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(first.reason).toBe('claim-rolled-back'); + expect(status).toBe('open'); + + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(second.dispatched).toBe(true); + }); + + it('spawn-failed recovery: claim → failure → rollback → re-selection next idle period (AC2/AC4)', async () => { + let status: 'open' | 'in_progress' = 'open'; + const spawnAgentPane = vi + .fn() + .mockResolvedValueOnce({ ok: false, error: 'ENOENT: send-to-pi.sh' }) + .mockResolvedValue({ ok: true }); + const deps = makeDeps({ + getNextItem: vi.fn().mockImplementation(async () => + status === 'open' + ? { + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + } + : { ok: true, candidate: null }, + ), + claimItem: vi.fn().mockImplementation(async () => { + status = 'in_progress'; + return { ok: true }; + }), + spawnAgentPane, + rollbackClaim: vi.fn().mockImplementation(async () => { + status = 'open'; + return true; + }), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(first.reason).toBe('spawn-failed'); + expect(status).toBe('open'); + + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(second.dispatched).toBe(true); + }); + + it('a throwing recordDispatchFailure never crashes the dispatch (fail-closed)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, exitCode: 1 }), + recordDispatchFailure: vi.fn().mockRejectedValue(new Error('log io boom')), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('spawn-failed'); + expect(outcome.exitCode).toBe(1); + }); + + it('claim → marker → spawn ordering is fixed (claim first, marker before spawn)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const claimOrder = (deps.claimItem as ReturnType).mock.invocationCallOrder[0]; + const markerOrder = (deps.recordDispatch as ReturnType).mock.invocationCallOrder[0]; + const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(claimOrder).toBeLessThan(markerOrder); + expect(markerOrder).toBeLessThan(spawnOrder); + }); +}); + diff --git a/packages/herdr/src/downtime-worker.critical-first.test.ts b/packages/herdr/src/downtime-worker.critical-first.test.ts new file mode 100644 index 000000000..cb91ac424 --- /dev/null +++ b/packages/herdr/src/downtime-worker.critical-first.test.ts @@ -0,0 +1,1683 @@ +/** + * packages/herdr/src/downtime-worker.critical-first.test.ts — critical-first + * selection, parsing and review-queue gating tests. + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4VMEHO005X1QQ) to keep each test file focused + * on one concern and under the 2,000-line cap. Covers: + * - critical candidate selection & parsing (`selectCriticalCandidate`, + * `parseCriticalCandidatesOutput`, `resolveDependencyFrontier`, + * `parseDepListBlockersOutput`, `parseShownWorkItem`, + * `parseShowItemOutput`), + * - the critical-first dispatch tier, + * - `needsProducerReview` exclusion and the select/classify gates, + * - review-queue depth gating. + * + * Shared scaffolding (`makeDeps` and the other fixtures) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + * The companion sprint-view / Herdr-head dispatch tests live in + * `downtime-worker.sprint-view.test.ts`. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + classifyItemForDispatch, + createDowntimePoller, + createDowntimeWorker, + criticalSkillKind, + dispatchDowntimeWork, + parseAuditCandidatesOutput, + parseCriticalCandidatesOutput, + parseDepListBlockersOutput, + parseImplementCandidatesOutput, + parseNextCandidatesOutput, + parseShowItemOutput, + parseShownWorkItem, + resolveDependencyFrontier, + selectAuditCandidate, + selectCriticalCandidate, + selectCriticalFirstCandidates, + selectImplementCandidate, + selectNextCandidate, + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + type AuditCandidate, + type CriticalCandidate, + type DowntimeCandidate, + type DowntimeHerdrItem, + type DowntimeItemInfo, + type DowntimeWorkerDeps, + type ImplementCandidate, + type ScheduledPrompt, +} from './downtime-worker.js'; +import { createRoundRobinRegistry } from './downtime-round-robin.js'; +import { + idleAllSlotsFree, + jsonResponseFixture, + makeDeps, +} from './downtime-worker.fixtures.js'; + +// ── Critical-first tier (parent WL-0MT3FM8VA005XBHE) ──────────────────── +// Test-first matrix for the critical-first pre-tier: an open critical item +// at ANY stage (idea / intake_complete / plan_complete) is dispatched with +// its stage-appropriate skill ahead of every non-critical candidate. The +// F2/F3/F4 contract being pinned here (parseCriticalCandidatesOutput, +// selectCriticalCandidate, criticalSkillKind, resolveDependencyFrontier, +// the getNextCriticalCandidate dep, and the tier order) is RED until the +// implementation slices land (WL-0MT3I9UVU004722X, WL-0MT3I9YNZ007IC5V, +// WL-0MT3IA1UB005TLVJ) — the tests reference exports that do not exist yet +// and fail as a unit (the rest of this file stays green). + +// ── Candidate shapes reused across the critical matrix ────────────── + +/** Open critical worklog item at a dispatchable stage. */ +function criticalIdea( + id = 'WL-CRIT-I', + overrides: Record = {}, +): Record { + return { + id, + title: `Critical idea ${id}`, + status: 'open', + stage: 'idea', + priority: 'critical', + sortIndex: 100, + risk: 'low', + effort: 'small', + ...overrides, + }; +} + +/** Open critical worklog item at intake_complete (plan-ready). */ +function criticalReady( + id = 'WL-CRIT-R', + overrides: Record = {}, +): Record { + return { + id, + title: `Critical ready ${id}`, + status: 'open', + stage: 'intake_complete', + priority: 'critical', + sortIndex: 100, + risk: 'low', + effort: 'small', + ...overrides, + }; +} + +/** Open critical worklog item at plan_complete (implement-ready). */ +function criticalPlanned( + id = 'WL-CRIT-P', + overrides: Record = {}, +): Record { + return { + id, + title: `Critical planned ${id}`, + status: 'open', + stage: 'plan_complete', + priority: 'critical', + sortIndex: 100, + risk: 'medium', + effort: 'medium', + ...overrides, + }; +} + +// ── Critical-first: selection helpers (F2) ─────────────────────────── + +describe('critical selection (selectCriticalCandidate)', () => { + it('selects a critical idea candidate (stage-appropriate intake)', () => { + const selected = selectCriticalCandidate([criticalIdea()] as never); + expect(selected?.id).toBe('WL-CRIT-I'); + expect(selected?.stage).toBe('idea'); + expect(criticalSkillKind(selected?.stage ?? '')).toBe('intake'); + }); + + it('selects a critical intake_complete candidate (stage-appropriate plan)', () => { + const selected = selectCriticalCandidate([criticalReady()] as never); + expect(selected?.id).toBe('WL-CRIT-R'); + expect(selected?.stage).toBe('intake_complete'); + expect(criticalSkillKind(selected?.stage ?? '')).toBe('plan'); + }); + + it('selects a critical plan_complete candidate within caps (stage-appropriate implement)', () => { + const selected = selectCriticalCandidate([criticalPlanned()] as never); + expect(selected?.id).toBe('WL-CRIT-P'); + expect(selected?.stage).toBe('plan_complete'); + expect(criticalSkillKind(selected?.stage ?? '')).toBe('implement'); + }); + + it('stage→skill mapping is exact: idea→intake, intake_complete→plan, plan_complete→implement', () => { + expect(criticalSkillKind('idea')).toBe('intake'); + expect(criticalSkillKind('intake_complete')).toBe('plan'); + expect(criticalSkillKind('plan_complete')).toBe('implement'); + }); + + it('non-dispatchable stages map to null (in_progress / in_review are not targets)', () => { + expect(criticalSkillKind('in_progress')).toBeNull(); + expect(criticalSkillKind('in_review')).toBeNull(); + expect(criticalSkillKind('completed')).toBeNull(); + expect(criticalSkillKind('')).toBeNull(); + }); + + it('deterministic selection: lowest sortIndex wins across stages (sortIndex priority order)', () => { + const candidates = [ + criticalReady('WL-CRIT-HIGH-IDX', { sortIndex: 900 }), + criticalIdea('WL-CRIT-LOW-IDX', { sortIndex: 10 }), + criticalPlanned('WL-CRIT-MID', { sortIndex: 500 }), + ] as never; + expect(selectCriticalCandidate(candidates)?.id).toBe('WL-CRIT-LOW-IDX'); + }); + + it('plan_complete above the risk cap is NOT implement-selected (caps retained, Q2)', () => { + const highRisk = criticalPlanned('WL-CRIT-HI-RISK', { risk: 'high' }); + expect(selectCriticalCandidate([highRisk] as never)).toBeNull(); + }); + + it('plan_complete above the effort cap is NOT implement-selected (caps retained, Q2)', () => { + const largeEffort = criticalPlanned('WL-CRIT-HI-EFFORT', { effort: 'large' }); + expect(selectCriticalCandidate([largeEffort] as never)).toBeNull(); + }); + + it('a capped-out plan_complete critical does not shadow a within-cap critical sibling', () => { + const candidates = [ + criticalPlanned('WL-CRIT-HI-RISK', { risk: 'high', sortIndex: 10 }), + criticalReady('WL-CRIT-OK', { sortIndex: 900 }), + ] as never; + // The capped critical is excluded, so the next critical wins (even with + // a higher sortIndex) — caps never multi-starve the critical tier. + expect(selectCriticalCandidate(candidates)?.id).toBe('WL-CRIT-OK'); + }); + + it('excludes non-open items (status=open client-side guard)', () => { + const inProgress = criticalIdea('WL-CRIT-INPROG', { status: 'in_progress' }); + expect(selectCriticalCandidate([inProgress] as never)).toBeNull(); + }); + + it('excludes candidates still at their dispatched-at stage (dispatched-marker change-guard)', () => { + const dispatched = new Map([['WL-CRIT-I', 'idea']]); + const other = criticalReady('WL-CRIT-OTHER'); + const selected = selectCriticalCandidate( + [criticalIdea(), other] as never, + dispatched, + ); + // WL-CRIT-I was dispatched for /skill:intake at idea and is still at idea + // → excluded; the next critical candidate is selected. + expect(selected?.id).toBe('WL-CRIT-OTHER'); + }); + + it('releases a candidate whose stage advanced past its dispatched-at stage', () => { + const dispatched = new Map([['WL-CRIT-R', 'intake_complete']]); + // Now at plan_complete (a different stage) → not suppressed. + const advanced = criticalPlanned('WL-CRIT-R'); + expect(selectCriticalCandidate([advanced] as never, dispatched)?.id).toBe('WL-CRIT-R'); + }); + + it('round-robins within the critical group when a registry is given (tie-break)', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-critical', rng: () => 0.5 }); + const candidates = [ + criticalIdea('WL-CRIT-A', { sortIndex: 1 }), + criticalIdea('WL-CRIT-B', { sortIndex: 2 }), + ] as never; + expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-A'); + expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-B'); + expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-A'); + }); + + it('critical rotation does not disturb the non-critical implement tier cursor', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-critical-isolated', rng: () => 0.5 }); + const criticals = [ + criticalIdea('WL-CRIT-A', { sortIndex: 1 }), + criticalIdea('WL-CRIT-B', { sortIndex: 2 }), + ] as never; + const implements_: ImplementCandidate[] = [ + { id: 'WL-IMP-A', title: 'A', status: 'open', priority: 'high', sortIndex: 1, risk: 'low', effort: 'small' }, + { id: 'WL-IMP-B', title: 'B', status: 'open', priority: 'high', sortIndex: 2, risk: 'low', effort: 'small' }, + ]; + // Rotate the critical group twice… + selectCriticalCandidate(criticals, undefined, registry); + selectCriticalCandidate(criticals, undefined, registry); + // …the implement tier's own cursor is untouched: still starts at A. + expect(selectImplementCandidate(implements_, undefined, registry)?.id).toBe('WL-IMP-A'); + }); + + it('returns null on an empty list', () => { + expect(selectCriticalCandidate([] as never)).toBeNull(); + }); +}); + +describe('parseCriticalCandidatesOutput', () => { + it('parses the wl list batch shape preserving status/stage/risk/effort/sortIndex/priority', () => { + const candidates = parseCriticalCandidatesOutput( + JSON.stringify({ + success: true, + count: 2, + workItems: [ + criticalIdea('WL-CRIT-I', { sortIndex: 7 }), + criticalReady('WL-CRIT-R', { sortIndex: 3 }), + ], + }), + ); + expect(candidates).toEqual([ + expect.objectContaining({ id: 'WL-CRIT-I', stage: 'idea', sortIndex: 7, priority: 'critical' }), + expect.objectContaining({ id: 'WL-CRIT-R', stage: 'intake_complete', sortIndex: 3, priority: 'critical' }), + ]); + }); + + it('malformed JSON yields null (fail-closed)', () => { + expect(parseCriticalCandidatesOutput('not json')).toBeNull(); + }); + + it('entries without an id are skipped; empty workItems yields []', () => { + const candidates = parseCriticalCandidatesOutput( + JSON.stringify({ + success: true, + count: 0, + workItems: [{ title: 'no id', stage: 'idea' }], + }), + ); + expect(candidates).toEqual([]); + }); +}); + +// ── Critical-first: dependency frontier (F3, decision Q3) ──────────── + +describe('resolveDependencyFrontier', () => { + it('an unblocked critical candidate resolves to itself (no frontier redirect)', async () => { + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async () => [], + ); + expect(result?.id).toBe('WL-CRIT-C'); + }); + + it('dispatches the nearest OPEN blocker when the critical candidate is dependency-blocked', async () => { + const blocker = criticalReady('WL-BLOCK'); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => (id === 'WL-CRIT-C' ? [blocker] : []), + ); + expect(result?.id).toBe('WL-BLOCK'); + expect(result?.stage).toBe('intake_complete'); + }); + + it('recurses through dependency-blocked blockers to the nearest open ancestor', async () => { + const ancestor = criticalPlanned('WL-ANCESTOR'); + const midBlocker = criticalReady('WL-MID', { status: 'in_progress' }); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => { + if (id === 'WL-CRIT-C') return [midBlocker]; + if (id === 'WL-MID') return [ancestor]; + return []; + }, + ); + expect(result?.id).toBe('WL-ANCESTOR'); + }); + + it('a chain bottoming in a closed blocker resolves to null (fall through to tiers)', async () => { + const closed = criticalIdea('WL-CLOSED', { status: 'completed' }); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => (id === 'WL-CRIT-C' ? [closed] : []), + ); + expect(result).toBeNull(); + }); + + it('an in_progress/in_review frontier blocker resolves to null (non-dispatchable stage)', async () => { + const busy = criticalReady('WL-BUSY', { status: 'in_progress' }); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => (id === 'WL-CRIT-C' ? [busy] : []), + ); + expect(result).toBeNull(); + }); + + it('tolerates cycles (bounded recursion, no infinite loop)', async () => { + const a = criticalIdea('WL-A'); + const b = criticalReady('WL-B'); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => { + if (id === 'WL-CRIT-C') return [a]; + if (id === 'WL-A') return [b]; + if (id === 'WL-B') return [a]; // cycle: A ↔ B + return []; + }, + ); + // Resolves without hanging; a cycle has no nearest open unblocked + // ancestor, so the frontier yields null (fall through). + expect(result).toBeNull(); + }); + + it('a blocker above the implement caps is not a valid frontier (caps apply to blockers, Q2)', async () => { + const capped = criticalPlanned('WL-CAPPED', { risk: 'high' }); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => (id === 'WL-CRIT-C' ? [capped] : []), + ); + expect(result).toBeNull(); + }); +}); + +describe('parseDepListBlockersOutput', () => { + it('parses outbound depends-on edges as the blockers of the queried item', () => { + const blockers = parseDepListBlockersOutput( + JSON.stringify({ + success: true, + item: 'WL-CRIT-C', + inbound: [{ id: 'WL-DEPENDENT', title: 'Depends on me', status: 'open', direction: 'depended-on-by' }], + outbound: [ + { id: 'WL-BLOCKER', title: 'Blocking item', status: 'open', priority: 'critical', direction: 'depends-on' }, + ], + }), + ); + expect(blockers).toEqual([ + { id: 'WL-BLOCKER', title: 'Blocking item', status: 'open', priority: 'critical' }, + ]); + }); + + it('ignores inbound depended-on-by edges (they are dependents, not blockers)', () => { + const blockers = parseDepListBlockersOutput( + JSON.stringify({ + success: true, + item: 'WL-CRIT-C', + inbound: [{ id: 'WL-DEPENDENT', title: 'Depends on me', status: 'open', direction: 'depended-on-by' }], + outbound: [], + }), + ); + expect(blockers).toEqual([]); + }); + + it('malformed JSON or a missing outbound list yields null (fail-closed)', () => { + expect(parseDepListBlockersOutput('not json')).toBeNull(); + expect(parseDepListBlockersOutput(JSON.stringify({ success: true, item: 'WL-CRIT-C' }))).toBeNull(); + }); +}); + +describe('parseShownWorkItem', () => { + it('parses the wl show single-item shape into a full critical candidate', () => { + const item = parseShownWorkItem( + JSON.stringify({ + success: true, + workItem: { + id: 'WL-BLOCKER', + title: 'Blocking item', + status: 'open', + stage: 'plan_complete', + risk: 'medium', + effort: 'medium', + sortIndex: 3, + priority: 'critical', + }, + }), + ); + expect(item).toEqual({ + id: 'WL-BLOCKER', + title: 'Blocking item', + status: 'open', + stage: 'plan_complete', + risk: 'medium', + effort: 'medium', + sortIndex: 3, + priority: 'critical', + }); + }); + + it('malformed output or a missing workItem yields null (fail-closed)', () => { + expect(parseShownWorkItem('not json')).toBeNull(); + expect(parseShownWorkItem(JSON.stringify({ success: true }))).toBeNull(); + }); +}); + +describe('parseShowItemOutput audit verdict (WL-0MU308WSF0002JWN)', () => { + it('reads the TOP-LEVEL auditResult verdict and timestamp', () => { + const info = parseShowItemOutput( + JSON.stringify({ + success: true, + workItem: { id: 'WL-AUD', status: 'completed', stage: 'in_review' }, + auditResult: { + workItemId: 'WL-AUD', + readyToClose: false, + auditedAt: '2026-01-02T00:00:00.000Z', + }, + }), + ); + expect(info).toMatchObject({ + id: 'WL-AUD', + status: 'completed', + stage: 'in_review', + auditedAt: '2026-01-02T00:00:00.000Z', + auditResult: false, + }); + }); + + it('leaves the verdict absent when auditResult is null (no audit recorded)', () => { + const info = parseShowItemOutput( + JSON.stringify({ success: true, workItem: { id: 'WL-AUD2', stage: 'in_review' }, auditResult: null }), + ); + expect(info!.auditResult).toBeUndefined(); + }); +}); + +// ── Critical-first: tier wiring (F4, decisions Q1/Q2/Q3) ───────────── + +describe('dispatch critical-first tier', () => { + it('dispatches a critical idea candidate with /skill:intake ahead of ANY non-critical candidate', async () => { + const deps = makeDeps({ + // Non-critical implement candidate exists and is the old winner — + // the critical tier must win regardless. + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Non-critical implement', + stage: 'implement', + } as DowntimeCandidate), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('WL-CRIT-I'); + expect(deps.getNextCriticalCandidate).toHaveBeenCalledWith('/repo'); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-I', { status: 'open', stage: 'idea' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-CRIT-I'), + { model: 'plan', cwd: '/repo', itemId: 'WL-CRIT-I', itemTitle: 'Critical idea WL-CRIT-I' }, + ); + }); + + it('dispatches a critical intake_complete candidate with /skill:plan ahead of the implement tier', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Non-critical implement', + stage: 'implement', + } as DowntimeCandidate), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalReady('WL-CRIT-R'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-CRIT-R'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-R', { status: 'open', stage: 'intake_complete' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-CRIT-R'), + expect.anything(), + ); + }); + + it('dispatches a critical plan_complete candidate within caps with /skill:implement first', async () => { + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalPlanned('WL-CRIT-P'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-CRIT-P'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-P', { status: 'open', stage: 'plan_complete' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:implement WL-CRIT-P'), + expect.anything(), + ); + }); + + it('critical-first tier runs AFTER the audit tier (audit keeps its slot)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, + }), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('audit'); + expect(deps.getNextCriticalCandidate).not.toHaveBeenCalled(); + }); + + it('scheduled-prompts tier still runs FIRST (ahead of the critical tier)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: '/skill:refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, + } as ScheduledPrompt), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('scheduled'); + expect(deps.getNextCriticalCandidate).not.toHaveBeenCalled(); + }); + + it('empty critical tier falls through to the non-critical tiers unchanged (regression)', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + } as DowntimeCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('implement'); + expect(deps.getNextCriticalCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + }); + + it('a critical-tier wl failure fails closed to wl-error (never a silent fall-through)', async () => { + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: false }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('during a code freeze critical implement dispatch PAUSES (fail-closed) while plan/intake tiers still run', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalPlanned('WL-CRIT-P'), // implement-ready, but frozen + }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Frozen → the critical tier is still consulted (a critical plan/intake + // candidate could still dispatch, Q1), but the implement-ready critical + // is NOT dispatched (no new code lands mid-release); the non-critical + // plan/intake tiers still dispatch. + expect(deps.getNextCriticalCandidate).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-PLAN'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-PLAN'), + expect.anything(), + ); + expect(deps.claimItem).not.toHaveBeenCalledWith('WL-CRIT-P', expect.anything()); + }); + + it('freeze split-by-skill: critical plan/intake dispatch CONTINUES while frozen (Q1)', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalReady('WL-CRIT-R'), // plan-ready + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-R', { status: 'open', stage: 'intake_complete' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-CRIT-R'), + expect.anything(), + ); + }); + + it('ambiguous marker is treated as frozen (fail-closed): critical implement pauses, plan/intake continue', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalPlanned('WL-CRIT-P'), + }), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDEA', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Frozen: the critical implement candidate is skipped (fail-closed on + // the ambiguous marker); the intake tier still dispatches. + expect(outcome.kind).toBe('intake'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-IDEA'), + expect.anything(), + ); + expect(deps.claimItem).not.toHaveBeenCalledWith('WL-CRIT-P', expect.anything()); + }); + + it('no double-dispatch: claim CAS applies to the critical tier (stale claim aborts)', async () => { + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'stale' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('claim-failed'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('dependency-blocked critical candidate dispatches its frontier blocker with the blocker stage-appropriate skill (Q3)', async () => { + // The dep returns the frontier-resolved blocker (like getNextItem does): + // the critical item is dependency-blocked, so the selected candidate IS + // the nearest open blocker, carrying ITS stage. + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalReady('WL-BLOCKER', { title: 'Blocking work item' }), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-BLOCKER'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-BLOCKER', { status: 'open', stage: 'intake_complete' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-BLOCKER'), + expect.anything(), + ); + }); + + it('records the critical dispatch with the skill-mapped kind (rolling log + change-guard consistency)', async () => { + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-CRIT-I'); + expect(event.kind).toBe('intake'); + expect(event.stage).toBe('idea'); + expect(event.cwd).toBe('/repo'); + }); + + it('single-flight: a concurrent dispatch refuses the critical tier (dispatch-in-flight)', async () => { + // Gate the pane spawn so the first dispatch is verifiably in flight + // when the second call arrives (same pattern as the existing + // single-flight guard test, using the critical-tier candidate). + let release!: () => void; + const gate = new Promise<{ ok: true }>((resolve) => { + release = () => resolve({ ok: true }); + }); + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + spawnAgentPane: vi.fn().mockImplementation(() => gate), + }); + + const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + release(); + const [firstOutcome, secondOutcome] = await Promise.all([first, second]); + + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(firstOutcome.dispatched).toBe(true); + expect(firstOutcome.kind).toBe('intake'); + expect(secondOutcome.dispatched).toBe(false); + expect(secondOutcome.reason).toBe('dispatch-in-flight'); + }); +}); + +// ── needsProducerReview exclusion (WL-0MTIAL65N004T22F) ───────────── +// AC1–AC4: every tier (audit / implement / plan / intake / critical-first +// including frontier blockers and the coordination leader path) excludes +// items with needsProducerReview === true; absent/false/undefined is +// dispatchable; failed/unparseable parsing is fail-safe; clearing the flag +// makes the item dispatchable again. + +describe('needsProducerReview exclusion', () => { + const auditDated = () => new Date(Date.now() - 1000).toISOString(); + const auditCandidate = (overrides: Partial = {}): AuditCandidate => ({ + id: `AUD-${Math.random().toString(36).slice(2, 8)}`, + title: 'Audit candidate', + sortIndex: 1, + updatedAt: auditDated(), + ...overrides, + }); + const implementCandidate = (overrides: Partial = {}): ImplementCandidate => ({ + id: `IMP-${Math.random().toString(36).slice(2, 8)}`, + title: 'Implement candidate', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 1, + ...overrides, + }); + const nextCandidate = (overrides: Partial = {}): DowntimeCandidate => ({ + id: `NEXT-${Math.random().toString(36).slice(2, 8)}`, + title: 'Next candidate', + stage: 'intake_complete', + status: 'open', + sortIndex: 1, + ...overrides, + }); + const critCandidate = (overrides: Partial = {}): CriticalCandidate => ({ + id: `CR-${Math.random().toString(36).slice(2, 8)}`, + title: 'Critical candidate', + status: 'open', + stage: 'idea', + sortIndex: 1, + ...overrides, + }); + + describe('selectAuditCandidate', () => { + it('excludes needsProducerReview === true candidates', () => { + const blockedAudit = auditCandidate({ id: 'A1', needsProducerReview: true, sortIndex: 1 }); + const allowedAudit = auditCandidate({ id: 'A2', sortIndex: 10 }); + const sel = selectAuditCandidate([blockedAudit, allowedAudit]); + expect(sel?.id).toBe('A2'); + }); + it('absent/false/undefined needsProducerReview remain dispatchable', () => { + const falsy = auditCandidate({ id: 'A3', needsProducerReview: false, sortIndex: 1 }); + const absent = auditCandidate({ id: 'A4', sortIndex: 2 }); + expect(selectAuditCandidate([falsy])?.id).toBe('A3'); + expect(selectAuditCandidate([absent])?.id).toBe('A4'); + }); + it('returns null when the tier contains only review-gated candidates', () => { + const b = auditCandidate({ id: 'A5', needsProducerReview: true, sortIndex: 1 }); + expect(selectAuditCandidate([b])).toBeNull(); + }); + it('clearing needsProducerReview makes the item dispatchable again', () => { + const c = auditCandidate({ id: 'A6', needsProducerReview: true, sortIndex: 1 }); + expect(selectAuditCandidate([c])).toBeNull(); + // Clear the flag — same candidate object, flag removed → dispatchable. + c.needsProducerReview = false; + expect(selectAuditCandidate([c])?.id).toBe('A6'); + }); + }); + + describe('selectImplementCandidate', () => { + it('excludes needsProducerReview === true candidates', () => { + const blocked = implementCandidate({ id: 'I1', needsProducerReview: true, sortIndex: 1 }); + const allowed = implementCandidate({ id: 'I2', sortIndex: 10 }); + const sel = selectImplementCandidate([blocked, allowed] as ImplementCandidate[]); + expect(sel?.id).toBe('I2'); + }); + it('absent/false remain dispatchable', () => { + expect(selectImplementCandidate([implementCandidate({ id: 'I3', needsProducerReview: false, sortIndex: 1 })] as ImplementCandidate[])?.id).toBe('I3'); + expect(selectImplementCandidate([implementCandidate({ id: 'I4', sortIndex: 1 })] as ImplementCandidate[])?.id).toBe('I4'); + }); + it('returns null when only review-gated candidates remain', () => { + expect(selectImplementCandidate([implementCandidate({ id: 'I5', needsProducerReview: true, sortIndex: 1 })] as ImplementCandidate[])).toBeNull(); + }); + it('clearing the flag makes the item dispatchable again', () => { + const c = implementCandidate({ id: 'I6', needsProducerReview: true, sortIndex: 1 }); + expect(selectImplementCandidate([c] as ImplementCandidate[])).toBeNull(); + c.needsProducerReview = false; + expect(selectImplementCandidate([c] as ImplementCandidate[])?.id).toBe('I6'); + }); + }); + + describe('selectNextCandidate', () => { + it('excludes needsProducerReview === true candidates', () => { + const blocked = nextCandidate({ id: 'N1', needsProducerReview: true, sortIndex: 1, stage: 'intake_complete' }); + const allowed = nextCandidate({ id: 'N2', sortIndex: 10, stage: 'intake_complete' }); + expect(selectNextCandidate([blocked, allowed])?.id).toBe('N2'); + }); + it('absent/false remain dispatchable', () => { + expect(selectNextCandidate([nextCandidate({ id: 'N3', needsProducerReview: false, sortIndex: 1 })])?.id).toBe('N3'); + expect(selectNextCandidate([nextCandidate({ id: 'N4', sortIndex: 1 })])?.id).toBe('N4'); + }); + it('returns null when only review-gated candidates remain', () => { + expect(selectNextCandidate([nextCandidate({ id: 'N5', needsProducerReview: true, sortIndex: 1 })])).toBeNull(); + }); + it('clearing the flag makes the item dispatchable again', () => { + const c = nextCandidate({ id: 'N6', needsProducerReview: true, sortIndex: 1 }); + expect(selectNextCandidate([c])).toBeNull(); + c.needsProducerReview = false; + expect(selectNextCandidate([c])?.id).toBe('N6'); + }); + }); + + describe('selectCriticalCandidate', () => { + it('excludes needsProducerReview === true candidates', () => { + const blocked = critCandidate({ id: 'C1', needsProducerReview: true, sortIndex: 1 }); + const allowed = critCandidate({ id: 'C2', sortIndex: 10 }); + expect(selectCriticalCandidate([blocked, allowed] as never)?.id).toBe('C2'); + }); + it('absent/false remain dispatchable', () => { + expect(selectCriticalCandidate([critCandidate({ id: 'C3', needsProducerReview: false, sortIndex: 1 })] as never)?.id).toBe('C3'); + expect(selectCriticalCandidate([critCandidate({ id: 'C4', sortIndex: 1 })] as never)?.id).toBe('C4'); + }); + it('returns null when only review-gated criticals remain', () => { + expect(selectCriticalCandidate([critCandidate({ id: 'C5', needsProducerReview: true })] as never)).toBeNull(); + }); + }); + + describe('resolveDependencyFrontier (AC2)', () => { + it('resolves to null when the only blocker is review-gated', async () => { + const crit: CriticalCandidate = critCandidate({ id: 'C6', stage: 'plan_complete', risk: 'low', effort: 'medium' }); + const fetchBlockers = async (_id: string): Promise => [ + { id: 'B1', title: 'Review-gated blocker', status: 'open', stage: 'idea', needsProducerReview: true }, + ]; + expect(await resolveDependencyFrontier(crit, fetchBlockers)).toBeNull(); + }); + it('skips a review-gated direct blocker and still reaches an open ancestor beneath a non-dispatchable wrapper', async () => { + const crit: CriticalCandidate = critCandidate({ id: 'C7', stage: 'plan_complete', risk: 'low', effort: 'medium' }); + const fetchBlockers = async (id: string): Promise => { + if (id === 'C7') return [{ id: 'MID', title: 'Non-dispatchable wrapper', status: 'open', stage: 'in_review', needsProducerReview: true }]; + if (id === 'MID') return [{ id: 'DEEP', title: 'Open deep blocker', status: 'open', stage: 'idea' }]; + return []; + }; + // Walk recurses through MID (non-dispatchable but has open descendant) → DEEP. + expect((await resolveDependencyFrontier(crit, fetchBlockers))?.id).toBe('DEEP'); + }); + it('review-gated plan_complete blockers are excluded (caps-covered + flag)', async () => { + const crit: CriticalCandidate = critCandidate({ id: 'C8', stage: 'plan_complete', risk: 'low', effort: 'medium' }); + const fetchBlockers = async (_id: string): Promise => [ + { id: 'B2', title: 'Gated plan_complete', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'small', needsProducerReview: true }, + ]; + expect(await resolveDependencyFrontier(crit, fetchBlockers)).toBeNull(); + }); + }); + + describe('classifyItemForDispatch', () => { + it('returns null immediately when needsProducerReview === true (AC1 — every tier)', () => { + for (const info of [ + { id: 'X', status: 'completed', stage: 'in_review', updatedAt: new Date().toISOString(), needsProducerReview: true }, + { id: 'X', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'medium', needsProducerReview: true }, + { id: 'X', status: 'open', stage: 'intake_complete', needsProducerReview: true }, + { id: 'X', status: 'open', stage: 'idea', needsProducerReview: true }, + ] as DowntimeItemInfo[]) { + expect(classifyItemForDispatch(info)).toBeNull(); + } + }); + it('dispatches normally when needsProducerReview is false/undefined', () => { + expect(classifyItemForDispatch({ id: 'I', status: 'open', stage: 'idea', needsProducerReview: false } as DowntimeItemInfo)).toBe('intake'); + expect(classifyItemForDispatch({ id: 'I', status: 'open', stage: 'idea' } as DowntimeItemInfo)).toBe('intake'); + }); + + // ── risk-effort dispatch for missing risk/effort (WL-0MTTSWCJR003OMN7) ── + it('returns risk-effort for plan_complete items with missing risk', () => { + const info = { id: 'RE1', status: 'open', stage: 'plan_complete', risk: '', effort: 'small' } as DowntimeItemInfo; + expect(classifyItemForDispatch(info)).toBe('risk-effort'); + }); + it('returns risk-effort for plan_complete items with missing effort', () => { + const info = { id: 'RE2', status: 'open', stage: 'plan_complete', risk: 'low', effort: '' } as DowntimeItemInfo; + expect(classifyItemForDispatch(info)).toBe('risk-effort'); + }); + it('returns risk-effort for plan_complete items with both risk and effort missing', () => { + const info = { id: 'RE3', status: 'open', stage: 'plan_complete', risk: '', effort: '' } as DowntimeItemInfo; + expect(classifyItemForDispatch(info)).toBe('risk-effort'); + }); + it('returns risk-effort for plan_complete items with undefined risk/effort', () => { + expect(classifyItemForDispatch({ id: 'RE4', status: 'open', stage: 'plan_complete' } as DowntimeItemInfo)).toBe('risk-effort'); + expect(classifyItemForDispatch({ id: 'RE5', status: 'open', stage: 'plan_complete', risk: null, effort: null } as DowntimeItemInfo)).toBe('risk-effort'); + }); + it('does NOT dispatch risk-effort when plan_complete has valid risk/effort within caps', () => { + expect(classifyItemForDispatch({ id: 'RE6', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'small' } as DowntimeItemInfo)).toBe('implement'); + expect(classifyItemForDispatch({ id: 'RE7', status: 'open', stage: 'plan_complete', risk: 'medium', effort: 'medium' } as DowntimeItemInfo)).toBe('implement'); + }); + it('classifies ABOVE-cap risk as implement (risk cap removed — high/severe no longer excluded)', () => { + expect(classifyItemForDispatch({ id: 'RE8', status: 'open', stage: 'plan_complete', risk: 'high', effort: 'small' } as DowntimeItemInfo)).toBe('implement'); + expect(classifyItemForDispatch({ id: 'RE8b', status: 'open', stage: 'plan_complete', risk: 'critical', effort: 'small' } as DowntimeItemInfo)).toBe('implement'); + expect(classifyItemForDispatch({ id: 'RE8c', status: 'open', stage: 'plan_complete', risk: 'severe', effort: 'medium' } as DowntimeItemInfo)).toBe('implement'); + }); + it('returns null when plan_complete has valid effort but ABOVE cap (extra large)', () => { + expect(classifyItemForDispatch({ id: 'RE9', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'extra large' } as DowntimeItemInfo)).toBeNull(); + }); + + // ── blocked status is dispatchable (WL-0MUBUA9C8009N0K5 AC1) ── + it('classifies blocked items to their stage-appropriate kind', () => { + expect(classifyItemForDispatch({ id: 'B1', status: 'blocked', stage: 'idea' } as DowntimeItemInfo)).toBe('intake'); + expect(classifyItemForDispatch({ id: 'B2', status: 'blocked', stage: 'intake_complete' } as DowntimeItemInfo)).toBe('plan'); + expect(classifyItemForDispatch({ id: 'B3', status: 'blocked', stage: 'plan_complete', risk: 'high', effort: 'small' } as DowntimeItemInfo)).toBe('implement'); + }); + it('still excludes blocked items needing producer review', () => { + expect(classifyItemForDispatch({ id: 'B4', status: 'blocked', stage: 'idea', needsProducerReview: true } as DowntimeItemInfo)).toBeNull(); + }); + it('does not classify a blocked item with a non-dispatchable stage', () => { + expect(classifyItemForDispatch({ id: 'B5', status: 'blocked', stage: 'in_review' } as DowntimeItemInfo)).toBeNull(); + expect(classifyItemForDispatch({ id: 'B6', status: 'blocked', stage: 'done' } as DowntimeItemInfo)).toBeNull(); + }); + + // ── retired in_progress stage (WL-0MTTSWCJR003OMN7 — OSL dead zones) ── + it('dispatches risk-effort for open/in_progress items (retired stage)', () => { + expect(classifyItemForDispatch({ id: 'IP1', status: 'open', stage: 'in_progress' } as DowntimeItemInfo)).toBe('risk-effort'); + expect(classifyItemForDispatch({ id: 'IP2', status: 'open', stage: 'in_progress', risk: 'low' } as DowntimeItemInfo)).toBe('risk-effort'); + expect(classifyItemForDispatch({ id: 'IP3', status: 'open', stage: 'in_progress', risk: 'low', effort: 'small' } as DowntimeItemInfo)).toBe('risk-effort'); + }); + it('still blocks npr items on in_progress stage', () => { + expect(classifyItemForDispatch({ id: 'IP4', status: 'open', stage: 'in_progress', needsProducerReview: true } as DowntimeItemInfo)).toBeNull(); + }); + }); + + describe('selectCriticalFirstCandidates — blocked critical items (AC2)', () => { + const crit = (over: Partial): DowntimeHerdrItem => ({ + id: 'C', + title: 'Crit', + status: 'open', + stage: 'idea', + priority: 'critical', + sortIndex: 1, + ...over, + }); + + it('includes a blocked critical item at its stage-appropriate kind', () => { + const out = selectCriticalFirstCandidates([ + crit({ id: 'C-BLOCKED-PLAN', status: 'blocked', stage: 'intake_complete' }), + crit({ id: 'C-BLOCKED-IMPL', status: 'blocked', stage: 'plan_complete', risk: 'High', effort: 'S' }), + ]); + expect(out.map((c) => [c.item.id, c.kind])).toEqual([ + ['C-BLOCKED-PLAN', 'plan'], + ['C-BLOCKED-IMPL', 'implement'], + ]); + }); + + it('includes open and blocked critical items ordered by sortIndex', () => { + const out = selectCriticalFirstCandidates([ + crit({ id: 'C-OPEN', status: 'open', stage: 'idea', sortIndex: 20 }), + crit({ id: 'C-BLOCKED', status: 'blocked', stage: 'idea', sortIndex: 10 }), + ]); + expect(out.map((c) => c.item.id)).toEqual(['C-BLOCKED', 'C-OPEN']); + }); + + it('still excludes blocked critical items needing producer review', () => { + const out = selectCriticalFirstCandidates([ + crit({ id: 'C-NPR', status: 'blocked', stage: 'idea', needsProducerReview: true }), + ]); + expect(out).toEqual([]); + }); + + it('does not select a non-critical blocked item', () => { + const out = selectCriticalFirstCandidates([ + crit({ id: 'C-OTHER', status: 'blocked', stage: 'idea', priority: 'high' }), + ]); + expect(out).toEqual([]); + }); + }); + + describe('blocked candidate claim CAS (AC3)', () => { + it('claims a blocked candidate with its actual blocked status as the CAS guard', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + { id: 'B-PLAN', title: 'Blocked plan', status: 'blocked', stage: 'intake_complete', priority: 'critical', sortIndex: 1 }, + ]}), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('B-PLAN'); + // The CAS guard must match the item's ACTUAL status, not the tier's + // nominal `open` — otherwise the claim aborts as stale. + expect(deps.claimItem).toHaveBeenCalledWith( + 'B-PLAN', + { status: 'blocked', stage: 'intake_complete' }, + '/repo', + ); + }); + }); + + describe('parse* preserves needsProducerReview (AC4 fail-safe)', () => { + it('parseNextCandidatesOutput preserves true/undefined/absent per candidate', () => { + const out = JSON.stringify({ + workItems: [ + { workItem: { id: 'P1', title: 'A', status: 'open', priority: 'medium', needsProducerReview: 1 } }, + { workItem: { id: 'P2', title: 'B', status: 'open', priority: 'medium', needsProducerReview: 0 } }, + { workItem: { id: 'P3', title: 'C', status: 'open', priority: 'medium', needsProducerReview: true } }, + { workItem: { id: 'P4', title: 'D', status: 'open', priority: 'medium' } }, + ], + }); + const c = parseNextCandidatesOutput(out, 'intake_complete')!; + expect(c.find((x) => x.id === 'P1')!.needsProducerReview).toBe(true); + expect(c.find((x) => x.id === 'P2')!.needsProducerReview).toBe(false); + expect(c.find((x) => x.id === 'P3')!.needsProducerReview).toBe(true); + expect(c.find((x) => x.id === 'P4')!.needsProducerReview).toBeUndefined(); + }); + it('parseImplementCandidatesOutput preserves needsProducerReview', () => { + const out = JSON.stringify({ + workItems: [{ workItem: { id: 'IM', title: 'X', status: 'open', risk: 'low', effort: 'small', needsProducerReview: true } }], + }); + expect(parseImplementCandidatesOutput(out)![0]!.needsProducerReview).toBe(true); + }); + it('parseAuditCandidatesOutput preserves needsProducerReview', () => { + const out = JSON.stringify({ + workItems: [{ id: 'AU', title: 'A', auditedAt: null, updatedAt: new Date().toISOString(), needsProducerReview: true }], + }); + expect(parseAuditCandidatesOutput(out)![0]!.needsProducerReview).toBe(true); + }); + it('parseCriticalCandidatesOutput preserves needsProducerReview', () => { + const out = JSON.stringify({ + workItems: [{ id: 'CR', title: 'A', status: 'open', stage: 'idea', needsProducerReview: true }], + }); + expect(parseCriticalCandidatesOutput(out)![0]!.needsProducerReview).toBe(true); + }); + it('parseShowItemOutput preserves needsProducerReview', () => { + const out = JSON.stringify({ success: true, workItem: { id: 'SH', title: 'shown', status: 'open', stage: 'idea', needsProducerReview: true } }); + expect(parseShownWorkItem(out)!.needsProducerReview).toBe(true); + }); + it('dispatch falls through when every candidate in every tier needs review (no-candidate, not wl-error)', async () => { + // Every tier returns a flagged candidate (or empty) → no-candidate, never a strike. + // Only the audit tier needs an explicit candidate: every other tier is + // left at its neutral `makeDeps` default (empty), which already models + // "no candidate". + const deps2 = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'A-B', title: 'A-B', stage: 'audit', needsProducerReview: true } }), + }); + // Audit tier candidate is review-gated → skipped → falls through to no-candidate. + const outcome = await dispatchDowntimeWork(deps2, { model: 'plan', cwd: '/repo' }); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + }); + }); +}); + +// ── Review-queue depth gate (WL-0MT2UQWOR007CYY9) ────────────────────── +// The implement tier is gated by the depth of the completed/in_review review +// queue: when root items >= browseItemCount, only critical-priority +// implement candidates remain eligible. The gate is re-read live on every +// dispatch (no plugin restart needed), fail-closed on wl error (gate active), +// and uses a neutral reason ('review-queue-hold') when plan/intake are also empty +// — never 'no-candidate', so the worker's cooldown is not triggered. + +// ── Review-queue depth gate (WL-0MT2UQWOR007CYY9) ────────────────────── +// The implement tier is gated by the depth of the completed/in_review review +// queue: when root items >= browseItemCount, only critical-priority +// implement candidates remain eligible. The gate is re-read live on every +// dispatch (no plugin restart needed), fail-closed on wl error (gate active), +// and uses a neutral reason ('review-queue-hold') when plan/intake are also empty +// — never 'no-candidate', so the worker's cooldown is not triggered. + +describe('review-queue depth gate (WL-0MT2UQWOR007CYY9)', () => { + const implementCandidate = (overrides: Partial = {}): ImplementCandidate => ({ + id: `IMP-${Math.random().toString(36).slice(2, 8)}`, + title: 'Implement candidate', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 1, + ...overrides, + }); + + const planCandidate: DowntimeCandidate = { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }; + + // ── AC1: critical bypass ─────────────────────────────────────────── + it('AC1: a critical-priority implement candidate always dispatches regardless of queue depth', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-CRIT', priority: 'critical' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(999), // deeply queued + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(deps.claimItem).toHaveBeenCalledWith('IMP-CRIT', { status: 'open', stage: 'plan_complete' }, '/repo'); + }); + + it('AC1: critical bypass works even when browseItemCount is 1 (queue deep)', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-CRIT2', priority: 'critical' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(1), // exactly at threshold + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 1 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + }); + + // ── AC2: gate inactive — all priorities eligible ─────────────────── + it('AC2: when reviewQueueCount < browseItemCount, all priorities dispatch unchanged', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-MED', priority: 'medium' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(10), // under threshold + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(deps.claimItem).toHaveBeenCalledWith('IMP-MED', { status: 'open', stage: 'plan_complete' }, '/repo'); + }); + + it('AC2: gate inactive — non-critical (high/low) dispatches when under threshold', async () => { + for (const priority of ['high', 'low'] as const) { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: `IMP-${priority}`, priority }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(0), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + } + }); + + // ── AC3: gate active — non-critical excluded ─────────────────────── + it('AC3: when reviewQueueCount == browseItemCount, non-critical candidates are excluded', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-NONCRIT', priority: 'high' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(20), // at threshold + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + // Non-critical candidate gated → falls through to plan/intake + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + it('AC3: gate active — only non-critical candidates: implement resolves null, falls through to plan', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-LOW', priority: 'low' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(25), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + // Plan tier should be reached + expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); + }); + + it('AC3: gate boundary — count == browseItemCount - 1: gate NOT active, all priorities dispatch', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-UNDER', priority: 'high' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(19), // one below threshold + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + }); + + it('AC3: gate boundary — count == browseItemCount: gate active, non-critical excluded', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-AT', priority: 'high' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(20), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + // Non-critical gated, no plan/intake → review-queue-hold + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('review-queue-hold'); + }); + + // ── AC4: gate applies ONLY to implement tier ─────────────────────── + it('AC4: audit tier is NOT affected by the review-queue gate', async () => { + const auditCandidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getReviewQueueCount: vi.fn().mockResolvedValue(999), // deeply queued + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-GATED', priority: 'high' }), + ), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + // Audit tier still dispatches regardless of review-queue depth + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + }); + + it('AC4: plan tier dispatches even when the review-queue gate is deep', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-GATED', priority: 'high' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(999), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + // ── AC5: bounded timeout and fail-closed ─────────────────────────── + it('AC5: a getReviewQueueCount error (null) activates the gate — only critical dispatches', async () => { + const deps = makeDeps({ + getReviewQueueCount: vi.fn().mockResolvedValue(null), // gate active on error + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-NONCRIT', priority: 'medium' }), + ), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + // Non-critical gated by error → review-queue-hold (no-candidate not triggered) + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('review-queue-hold'); + }); + + it('AC5: fail-closed — queue error + critical candidate: critical still dispatches', async () => { + const deps = makeDeps({ + getReviewQueueCount: vi.fn().mockResolvedValue(null), // gate active on error + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-CRIT', priority: 'critical' }), + ), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + }); + + // ── AC6: neutral reason (never 'no-candidate') ───────────────────── + it('AC6: gate skip with empty plan AND intake reports review-queue-hold, never no-candidate', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-GATED', priority: 'high' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(25), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), // plan empty + // intake (idea tier) is attempted but the review-queue-hold check short-circuits + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + // Gate skip with empty plan/intake → review-queue-hold, NOT no-candidate + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('review-queue-hold'); + // No cooldown triggered — polling continues + }); + + it('AC6: non-gated empty backlog still reports no-candidate (unchanged)', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getReviewQueueCount: vi.fn().mockResolvedValue(0), // not gated + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + }); + + // ── Default browseItemCount ──────────────────────────────────────── + it('uses default browseItemCount of 20 when not provided', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-DEFAULT', priority: 'high' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(20), + // No browseItemCount passed — defaults to 20 + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Gate active at default threshold → non-critical excluded + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('review-queue-hold'); + }); + + it('default browseItemCount — count 19 still dispatches non-critical', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-DEFAULT', priority: 'high' }), + ), + getReviewQueueCount: vi.fn().mockResolvedValue(19), + // No browseItemCount — defaults to 20 + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + }); + + // ── Root-only counting ───────────────────────────────────────────── + it('root-only: the queue count uses --root-only (child items excluded)', async () => { + // This tests the implementation uses the correct wl query. The + // getReviewQueueCount mock returns whatever the caller passes. + // The real implementation uses --root-only so children are excluded. + const deps = makeDeps({ + getReviewQueueCount: vi.fn().mockResolvedValue(5), + getNextImplementCandidate: vi.fn().mockResolvedValue( + implementCandidate({ id: 'IMP-ROOT', priority: 'high' }), + ), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + // Under threshold → gate not active → implement dispatches + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + }); +}); + +// ── Review-queue depth gate on the LIVE Herdr-head path (WL-0MTTSWC1X005P4VD) ── +// Root cause B2: the original gate lived ONLY in the legacy tier chain, which +// is unreachable while the Herdr head is non-empty (the production state), so +// non-critical implements kept dispatching into a deep review queue. These +// tests pin the gate on the LIVE `dispatchFromHerdrList` path (exercised via +// `dispatchDowntimeWork` with a populated `getHerdrListHead`): audits (the +// queue drain), plan, intake and critical implements flow unconditionally; +// only NON-CRITICAL implement candidates are held; a deep queue with nothing +// audit-needed reports 'review-queue-hold' (never 'no-candidate'). + +describe('review-queue depth gate — live Herdr-head path (WL-0MTTSWC1X005P4VD)', () => { + const now = Date.now(); + const fresh = new Date(now - 60_000).toISOString(); + const auditHead = (id: string): DowntimeHerdrItem => ({ + id, + title: `Audit ${id}`, + status: 'completed', + stage: 'in_review', + updatedAt: fresh, + sortIndex: 10, + }); + const implementHead = (id: string, priority = 'high'): DowntimeHerdrItem => ({ + id, + title: `Implement ${id}`, + status: 'open', + stage: 'plan_complete', + risk: 'Low', + effort: 'S', + priority, + sortIndex: 20, + }); + const planHead = (id: string): DowntimeHerdrItem => ({ + id, + title: `Plan ${id}`, + status: 'open', + stage: 'intake_complete', + sortIndex: 30, + }); + const intakeHead = (id: string): DowntimeHerdrItem => ({ + id, + title: `Intake ${id}`, + status: 'open', + stage: 'idea', + sortIndex: 40, + }); + + it('AC1: with a deep queue an AUDIT candidate still dispatches even behind a held non-critical implement', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1'), auditHead('AUD-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(999), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(outcome.candidate?.id).toBe('AUD-1'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:audit AUD-1'), + expect.anything(), + ); + }); + + it('AC1: an audit-first deep queue dispatches the audit (the gate is implement-only)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [auditHead('AUD-1'), implementHead('IMP-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(999), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + }); + + it('AC2: a deep queue HOLDS a non-critical implement — nothing audit-needed left → review-queue-hold (never no-candidate)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(25), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('review-queue-hold'); + expect(outcome.reason).not.toBe('no-candidate'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('AC2: a CRITICAL implement dispatches even when the queue is deep', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-CRIT', 'critical')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(999), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('IMP-CRIT'); + }); + + it('AC2: PLAN and INTAKE candidates flow while the queue is deep', async () => { + // Plan candidate deeper than a held implement → plan dispatches. + const planDeps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1'), planHead('PLN-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(999), + }); + const planOutcome = await dispatchDowntimeWork(planDeps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(planOutcome.dispatched).toBe(true); + expect(planOutcome.kind).toBe('plan'); + expect(planOutcome.candidate?.id).toBe('PLN-1'); + + // Intake candidate behind a held implement → intake dispatches. + const intakeDeps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1'), intakeHead('IDE-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(999), + }); + const intakeOutcome = await dispatchDowntimeWork(intakeDeps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(intakeOutcome.dispatched).toBe(true); + expect(intakeOutcome.kind).toBe('intake'); + expect(intakeOutcome.candidate?.id).toBe('IDE-1'); + }); + + it('shallow queue: a non-critical implement dispatches unchanged (threshold not met)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(10), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + }); + + it('AC5 boundary: count == browseItemCount HOLDS; count == browseItemCount - 1 dispatches', async () => { + const atThreshold = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(20), + }); + const held = await dispatchDowntimeWork(atThreshold, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(held.dispatched).toBe(false); + expect(held.reason).toBe('review-queue-hold'); + + const underThreshold = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(19), + }); + const dispatched = await dispatchDowntimeWork(underThreshold, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(dispatched.dispatched).toBe(true); + expect(dispatched.kind).toBe('implement'); + }); + + it('AC5 fail-closed: a count-query failure (null) activates the gate on the live path', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(null), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('review-queue-hold'); + }); + + it('the review gate is never consulted while frozen (code-freeze already pauses implements)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1'), planHead('PLN-1')] }), + getReviewQueueCount: vi.fn().mockResolvedValue(25), + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + // Freeze skips the implement; plan still dispatches. The gate read is + // skipped entirely under a freeze (single shared read in the caller). + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); +}); + +// ── Worker keeps polling while the review queue is deep (WL-0MTTSWC1X005P4VD) ── +// RCA root cause A regression: queue depth used to WRITE the disable marker +// and early-return BEFORE leader-election/check-in (the 21:53Z silence). The +// sprint-complete auto-disable is removed; a deep queue only ever yields the +// neutral 'review-queue-hold' reason, which never triggers the no-candidate +// cooldown — the worker keeps checking in and keeps polling. + +describe('worker keeps polling while the review queue is deep (WL-0MTTSWC1X005P4VD)', () => { + // Both tests below install fake timers; restore the real clock after each + // one so the leaked fake timer state cannot affect later describe blocks + // (the split of the pane suites removed the earlier incidental + // `vi.useRealTimers()` that previously masked this leak). + afterEach(() => { + vi.useRealTimers(); + }); + + function makeDeepQueueWorker(overrides: Partial = {}) { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + // Deep queue + only a non-critical implement candidate remains. + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [] }), + getReviewQueueCount: vi.fn().mockResolvedValue(25), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'IMP-HELD', + title: 'Held implement', + stage: 'implement', + status: 'open', + priority: 'high', + }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), // plan+intake empty + ...overrides, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg }; + } + + it('a deep queue never triggers the no-candidate cooldown (review-queue-hold is neutral)', async () => { + vi.useFakeTimers(); + const start = 1_000_000; + vi.setSystemTime(start); + const { worker, deps } = makeDeepQueueWorker(); + await worker.tick(); // idle run starts + expect(worker.idleSince).toBe(start); + + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + const at = await worker.tick(); + // Held: no dispatch, but NOT no-candidate → the worker must NOT pause. + expect(at.dispatched).toBe(false); + expect(at.polled).toBe(true); + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(0); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + // Queue thins below the threshold → dispatch resumes on the very next + // idle tick (no stale cooldown blocking it — the neutral hold never + // reset the idle run, so the warm run is immediately dispatchable). + (deps.getReviewQueueCount as ReturnType).mockResolvedValue(5); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + const resumed = await worker.tick(); + expect(resumed.dispatched).toBe(true); + expect(worker.paused).toBe(false); + // The implement pane actually spawned (tick results carry no kind field). + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:implement IMP-HELD'), + expect.anything(), + ); + }); + + it('queue depth alone never short-circuits a tick (no disable marker write)', async () => { + vi.useFakeTimers(); + const start = 2_000_000; + vi.setSystemTime(start); + const { worker } = makeDeepQueueWorker({ getReviewQueueCount: vi.fn().mockResolvedValue(999) }); + // Previously the sprint-complete auto-disable returned BEFORE the poll + // ({polled:false}) when the count crossed browseItemCount. Now the worker + // always polls — the deep queue only gates implement dispatch inside the + // dispatch call (which returns the neutral review-queue-hold). + const result = await worker.tick(); + expect(result.polled).toBe(true); + expect(worker.paused).toBe(false); + }); +}); diff --git a/packages/herdr/src/downtime-worker.dispatch.test.ts b/packages/herdr/src/downtime-worker.dispatch.test.ts new file mode 100644 index 000000000..e7b2e5f48 --- /dev/null +++ b/packages/herdr/src/downtime-worker.dispatch.test.ts @@ -0,0 +1,1011 @@ +/** + * packages/herdr/src/downtime-worker.dispatch.test.ts — dispatch wiring tests + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4V3WJN0013EZZ) to keep each test file focused + * on one concern. + * + * Covers dispatch selection and the dispatcher-anchor wiring paths: + * - `dispatch selection` — the plan/intake tier selection contract, + * - `dispatcher anchor wiring (C0 dispatcher workspace)`, + * - `per-prefix Dispatcher tab dispatch wiring (C1 fallback)`, and + * - `project workspace + item-ID tab dispatch wiring (primary path)`. + * + * Shared scaffolding (`makeDeps`, etc.) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { dispatchDowntimeWork, buildDowntimePaneArgs, type DowntimeWorkerDeps } from './downtime-worker.js'; +import { makeDeps } from './downtime-worker.fixtures.js'; +import type { StalledPaneCandidate } from './stalled-work.js'; + +describe('dispatch selection', () => { + it('runs /skill:plan on the next intake_complete item', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); + expect(deps.getNextItem).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-ABC'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-ABC'), + { model: 'plan', cwd: '/repo', itemId: 'WL-ABC', itemTitle: 'Some task' }, + ); + }); + + it('claims the item BEFORE the pane spawns with the tier CAS guard', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // The claim carries the expected state the tier selected the item in + // (RCA WL-0MSRBFFLN005W3VT design point 1 — compare-and-swap). + expect(deps.claimItem).toHaveBeenCalledWith('WL-ABC', { status: 'open', stage: 'intake_complete' }, '/repo'); + const claimOrder = (deps.claimItem as ReturnType).mock.invocationCallOrder[0]; + const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(claimOrder).toBeLessThan(spawnOrder); + }); + + it('falls back to /skill:intake on the next idea item when none is intake_complete', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-DEF', title: 'An idea', stage: 'idea', status: 'open' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('WL-DEF'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-DEF', { status: 'open', stage: 'idea' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-DEF'), + expect.anything(), + ); + }); + + it('does not dispatch when no item exists in either stage', async () => { + const deps = makeDeps(); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + it('fails closed to wl-error when the intake_complete lookup errors AND the idea lookup errors', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: false }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Tier 2 error does NOT short-circuit: tier 3 is still attempted. + expect(deps.getNextItem).toHaveBeenCalledTimes(2); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('still checks the idea tier after an intake_complete error and dispatches its candidate', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea', status: 'open' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('WL-IDE'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-IDE', { status: 'open', stage: 'idea' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-IDE'), + expect.anything(), + ); + }); + + it('fails closed to wl-error when the intake_complete lookup errors and idea answers empty', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + // Backlog is not provably empty (intake_complete state unknown) → the + // worker must NOT treat this as a genuine no-candidate (no cooldown). + expect(outcome.reason).toBe('wl-error'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('fails closed to wl-error when the idea lookup errors after an empty intake_complete stage', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) + .mockResolvedValueOnce({ ok: false }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); +}); + +// ── Dispatcher anchor wiring (C0 WL-0MTR01EU7005SYZG / F2 WL-0MTR2HLLJ009PTPJ) ── + +describe('dispatcher anchor wiring (C0 dispatcher workspace)', () => { + it('AC1: buildDowntimePaneArgs appends --anchor when an anchorId is provided', () => { + const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { + model: 'plan', + cwd: '/repo', + anchorId: 'wD:pANCHOR', + }); + expect(args).toContain('--anchor'); + expect(args).toContain('wD:pANCHOR'); + expect(args).toContain('--no-focus'); + expect(args).toContain('--cwd'); + expect(args).toContain('/repo'); + expect(args).toContain('--model'); + expect(args).toContain('plan'); + }); + + it('AC1 backward-compat: buildDowntimePaneArgs omits --anchor when no anchorId is given', () => { + const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).not.toContain('--anchor'); + expect(args).not.toContain('wD:pANCHOR'); + expect(args[0]).toBe('--pane-name'); + }); + + it('AC3: dispatchDowntimeWork resolves the anchor and passes its pane id to spawnAgentPane', async () => { + const deps = makeDeps({ + getDispatcherAnchor: vi.fn().mockResolvedValue({ + paneId: 'wD:pANCHOR', + workspaceId: 'wD', + }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(deps.getDispatcherAnchor).toHaveBeenCalled(); + // The resolved anchor pane id flows into the spawn opts (→ --anchor in + // the send-to-pi.sh args built by buildDowntimePaneArgs). + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-ABC'), + expect.objectContaining({ anchorId: 'wD:pANCHOR' }), + ); + }); + + it('AC1 legacy wiring: without the getDispatcherAnchor dep no anchorId key is added (backward compat)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-ABC'), + expect.not.objectContaining({ anchorId: expect.anything() }), + ); + }); + + it('fail-safe: a null anchor (provisioning failed) aborts the dispatch with anchor-unavailable', async () => { + const deps = makeDeps({ + getDispatcherAnchor: vi.fn().mockResolvedValue(null), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('anchor-unavailable'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('scheduled-prompt spawns resolve the anchor and forward anchorId (dispatchDowntimeWork path)', async () => { + const deps = makeDeps({ + getDispatcherAnchor: vi.fn().mockResolvedValue({ + paneId: 'wD:pANCHOR', + workspaceId: 'wD', + }), + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: 'prompt-1', + prompt: 'Run the nightly sweep', + frequencyMinutes: 60, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('scheduled'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + 'Run the nightly sweep', + expect.objectContaining({ anchorId: 'wD:pANCHOR' }), + ); + }); +}); + +// ── Per-prefix tab dispatch wiring (C1 fallback, WL-0MUR5FUWD00024XN) ─────── + +describe('per-prefix Dispatcher tab dispatch wiring (C1 fallback)', () => { + const candidate = { + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete' as const, + status: 'open', + }; + + it('AC1: resolves the per-prefix tab and forwards its anchor pane (primary path)', async () => { + const getDispatcherTabAnchor = vi.fn().mockResolvedValue({ + workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1', + }); + const deps = makeDeps({ + getDispatcherTabAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(getDispatcherTabAnchor).toHaveBeenCalledWith('/repo', 'WL'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-ABC'), + expect.objectContaining({ anchorId: 'wD:tWL:p1' }), + ); + }); + + it('AC1: each prefix routes to its own tab (WL vs TCE)', async () => { + const wlDeps = makeDeps({ + getDispatcherTabAnchor: vi.fn().mockResolvedValue({ + workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1', + }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-1', title: 'wl', stage: 'intake_complete', status: 'open' }, + }), + }); + const tceDeps = makeDeps({ + getDispatcherTabAnchor: vi.fn().mockResolvedValue({ + workspaceId: 'wD', tabId: 'wD:tTCE', paneId: 'wD:tTCE:p1', + }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'TCE-2', title: 'tce', stage: 'intake_complete', status: 'open' }, + }), + }); + + await dispatchDowntimeWork(wlDeps, { model: 'plan', cwd: '/repo-wl' }); + await dispatchDowntimeWork(tceDeps, { model: 'plan', cwd: '/repo-tce' }); + + expect(wlDeps.getDispatcherTabAnchor).toHaveBeenCalledWith('/repo-wl', 'WL'); + expect(tceDeps.getDispatcherTabAnchor).toHaveBeenCalledWith('/repo-tce', 'TCE'); + expect(wlDeps.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ anchorId: 'wD:tWL:p1', cwd: '/repo-wl' }), + ); + expect(tceDeps.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ anchorId: 'wD:tTCE:p1', cwd: '/repo-tce' }), + ); + }); + + it('AC2/AC4: a resolved project workspace wins and the per-prefix dep is not called', async () => { + const resolveProjectWorkspace = vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }); + const getItemTabAnchor = vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }); + const getDispatcherTabAnchor = vi.fn().mockResolvedValue({ + workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1', + }); + const deps = makeDeps({ + getDispatcherTabAnchor, + resolveProjectWorkspace, + getItemTabAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); + expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); + expect(getDispatcherTabAnchor).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ anchorId: 'wC:tWL-ABC:p1' }), + ); + }); + + it('AC5: the per-prefix Dispatcher path is used when the project workspace resolves to null', async () => { + const getDispatcherTabAnchor = vi.fn().mockResolvedValue({ + workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1', + }); + const resolveProjectWorkspace = vi.fn().mockResolvedValue(null); + const getItemTabAnchor = vi.fn(); + const getDispatcherAnchor = vi + .fn() + .mockResolvedValue({ paneId: 'wD:LEGACY', workspaceId: 'wD' }); + const deps = makeDeps({ + getDispatcherTabAnchor, + resolveProjectWorkspace, + getItemTabAnchor, + getDispatcherAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); + expect(getItemTabAnchor).not.toHaveBeenCalled(); + expect(getDispatcherTabAnchor).toHaveBeenCalledWith('/repo', 'WL'); + expect(getDispatcherAnchor).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ anchorId: 'wD:tWL:p1' }), + ); + }); + + it('AC1 fail-safe: a null per-prefix anchor aborts with anchor-unavailable (no fallback)', async () => { + const getDispatcherTabAnchor = vi.fn().mockResolvedValue(null); + const getDispatcherAnchor = vi + .fn() + .mockResolvedValue({ paneId: 'wD:LEGACY', workspaceId: 'wD' }); + const deps = makeDeps({ + getDispatcherTabAnchor, + getDispatcherAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('anchor-unavailable'); + expect(getDispatcherAnchor).not.toHaveBeenCalled(); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('AC1 fail-safe: a throwing per-prefix resolver aborts with anchor-unavailable', async () => { + const deps = makeDeps({ + getDispatcherTabAnchor: vi.fn().mockRejectedValue(new Error('herdr down')), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('anchor-unavailable'); + expect(deps.claimItem).not.toHaveBeenCalled(); + }); +}); + +// ── Project workspace + item-ID tab dispatch wiring (WL-0MU321YK70035AYT) ── + +// These tests exercise the PRIMARY project-workspace + item-ID tab path, +// reaffirmed as primary in WL-0MUR5FUWD00024XN. The per-prefix Dispatcher tab +// and the legacy single anchor are fallbacks used only when no project +// workspace resolves for the item's root. +describe('project workspace + item-ID tab dispatch wiring (primary path)', () => { + const candidate = { + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete' as const, + status: 'open', + }; + + /** + * Build deps for a single project root: its project workspace, its item-ID + * tab anchor and a plan candidate. Factored out of the per-project + * separation test so each root's setup stays small and within the per-test + * mock budget (WL-0MV4VSNI00002WFM). + */ + const makeRootDeps = (opts: { + workspaceId: string; + paneId: string; + tabId: string; + anchorTabId: string; + anchorPaneId: string; + itemId: string; + itemTitle: string; + }): DowntimeWorkerDeps => + makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: opts.paneId, workspaceId: opts.workspaceId, tabId: opts.tabId }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: opts.anchorTabId, paneId: opts.anchorPaneId }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { + id: opts.itemId, + title: opts.itemTitle, + stage: 'intake_complete', + status: 'open', + }, + }), + }); + + it('AC1/AC2: resolves the project workspace, ensures the item-ID tab and forwards its anchor pane', async () => { + const resolveProjectWorkspace = vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }); + const getItemTabAnchor = vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }); + const getDispatcherAnchor = vi + .fn() + .mockResolvedValue({ paneId: 'wD:LEGACY', workspaceId: 'wD' }); + const deps = makeDeps({ + resolveProjectWorkspace, + getItemTabAnchor, + getDispatcherAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); + expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); + expect(getDispatcherAnchor).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-ABC'), + expect.objectContaining({ anchorId: 'wC:tWL-ABC:p1' }), + ); + }); + + it('AC4: no project workspace → falls back to the Dispatcher anchor', async () => { + const resolveProjectWorkspace = vi.fn().mockResolvedValue(null); + const getItemTabAnchor = vi.fn(); + const getDispatcherAnchor = vi + .fn() + .mockResolvedValue({ paneId: 'wD:pFALLBACK', workspaceId: 'wD' }); + const deps = makeDeps({ + resolveProjectWorkspace, + getItemTabAnchor, + getDispatcherAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(getDispatcherAnchor).toHaveBeenCalledWith('/repo'); + expect(getItemTabAnchor).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ anchorId: 'wD:pFALLBACK' }), + ); + }); + + it('AC4 fail-safe: workspace resolves but item tab fails → anchor-unavailable, no Dispatcher fallback', async () => { + const resolveProjectWorkspace = vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1' }); + const getItemTabAnchor = vi.fn().mockResolvedValue(null); + const getDispatcherAnchor = vi + .fn() + .mockResolvedValue({ paneId: 'wD:LEGACY', workspaceId: 'wD' }); + const deps = makeDeps({ + resolveProjectWorkspace, + getItemTabAnchor, + getDispatcherAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('anchor-unavailable'); + expect(getDispatcherAnchor).not.toHaveBeenCalled(); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('AC4 fail-safe: no project workspace and Dispatcher anchor null → anchor-unavailable, no marker/spawn', async () => { + const deps = makeDeps({ + resolveProjectWorkspace: vi.fn().mockResolvedValue(null), + getDispatcherAnchor: vi.fn().mockResolvedValue(null), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('anchor-unavailable'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('AC4 fail-safe: a throwing project-workspace resolver falls back to the Dispatcher anchor', async () => { + const getDispatcherAnchor = vi + .fn() + .mockResolvedValue({ paneId: 'wD:pFALLBACK', workspaceId: 'wD' }); + const deps = makeDeps({ + resolveProjectWorkspace: vi.fn().mockRejectedValue(new Error('herdr down')), + getDispatcherAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(getDispatcherAnchor).toHaveBeenCalled(); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ anchorId: 'wD:pFALLBACK' }), + ); + }); + + it('AC4 fail-safe: a throwing item-tab resolver aborts with anchor-unavailable', async () => { + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1' }), + getItemTabAnchor: vi.fn().mockRejectedValue(new Error('tab create blew up')), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('anchor-unavailable'); + expect(deps.claimItem).not.toHaveBeenCalled(); + }); + + it('legacy wiring: without the per-prefix dep the item-ID tab path is used (backward compat)', async () => { + const getItemTabAnchor = vi.fn().mockResolvedValue({ tabId: 'wC:tX', paneId: 'wC:tX:p1' }); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1' }), + getItemTabAnchor, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); + expect('getDispatcherTabAnchor' in deps).toBe(false); + }); + + it('AC1/AC2 per-project separation: each root resolves its own workspace and tab', async () => { + const wl = makeRootDeps({ + workspaceId: 'wC', + paneId: 'wC:pB', + tabId: 'wC:t1', + anchorTabId: 'wC:tWL-1', + anchorPaneId: 'wC:tWL-1:p1', + itemId: 'WL-1', + itemTitle: 'wl', + }); + const tce = makeRootDeps({ + workspaceId: 'wY', + paneId: 'wY:pW', + tabId: 'wY:t1', + anchorTabId: 'wY:tTCE-2', + anchorPaneId: 'wY:tTCE-2:p1', + itemId: 'TCE-2', + itemTitle: 'tce', + }); + + const first = await dispatchDowntimeWork(wl, { model: 'plan', cwd: '/repo-wl' }); + const second = await dispatchDowntimeWork(tce, { model: 'plan', cwd: '/repo-tce' }); + + expect(first.dispatched).toBe(true); + expect(second.dispatched).toBe(true); + expect(wl.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ anchorId: 'wC:tWL-1:p1', cwd: '/repo-wl' }), + ); + expect(tce.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ anchorId: 'wY:tTCE-2:p1', cwd: '/repo-tce' }), + ); + }); + + it('scheduled-prompt spawns keep the Dispatcher anchor (no work-item id)', async () => { + const getDispatcherAnchor = vi + .fn() + .mockResolvedValue({ paneId: 'wD:pSCHED', workspaceId: 'wD' }); + const resolveProjectWorkspace = vi.fn(); + const deps = makeDeps({ + getDispatcherAnchor, + resolveProjectWorkspace, + getDueScheduledPrompt: vi + .fn() + .mockResolvedValue({ id: 'prompt-1', prompt: 'Run the nightly sweep', frequencyMinutes: 60 }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('scheduled'); + expect(resolveProjectWorkspace).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + 'Run the nightly sweep', + expect.objectContaining({ anchorId: 'wD:pSCHED' }), + ); + }); + + it('AC2 audit-log context: the marker records the item and its project root before the spawn', async () => { + const recordDispatch = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wY:pW', workspaceId: 'wY', tabId: 'wY:t1' }), + getItemTabAnchor: vi.fn().mockResolvedValue({ tabId: 'wY:tTCE-9', paneId: 'wY:tTCE-9:p1' }), + recordDispatch, + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'TCE-9', title: 'Engine task', stage: 'intake_complete', status: 'open' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo-tce' }); + + expect(outcome.dispatched).toBe(true); + expect(recordDispatch).toHaveBeenCalledWith( + expect.objectContaining({ itemId: 'TCE-9', kind: 'plan', cwd: '/repo-tce' }), + ); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ cwd: '/repo-tce', anchorId: 'wY:tTCE-9:p1' }), + ); + const markerOrder = recordDispatch.mock.invocationCallOrder[0]; + const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(markerOrder).toBeLessThan(spawnOrder); + }); + + // ── Root-pane cleanup (WL-0MU2EOHK900425VU) ──────────────────────── + + it('AC1: closes the project-workspace item tab root pane after a successful dispatch', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + // No live downtime pane carries the anchor id → the anchor IS the root + // pane and must be closed. + getRunningDowntimePanes: vi + .fn() + .mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).toHaveBeenCalledTimes(1); + expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); + }); + + it('AC2: NEVER closes the Dispatcher-fallback anchor (would re-provision a blank pane)', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi.fn().mockResolvedValue(null), + getDispatcherAnchor: vi + .fn() + .mockResolvedValue({ paneId: 'wD:pFALLBACK', workspaceId: 'wD' }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).not.toHaveBeenCalled(); + }); + + it('AC2: NEVER closes an anchor that is itself a live downtime dispatch pane', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + // The anchor id is a recorded downtime pane → it is a previous + // dispatch's agent pane and must stay open. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, + count: 1, + paneIds: ['wC:tWL-ABC:p1'], + records: [{ paneId: 'wC:tWL-ABC:p1', label: 'Downtime triggered plan X - WL-ABC' }], + }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).not.toHaveBeenCalled(); + }); + + it('AC5 fail-safe: a throwing closePane never blocks the dispatch outcome', async () => { + const closePane = vi.fn().mockRejectedValue(new Error('herdr close blew up')); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).toHaveBeenCalledTimes(1); + }); + + it('AC5 backward-compat: without the closePane dep no cleanup call is attempted', async () => { + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(deps.closePane).toBeUndefined(); + }); + + // ── Audit-rejection regressions (WL-0MU2EOHK900425VU) ───────────────── + // The producer rejected the first fix because the empty shell pane was + // still present after creating a tab in an existing workspace. Root cause: + // the cleanup preferred `records` (the FULL machine-wide pane set, which + // always contains the anchor itself) over `paneIds` (the running DOWNTIME + // panes), so `liveIds.includes(anchorId)` was always true and every close + // was suppressed. The cleanup now uses the running-downtime set. + + it('AC1 regression: closes the root pane even when records contains the anchor (full pane set)', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + // Production `records` = the FULL pane list: it contains the blank + // anchor (which is not a downtime pane). `paneIds` (running downtime + // panes) is empty → the anchor must be closed. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, + count: 0, + paneIds: [], + records: [ + { paneId: 'wC:tWL-ABC:p1', label: 'bash' }, + { paneId: 'wC:other', label: 'bash' }, + ], + }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).toHaveBeenCalledTimes(1); + expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); + }); + + it('AC1 regression: records-only payload is filtered to downtime panes (anchor still closed)', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + // Legacy payload without `paneIds`; `records` holds the blank anchor + // (not a downtime pane) → filtering yields no live downtime pane. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, + count: 0, + records: [{ paneId: 'wC:tWL-ABC:p1', label: 'bash' }], + }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).toHaveBeenCalledTimes(1); + expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); + }); + + it('AC2 fail-safe regression: leaves the anchor open when liveness fails (ok:false)', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + // Liveness query FAILED → cannot confirm → leave the anchor open. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: false, error: 'herdr unavailable' }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).not.toHaveBeenCalled(); + }); +}); + +// ── Bounded model-cooldown wake (WL-0MV5639GG00085Q7) ────────────────── + +// These tests exercise the dispatcher seam: a due model cooldown candidate +// sourced by the scan is resumed through the existing stalled-resume path, +// at most one per cycle, and it is preferred over opening a new pane. +describe('bounded model-cooldown wake (WL-0MV5639GG00085Q7)', () => { + const ITEM_ID = 'WL-COOL'; + const NOW = Date.now(); + + function cooldownCandidate(paneId: string): StalledPaneCandidate { + return { + pane: { + paneId, + label: `Downtime triggered implement Cooled job - ${ITEM_ID}`, + agent: 'pi', + agentStatus: 'idle', + }, + item: { + id: ITEM_ID, + status: 'open', + stage: 'plan_complete', + risk: 'low', + effort: 'small', + updatedAt: new Date(NOW - 1000).toISOString(), + }, + cwd: '/repo', + kind: 'implement', + modelCooldown: { + paneId, + sessionId: null, + itemId: ITEM_ID, + kind: 'implement', + reason: 'all providers exhausted', + createdAt: NOW - 60 * 1000, + cooldownUntil: NOW - 1, + }, + }; + } + + it('resumes at most ONE due cooldown job and opens no new pane', async () => { + const resumeStalledPane = vi.fn().mockResolvedValue({ + resumed: true, + via: 'prompt', + paneId: 'p1', + itemId: ITEM_ID, + kind: 'implement', + }); + const deps = makeDeps({ + scanStalledPanes: vi.fn().mockResolvedValue([cooldownCandidate('p1'), cooldownCandidate('p2')]), + resumeStalledPane, + getHerdrListHead: vi.fn().mockResolvedValue({ + ok: true, + items: [{ id: ITEM_ID, title: 'Cooled job', stage: 'plan_complete' }], + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'implement', cwd: '/repo', freeSlots: 5 }); + + expect(outcome).toEqual({ dispatched: false, reason: 'stalled-resume' }); + // At most one resume: the loop short-circuits on the first success. + expect(resumeStalledPane).toHaveBeenCalledTimes(1); + // Preferred over opening a new pane: no head read, no spawn. + expect(deps.getHerdrListHead).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); + }); + + it('still resumes at most one job when an earlier cooldown candidate fails', async () => { + const resumeStalledPane = vi + .fn() + .mockResolvedValueOnce({ + resumed: false, + reason: 'agent-prompt-stalled', + paneId: 'p1', + itemId: ITEM_ID, + kind: 'implement', + }) + .mockResolvedValueOnce({ + resumed: true, + via: 'prompt', + paneId: 'p2', + itemId: ITEM_ID, + kind: 'implement', + }); + const deps = makeDeps({ + scanStalledPanes: vi.fn().mockResolvedValue([cooldownCandidate('p1'), cooldownCandidate('p2')]), + resumeStalledPane, + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'implement', cwd: '/repo', freeSlots: 5 }); + + expect(outcome).toEqual({ dispatched: false, reason: 'stalled-resume' }); + expect(resumeStalledPane).toHaveBeenCalledTimes(2); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/herdr/src/downtime-worker.fixtures.test.ts b/packages/herdr/src/downtime-worker.fixtures.test.ts new file mode 100644 index 000000000..09b06544e --- /dev/null +++ b/packages/herdr/src/downtime-worker.fixtures.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for the shared downtime-worker test scaffolding + * (WL-0MV4V0V8Z003PBQ2). + * + * Pins the behaviour every split downtime test file depends on: `makeDeps` + * default/override semantics, the temporary-directory helpers' create and + * cleanup contract, and the downtime-log writers' on-disk shape. These are + * the primitives the split suite imports, so a regression here would silently + * weaken every consumer. + */ + +import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + ago, + createTempDirTracker, + makeDeps, + makeTempDir, + writeDowntimeLog, + writeRawDowntimeLog, +} from './downtime-worker.fixtures.js'; + +describe('makeDeps', () => { + it('returns neutral default deps that resolve to "nothing to do"', async () => { + const deps = makeDeps(); + const cwd = '/repo'; + + await expect(deps.getHerdrListHead(cwd)).resolves.toEqual({ ok: true, items: [] }); + await expect(deps.getNextItem('intake_complete', cwd)).resolves.toEqual({ + ok: true, + candidate: null, + }); + await expect(deps.getNextAuditCandidate(cwd)).resolves.toEqual({ ok: true, candidate: null }); + await expect(deps.getNextCriticalCandidate(cwd)).resolves.toEqual({ + ok: true, + candidate: null, + }); + await expect(deps.getNextImplementCandidate(cwd)).resolves.toBeNull(); + expect(deps.readCodeFreezeStatus(cwd)).toBe('not-frozen'); + await expect(deps.hasFreshAudit('WL-1', cwd)).resolves.toBe(false); + await expect(deps.getReviewQueueCount(cwd)).resolves.toBe(0); + }); + + it('merges overrides over the defaults without dropping unspecified deps', async () => { + const custom = vi.fn().mockResolvedValue({ ok: true, candidate: null }); + const deps = makeDeps({ getNextItem: custom }); + + expect(deps.getNextItem).toBe(custom); + // An unspecified dep keeps its default. + await expect(deps.getNextImplementCandidate('/repo')).resolves.toBeNull(); + }); + + it('produces independent mock instances per call', () => { + expect(makeDeps().claimItem).not.toBe(makeDeps().claimItem); + }); +}); + +describe('temporary-directory helpers', () => { + it('makeTempDir creates a fresh existing directory per call', () => { + const first = makeTempDir('fixtures-test-'); + const second = makeTempDir('fixtures-test-'); + try { + expect(existsSync(first)).toBe(true); + expect(existsSync(second)).toBe(true); + expect(first).not.toBe(second); + } finally { + rmSync(first, { recursive: true, force: true }); + rmSync(second, { recursive: true, force: true }); + } + }); + + it('createTempDirTracker cleanup removes every tracked directory and is idempotent', () => { + const tracker = createTempDirTracker(); + const a = tracker.make('fixtures-tracker-'); + const b = tracker.make('fixtures-tracker-'); + + expect(existsSync(a)).toBe(true); + expect(existsSync(b)).toBe(true); + + tracker.cleanup(); + expect(existsSync(a)).toBe(false); + expect(existsSync(b)).toBe(false); + // A second cleanup is a no-op rather than a throw. + expect(() => tracker.cleanup()).not.toThrow(); + }); +}); + +describe('downtime log writers', () => { + const tempDir = createTempDirTracker(); + afterEach(() => tempDir.cleanup()); + + it('writeDowntimeLog creates .worklog and writes one JSON object per line', () => { + const cwd = tempDir.make('fixtures-log-'); + writeDowntimeLog(cwd, [ + { itemId: 'WL-1', kind: 'implement' }, + { itemId: 'WL-2', kind: 'audit' }, + ]); + + const raw = readFileSync(join(cwd, '.worklog', 'downtime-dispatches.log'), 'utf8'); + expect(raw.endsWith('\n')).toBe(true); + const lines = raw.trimEnd().split('\n'); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0])).toEqual({ itemId: 'WL-1', kind: 'implement' }); + expect(JSON.parse(lines[1])).toEqual({ itemId: 'WL-2', kind: 'audit' }); + }); + + it('writeRawDowntimeLog writes the raw string verbatim', () => { + const cwd = tempDir.make('fixtures-rawlog-'); + writeRawDowntimeLog(cwd, 'not json\n'); + + expect(readFileSync(join(cwd, '.worklog', 'downtime-dispatches.log'), 'utf8')).toBe( + 'not json\n', + ); + }); +}); + +describe('ago', () => { + it('returns an ISO timestamp at roughly the requested age in the past', () => { + const before = Date.now(); + const parsed = Date.parse(ago(60_000)); + const after = Date.now(); + + expect(parsed).toBeGreaterThanOrEqual(before - 60_000); + expect(parsed).toBeLessThanOrEqual(after - 60_000); + }); +}); diff --git a/packages/herdr/src/downtime-worker.fixtures.ts b/packages/herdr/src/downtime-worker.fixtures.ts index cb454a8db..1d0d4cd2a 100644 --- a/packages/herdr/src/downtime-worker.fixtures.ts +++ b/packages/herdr/src/downtime-worker.fixtures.ts @@ -7,12 +7,25 @@ * query / model switch / local lease / server down / not all slots free), * ambiguous (`total_slots` 0 or missing fields), and network error / * timeout / HTTP-failure responses for the poller tests (F2). + * + * Also hosts the shared test scaffolding (WL-0MV4V0V8Z003PBQ2): the + * `makeDeps` mock factory, temporary-directory helpers, and downtime-log + * writers, so every split test file imports one module instead of + * re-implementing the same setup. */ -import type { LlamaStatus } from './downtime-worker.js'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { vi } from 'vitest'; + +import { DOWNTIME_LOG_FILE } from './downtime-log.js'; +import type { DowntimeWorkerDeps, LlamaStatus } from './downtime-worker.js'; // ── Status payloads ─────────────────────────────────────────────────── +/** Every slot idle → the worker may dispatch freely (all-free availability signal). */ export const idleAllSlotsFree: LlamaStatus = { llama_server_running: true, local_active_query: false, @@ -35,9 +48,13 @@ export const idleAllSlotsFree: LlamaStatus = { local_owner_session_ids: [], }; +/** A live query is queued on the proxy → slots effectively busy. */ export const busyActiveQuery: LlamaStatus = { ...idleAllSlotsFree, local_active_query: true }; +/** A model switch is in progress → slots effectively busy. */ export const busyModelSwitch: LlamaStatus = { ...idleAllSlotsFree, model_switch_in_progress: true }; +/** An in-flight local lease is held → slots effectively busy. */ export const busyLocalLease: LlamaStatus = { ...idleAllSlotsFree, local_lease_active: true }; +/** The llama server is not running → nothing can be dispatched. */ export const busyServerNotRunning: LlamaStatus = { ...idleAllSlotsFree, llama_server_running: false }; /** available_slots < total_slots → busy with the default N=0 (all slots). */ @@ -242,6 +259,7 @@ export const ambiguousMissingFieldsRaw: Record = { current_model: 'qwen3-8b', }; +/** Shape of the fixture used to stub the parsed proxy status in worker tests. */ export interface LlamaStatusFixture { name: string; status: LlamaStatus; @@ -309,6 +327,7 @@ export interface LlamaStatusHttpResponse { json(): Promise; } +/** Build a minimal fetch-style `Response` double returning the given body. */ export function jsonResponseFixture(body: unknown, status = 200): LlamaStatusHttpResponse { return { ok: status >= 200 && status < 300, @@ -317,6 +336,7 @@ export function jsonResponseFixture(body: unknown, status = 200): LlamaStatusHtt }; } +/** A non-2xx `Response` double for error-path tests. */ export const httpErrorResponseFixture: LlamaStatusHttpResponse = jsonResponseFixture( { error: 'boom' }, 500, @@ -332,3 +352,146 @@ export const timeoutErrorFixture: Error = Object.assign( new Error('The operation was aborted'), { name: 'AbortError' }, ); + +// ── Shared test scaffolding (WL-0MV4V0V8Z003PBQ2) ───────────────────── + +// ── Per-test mock budget (WL-0MV4VSNI00002WFM) ──────────────────────── +// +// The split downtime-worker suite allows at most **5** `vi.fn()` calls per +// `it()`/`test()` block. The ~20 mocks `makeDeps()` creates are shared +// infrastructure and are excluded — the budget bounds the *per-test +// overrides* a reader must hold in their head, which is where the original +// 13k-line file's 664 mocks came from. +// +// Rationale: analysis of the 718 split tests found an average of ~5.2 mocks +// per original describe block and a maximum of 6 per test. A cap of 5 keeps +// every focused test green while forcing genuinely overloaded tests to +// extract setup (the two original 6-mock tests now share a helper instead). +// +// Enforcement: `npm run test:mock-budget` (`scripts/check-mock-budget.ts`, +// Option B of the intake) parses each `downtime-worker*.test.ts` with the +// TypeScript compiler API and fails when any test exceeds the cap. The same +// check runs in the unit suite (`tests/unit/mock-budget.test.ts`), so it is +// part of the normal test gate. Extract extra setup into this module (or a +// local helper) rather than raising the cap. + +/** + * Default mocked `DowntimeWorkerDeps` for dispatch tests, with every dep + * answering a neutral "nothing to do" result so a test only overrides the + * dep it exercises. Merges `overrides` over the defaults (last write wins). + */ +export function makeDeps(overrides: Partial = {}): DowntimeWorkerDeps { + return { + // Herdr list head (WL-0MTK1ILM2009QYB2): empty by default so legacy tests that + // stub per-tier lookups keep passing (the head path is inert on empty). + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [] }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + // Audit tier answers a GENUINELY empty tier by default ({ok:true, + // candidate:null}); a wl/parse failure is {ok:false} (WL-0MSLWJ2KP0002SV0). + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + // Active-audit single-flight (WL-0MT3PHW4I002SNOV): no active audit by + // default ({ok:true, active:false}), so the existing tier tests exercise + // the unchanged audit tier (backward-compatible default — the audit + // tier proceeds). The dispatcher consults this dep only after F2 lands; + // existing tests are unaffected either way. + getActiveAudit: vi.fn().mockResolvedValue({ ok: true, active: false }), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + recordDispatchFailure: vi.fn().mockResolvedValue(undefined), + // Claim rollback (WL-0MT32F908002YFFA): default success so the recovery + // paths report 'claim-rolled-back' unless a test forces a failed rollback. + rollbackClaim: vi.fn().mockResolvedValue(true), + recordError: vi.fn().mockResolvedValue(undefined), + // Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX): no due prompt by + // default, so existing tier tests exercise the unchanged backlog tiers. + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + recordScheduledPromptTrigger: vi.fn().mockResolvedValue(true), + // Critical-first tier (WL-0MT3FM8VA005XBHE): no critical candidate by + // default, so the existing tier tests exercise the unchanged backlog + // tiers (regression guard — after F4 the critical tier must fall + // through to implement → plan → intake when the critical tier is + // genuinely empty). + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + // Code-freeze gate (WL-0MSQ0RPQP00636JY): not frozen by default, so + // existing dispatch tests exercise the unchanged audit/implement tiers. + readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), + // Interim freshness re-check (WL-0MT8KSTOE00871E7): no fresh audit by + // default, so existing dispatch tests exercise the unchanged path. + hasFreshAudit: vi.fn().mockResolvedValue(false), + // Review-queue depth gate (WL-0MT2UQWOR007CYY9): empty queue by default + // so the gate is inactive and existing tests exercise the unchanged path. + getReviewQueueCount: vi.fn().mockResolvedValue(0), + // Item-scoped in-flight guard (WL-0MUBEZ6PE002WLP4 / F3): an available + // query reporting NO live panes by default, so the guard is inert and + // existing tests escalate critical items unchanged. Tests that exercise + // the guard override this with `records` carrying a live working pane. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }), + ...overrides, + }; +} + +/** + * Create a unique temporary directory under the OS temp root, prefixed with + * `prefix`. The caller owns cleanup; prefer `createTempDirTracker()` when a + * describe block creates several directories. + */ +export function makeTempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +/** Registry returned by `createTempDirTracker()`. */ +export interface TempDirTracker { + /** Create, remember and return a new unique temp directory. */ + make(prefix: string): string; + /** Remove every directory created by this tracker (idempotent). */ + cleanup(): void; +} + +/** + * Per-describe temporary-directory registry: `make()` creates a unique temp + * directory and records it, `cleanup()` removes them all (call it from + * `afterEach`). Centralises the `mkdtempSync` + `rmSync` boilerplate that was + * duplicated across describe blocks. + */ +export function createTempDirTracker(): TempDirTracker { + const dirs: string[] = []; + return { + make(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + dirs.push(dir); + return dir; + }, + cleanup(): void { + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }, + }; +} + +/** + * ISO timestamp for a moment `ms` milliseconds in the past. Dispatch paths + * read the real `Date.now()`, so tests that seed dispatch markers use this + * rather than a fake clock. + */ +export function ago(ms: number): string { + return new Date(Date.now() - ms).toISOString(); +} + +/** + * Write a raw string to the downtime dispatch log at + * `/.worklog/`, creating the `.worklog` directory + * when absent. + */ +export function writeRawDowntimeLog(cwd: string, raw: string): void { + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), raw, 'utf8'); +} + +/** + * Write `entries` to the downtime dispatch log as JSONL (one JSON object per + * line, trailing newline), creating the `.worklog` directory when absent. + */ +export function writeDowntimeLog(cwd: string, entries: Array>): void { + writeRawDowntimeLog(cwd, entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); +} diff --git a/packages/herdr/src/downtime-worker.gates.test.ts b/packages/herdr/src/downtime-worker.gates.test.ts new file mode 100644 index 000000000..75bedb9cd --- /dev/null +++ b/packages/herdr/src/downtime-worker.gates.test.ts @@ -0,0 +1,205 @@ +/** + * packages/herdr/src/downtime-worker.gates.test.ts — dispatch gate and + * concurrency tests. + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4VQ78L003TGY6) to keep each test file focused + * on one concern and under the 2,000-line cap. Covers: + * - the single-flight dispatch pipeline gate (one in-flight pipeline), + * - the zero-free-slots idle gate (including fast-mode operator priority), + * - bounded concurrent dispatch claim-safety regression, + * - the transient-downtime-error classifier and the bounded retry helper. + * + * The transient-retry describes were originally nested under the + * `needsProducerReview exclusion` block; they are relocated here because they + * exercise the `isTransientDowntimeError` / `withTransientRetry` utilities + * (retry gating), not the producer-review gate itself. + * + * Shared scaffolding (`makeDeps`) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + dispatchDowntimeWork, + isTransientDowntimeError, + withTransientRetry, +} from './downtime-worker.js'; +import { makeDeps } from './downtime-worker.fixtures.js'; + +// ── Bounded concurrent dispatch (F1, parent WL-0MT50LKAK001EF5Q) ────── + +describe('pipeline single-flight — one in-flight dispatch pipeline at a time', () => { + it('a second concurrent same-process dispatch is refused (dispatch-in-flight)', async () => { + let release!: () => void; + const gate = new Promise<{ ok: true }>((resolve) => { + release = () => resolve({ ok: true }); + }); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + spawnAgentPane: vi.fn().mockImplementation(() => gate), + }); + + const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + release(); + const [firstOutcome, secondOutcome] = await Promise.all([first, second]); + + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(firstOutcome.dispatched).toBe(true); + expect(secondOutcome.dispatched).toBe(false); + expect(secondOutcome.reason).toBe('dispatch-in-flight'); + }); +}); + +describe('idle gate — no dispatch with zero free slots', () => { + it('second dispatch only fires when requiredFreeSlots continuously idle for threshold', async () => { + // This test verifies the per-slot idle tracker contract. + // With cap=2, we need 2 slots continuously idle for the threshold. + // A second dispatch should NOT fire if only 1 slot is idle. + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + // With 0 free slots (below panes min of 1), dispatch is gated + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); + + // Dispatch should be gated when no slots are free + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + }); +}); + +describe('idle gate — no dispatch with zero free slots (fast-mode operator priority)', () => { + it('with 3 total slots and 1 free, only 1 dispatch fires even if cap=2', async () => { + // Fast-mode / operator priority preserved: freeSlots check is the hard limit + // With 0 free slots, no dispatch can occur regardless of cap + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); + expect(outcome.dispatched).toBe(false); // no slots free → gated + }); +}); + +describe('bounded concurrent dispatch — claim safety regression', () => { + it('dispatched-marker exclusion still prevents re-dispatch of already-dispatched item', async () => { + // Regression guard for WL-0MSLIY8ZR004QUSY: claim safety via CAS + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Claim safety: the item should be claimed once, not re-claimed + if (outcome.dispatched) { + expect(deps.claimItem).toHaveBeenCalledTimes(1); + } + }); +}); + +// ── Transient retry (WL-0MTOTCETU004YYIU AC3 — bounded backoff) ─────── + +describe('transient retry (WL-0MTOTCETU004YYIU AC3 — bounded backoff)', () => { + describe('isTransientDowntimeError', () => { + it('classifies SQLITE_BUSY variants as transient', () => { + expect(isTransientDowntimeError('SQLITE_BUSY: database is locked')).toBe(true); + expect(isTransientDowntimeError('SQLITE BUSY: database is locked')).toBe(true); + expect(isTransientDowntimeError('database is locked')).toBe(true); + expect(isTransientDowntimeError('database table is locked')).toBe(true); + expect(isTransientDowntimeError('database busy')).toBe(true); + }); + it('does not classify timeout/hang as transient (retry would triple 10s wall-clock)', () => { + // ETIMEDOUT / "timed out" is a hung child (10s per attempt); retrying + // would be 30s and risks DOWNTIME_RUN_TIMEOUT_MS — strikes correctly. + expect(isTransientDowntimeError('ETIMEDOUT')).toBe(false); + expect(isTransientDowntimeError('timed out after 10000ms')).toBe(false); + expect(isTransientDowntimeError('spawn ETIMEDOUT')).toBe(false); + expect(isTransientDowntimeError('exceeded timeout of 10000ms')).toBe(false); + expect(isTransientDowntimeError('operation timeout')).toBe(false); + expect(isTransientDowntimeError('TIMED OUT')).toBe(false); + }); + it('is case-insensitive', () => { + expect(isTransientDowntimeError('Sqlite_Busy: DATABASE IS LOCKED')).toBe(true); + expect(isTransientDowntimeError('DATABASE BUSY')).toBe(true); + }); + it('does not classify parse/author-gate errors as transient', () => { + expect(isTransientDowntimeError('show parse error')).toBe(false); + expect(isTransientDowntimeError('audit list parse error')).toBe(false); + expect(isTransientDowntimeError('author identity gate')).toBe(false); + expect(isTransientDowntimeError('needsProducerReview gate')).toBe(false); + expect(isTransientDowntimeError('')).toBe(false); + expect(isTransientDowntimeError('some random error')).toBe(false); + }); + }); + + describe('withTransientRetry', () => { + it('succeeds on first try without retry', async () => { + const fn = vi.fn().mockResolvedValue('ok'); + await expect(withTransientRetry(fn, 2, 1)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + }); + it('retries once on transient then succeeds (no strike)', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')) + .mockResolvedValueOnce('ok'); + await expect(withTransientRetry(fn, 2, 1)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + it('retries twice on two transients then succeeds', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')) + .mockRejectedValueOnce(new Error('database is locked')) + .mockResolvedValueOnce('ok'); + await expect(withTransientRetry(fn, 2, 1)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(3); + }); + it('retries up to limit then throws (single strike, not three)', async () => { + const fn = vi.fn().mockRejectedValue(new Error('SQLITE_BUSY: database is locked')); + await expect(withTransientRetry(fn, 2, 1)).rejects.toThrow('SQLITE_BUSY'); + expect(fn).toHaveBeenCalledTimes(3); // 1 + 2 retries + }); + it('fails fast on non-transient (no retry)', async () => { + const fn = vi.fn().mockRejectedValue(new Error('show parse error')); + await expect(withTransientRetry(fn, 2, 1)).rejects.toThrow('show parse error'); + expect(fn).toHaveBeenCalledTimes(1); + }); + it('fails fast on second attempt if second error is non-transient', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')) + .mockRejectedValueOnce(new Error('show parse error')); + await expect(withTransientRetry(fn, 2, 1)).rejects.toThrow('show parse error'); + expect(fn).toHaveBeenCalledTimes(2); + }); + it('does not retry timeout variants (fail fast — one strike)', async () => { + const fn = vi.fn().mockRejectedValue(new Error('timed out after 10000ms')); + await expect(withTransientRetry(fn, 2, 1)).rejects.toThrow('timed out'); + expect(fn).toHaveBeenCalledTimes(1); + }); + it('handles non-Error thrown values', async () => { + const fn = vi + .fn() + .mockRejectedValueOnce('SQLITE_BUSY: database is locked') + .mockResolvedValueOnce('ok'); + await expect(withTransientRetry(fn, 2, 1)).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/herdr/src/downtime-worker.idle.test.ts b/packages/herdr/src/downtime-worker.idle.test.ts new file mode 100644 index 000000000..c96c6a8e1 --- /dev/null +++ b/packages/herdr/src/downtime-worker.idle.test.ts @@ -0,0 +1,596 @@ +/** + * packages/herdr/src/downtime-worker.idle.test.ts — idle-detection tests + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4V2H4O004U8M9) to keep each test file focused + * on one concern. + * + * Covers the idle-detection contract: + * - `isIdleStatus` classification (global gate), + * - idle-duration threshold timing (`createIdleTracker`), + * - runtime idle evaluation (`evaluateIdle`) in global and per-slot mode, + * - the per-slot idle tracker (`createPerSlotIdleTracker`), and + * - spare-capacity dispatch: relaxed global gate, the + * `DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS` default, pre-fix-proxy + * fail-closed behaviour, and ambiguous payloads, + * - slot-ownership helpers (`isSlotOwned`, `countFreeUnownedSlots`). + * + * Shared scaffolding (`makeDeps`, idle fixtures, etc.) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + */ + +import { describe, it, expect } from 'vitest'; +import { + isIdleStatus, + evaluateIdle, + createIdleTracker, + createPerSlotIdleTracker, + countFreeUnownedSlots, + isSlotOwned, + DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, + type LlamaStatus, + type LlamaSlot, +} from './downtime-worker.js'; +import { + makeDeps, + singleSlotIdleOwned, + perSlotIdleOwned, + perSlotOwnedOneUnowned, +} from './downtime-worker.fixtures.js'; + +// ── Idle detection (AC2) ────────────────────────────────────────────── + +describe('idle detection (isIdleStatus)', () => { + const idle: LlamaStatus = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + + it('is idle when the server is up, nothing active, and all slots free', () => { + expect(isIdleStatus(idle, 0)).toBe(true); + }); + + it('is busy when llama-server is not running', () => { + expect(isIdleStatus({ ...idle, llama_server_running: false }, 0)).toBe(false); + }); + + it('is busy when local_active_query=true (a local query is in flight)', () => { + expect(isIdleStatus({ ...idle, local_active_query: true }, 0)).toBe(false); + }); + + it('is busy when local_active_query is absent (fail-closed on pre-fix proxy)', () => { + // A payload without local_active_query (pre-fix proxy) fails closed: + // the worker must never dispatch on an unverifiable busy signal. + const partial: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + expect(isIdleStatus(partial, 0)).toBe(false); + }); + + it('is idle during remote-only traffic when local_active_query=false', () => { + // Remote provider streams keep the global query signal true while the + // local model is idle with free slots; the proxy's local_active_query is + // the local-only signal (LP-0MSL2ZLLS009RVKR) and must not block dispatch. + expect(isIdleStatus({ ...idle, local_active_query: false }, 0)).toBe(true); + }); + + it('is busy while a model switch is in progress', () => { + expect(isIdleStatus({ ...idle, model_switch_in_progress: true }, 0)).toBe(false); + }); + + it('is busy while a local lease is active', () => { + expect(isIdleStatus({ ...idle, local_lease_active: true }, 0)).toBe(false); + }); + + it('N=0 (default) requires ALL slots free', () => { + expect(isIdleStatus({ ...idle, available_slots: 3 }, 0)).toBe(false); + expect(isIdleStatus({ ...idle, available_slots: 4 }, 0)).toBe(true); + }); + + it('a positive N requires at least N slots free', () => { + expect(isIdleStatus({ ...idle, available_slots: 2 }, 2)).toBe(true); + expect(isIdleStatus({ ...idle, available_slots: 1 }, 2)).toBe(false); + }); + + it('N > total_slots can never be idle (never dispatches)', () => { + expect(isIdleStatus({ ...idle, available_slots: 4 }, 5)).toBe(false); + }); + + it('ambiguous responses (total_slots 0 or missing fields) are busy', () => { + expect(isIdleStatus({ ...idle, total_slots: 0, available_slots: 0 }, 0)).toBe(false); + expect(isIdleStatus({ llama_server_running: true } as LlamaStatus, 0)).toBe(false); + }); + + it('non-finite or negative slot counts are busy', () => { + expect(isIdleStatus({ ...idle, total_slots: Number.NaN }, 0)).toBe(false); + expect(isIdleStatus({ ...idle, available_slots: -1 }, 0)).toBe(false); + }); +}); + +// ── Threshold timing (AC1/AC3) ──────────────────────────────────────── + +describe('threshold timing (idle-duration tracker)', () => { + const thresholdMs = DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS; + + it('dispatches only after idle has lasted the full threshold continuously', () => { + const tracker = createIdleTracker(); + const start = 1_000_000; + + tracker.record(true, start); + expect(tracker.idleSince).toBe(start); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs - 1)).toBe(false); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); + }); + + it('any busy poll resets the idle-since timestamp', () => { + const tracker = createIdleTracker(); + const start = 1_000_000; + + tracker.record(true, start); + tracker.record(false, start + 120_000); + expect(tracker.idleSince).toBeNull(); + expect(tracker.isThresholdMet(thresholdMs, start + 360_000)).toBe(false); + }); + + it('keeps the idle run start fixed across consecutive idle polls', () => { + const tracker = createIdleTracker(); + const start = 1_000_000; + tracker.record(true, start); + tracker.record(true, start + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + tracker.record(true, start + 2 * DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + expect(tracker.idleSince).toBe(start); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs - 1_000)).toBe(false); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); + }); +}); + +// ── Runtime idle evaluation (F2, AC5) ───────────────────────────────── + +describe('runtime idle evaluation (evaluateIdle)', () => { + const idle: LlamaStatus = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + + function slotsFor(processing: number[], total = 4): LlamaSlot[] { + return Array.from({ length: total }, (_, i) => ({ + slot_id: `s${i + 1}`, + is_processing: processing.includes(i + 1), + })); + } + + it('fails closed when per-slot detail is absent (never admits the count)', () => { + // Fail-closed contract (WL-0MUXVPXAZ005RESW): the untrusted + // `available_slots` count is never a dispatch budget — absent `slots` + // means no trustworthy signal, so even a full count is busy. + expect(evaluateIdle(idle, 0)).toBe(false); + expect(evaluateIdle({ ...idle, available_slots: 3 }, 0)).toBe(false); + expect(evaluateIdle({ ...idle, available_slots: 4 }, 2)).toBe(false); + }); + + it('fails closed when per-slot detail is empty or stale', () => { + expect(evaluateIdle({ ...idle, slots: [] }, 0)).toBe(false); + expect( + evaluateIdle( + { ...idle, slots: slotsFor([]), slots_stale: true }, + 0, + ), + ).toBe(false); + }); + + it('N=0 (default) requires all slots free from the per-slot array', () => { + expect(evaluateIdle({ ...idle, slots: slotsFor([]) }, 0)).toBe(true); + // available_slots still says 4 but the per-slot array shows one busy → busy. + expect( + evaluateIdle({ ...idle, slots: slotsFor([4]) }, 0), + ).toBe(false); + }); + + it('N == total slots behaves like all slots free', () => { + expect(evaluateIdle({ ...idle, slots: slotsFor([]) }, 4)).toBe(true); + expect(evaluateIdle({ ...idle, slots: slotsFor([4]) }, 4)).toBe(false); + }); + + it('N > total slots can never be idle (never dispatches)', () => { + expect(evaluateIdle({ ...idle, slots: slotsFor([]) }, 5)).toBe(false); + }); + + it('ambiguous responses (total_slots 0) are busy', () => { + expect( + evaluateIdle({ ...idle, total_slots: 0, available_slots: 0 }, 0), + ).toBe(false); + }); +}); + +// ── Per-slot idle tracker (AC3/AC4) ─────────────────────────────────── + +describe('per-slot idle tracker (createPerSlotIdleTracker)', () => { + const a: LlamaSlot = { slot_id: 'a', is_processing: false }; + const b: LlamaSlot = { slot_id: 'b', is_processing: false }; + + it('starts a slot timer on its first free poll and continues it across free polls', () => { + const tracker = createPerSlotIdleTracker(); + const start = 1_000_000; + + tracker.record([a, b], start); + expect(tracker.idleSince.get('a')).toBe(start); + expect(tracker.idleSince.get('b')).toBe(start); + + tracker.record([a, b], start + 30_000); + expect(tracker.idleSince.get('a')).toBe(start); // run start stays fixed + expect(tracker.idleSince.get('b')).toBe(start); + }); + + it('a slot starting processing resets ONLY its own timer (other timers continue)', () => { + const tracker = createPerSlotIdleTracker(); + const start = 1_000_000; + + tracker.record([a, b], start); + tracker.record([{ slot_id: 'a', is_processing: true }, b], start + 10_000); + + expect(tracker.idleSince.get('a')).toBeNull(); // own timer reset + expect(tracker.idleSince.get('b')).toBe(start); // other timer continues + }); + + it('a slot absent from the payload is fail-closed to busy (its timer resets)', () => { + const tracker = createPerSlotIdleTracker(); + const start = 1_000_000; + + tracker.record([a, b], start); + tracker.record([a], start + 10_000); // b absent + + expect(tracker.idleSince.get('a')).toBe(start); + expect(tracker.idleSince.get('b')).toBeNull(); + }); + + it('an empty record resets every slot timer (global-busy reset path)', () => { + const tracker = createPerSlotIdleTracker(); + const start = 1_000_000; + + tracker.record([a, b], start); + tracker.record([], start + 10_000); + + expect(tracker.idleSince.get('a')).toBeNull(); + expect(tracker.idleSince.get('b')).toBeNull(); + }); + + it('thresholdMetCount counts only slots continuously free for the full threshold', () => { + const tracker = createPerSlotIdleTracker(); + const start = 1_000_000; + + tracker.record([a, b], start); + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS - 1)).toBe(0); + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(2); + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + 500_000)).toBe(2); + }); + + it('a reset slot needs a fresh full run before it counts toward the threshold again', () => { + const tracker = createPerSlotIdleTracker(); + const start = 1_000_000; + + tracker.record([a, b], start); + // a goes processing at +10s (own timer reset) and free again at +20s. + tracker.record([{ slot_id: 'a', is_processing: true }, b], start + 10_000); + tracker.record([a, b], start + 20_000); + + // Only b has a full continuous run at start+threshold+5s. + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 5_000)).toBe(1); + // a completes its fresh run at start+20s+threshold → both count. + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + 20_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(2); + }); +}); + +// ── evaluateIdle: per-slot mode (AC5 + per-slot free count) ─────────── + +describe('evaluateIdle per-slot mode (0 < N < total with slots present)', () => { + const free = (id: string): LlamaSlot => ({ slot_id: id, is_processing: false }); + const busy = (id: string): LlamaSlot => ({ slot_id: id, is_processing: true }); + const perSlot = (slots: LlamaSlot[], available: number, total: number): LlamaStatus => ({ + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: available, + total_slots: total, + slots, + }); + // 4 slots, 2 free (slot-1, slot-2). + const twoFree = perSlot([free('s1'), free('s2'), busy('s3'), busy('s4')], 2, 4); + + it('uses the per-slot free count when per-slot data is present and 0 < N < total', () => { + expect(evaluateIdle(twoFree, 2)).toBe(true); // 2 of 4 free — enough + expect(evaluateIdle(twoFree, 3)).toBe(false); // needs 3 — not enough + }); + + it('per-slot mode still requires the relaxed global checks (server up + no model switch)', () => { + // Spare-capacity relaxation (parent WL-0MT32F90V008UAD2 AC2): in per-slot + // mode ONLY llama_server_running and model_switch_in_progress remain + // global gates — query/lease signals are superseded by per-slot + // is_processing (a busy slot's query/lease is the operator's own session). + expect(evaluateIdle(twoFree, 2)).toBe(true); // relaxed: query does NOT block + expect(evaluateIdle({ ...twoFree, local_active_query: true }, 2)).toBe(true); // relaxed + expect(evaluateIdle({ ...twoFree, local_lease_active: true }, 2)).toBe(true); // relaxed: lease does NOT block + expect(evaluateIdle({ ...twoFree, model_switch_in_progress: true }, 2)).toBe(false); // still global + expect(evaluateIdle({ ...twoFree, llama_server_running: false }, 2)).toBe(false); // still global + }); + + it('N=0 (default) still requires ALL slots free even when per-slot data is present', () => { + expect(evaluateIdle(twoFree, 0)).toBe(false); // 2 < 4 → busy + expect(evaluateIdle(perSlot([free('s1'), free('s2'), free('s3'), free('s4')], 4, 4), 0)).toBe(true); + }); + + it('N == total still requires ALL slots free even when per-slot data is present', () => { + expect(evaluateIdle(perSlot([free('s1'), free('s2'), free('s3'), free('s4')], 4, 4), 4)).toBe(true); + expect(evaluateIdle(perSlot([free('s1'), free('s2'), busy('s3'), free('s4')], 3, 4), 4)).toBe(false); + }); + + it('N > total never idles even with per-slot data', () => { + expect(evaluateIdle(perSlot([free('s1'), free('s2'), free('s3'), free('s4')], 4, 4), 5)).toBe(false); + }); +}); + +// ── Spare-capacity dispatch (parent WL-0MT32F90V008UAD2, F1) ────────── +// Tests for the spare-capacity dispatch feature: relaxed global gate in +// per-slot mode (local_active_query / local_lease_active do NOT block), +// the new DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 2, per-tier free-slot +// minimums at dispatch selection, and unchanged pre-fix proxy degradation. + +/** Per-slot status: 3 of 4 slots free (slot-1 busy), with global query/lease + * signals active — exactly the "operator session" pattern the spare-capacity + * dispatch must handle. */ +function perSlotThreeOfFourFreeActiveQuery(): LlamaStatus { + return { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 3, + total_slots: 4, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + { slot_id: 'slot-4', is_processing: false }, + ], + }; +} + +describe('spare-capacity dispatch: relaxed global idle gate (per-slot mode)', () => { + it('per-slot mode: a mid-query busy slot does NOT block dispatch into free slots (AC1)', () => { + // 3 of 4 slots free with local_active_query=true, local_lease_active=true. + // In the relaxed global gate (AC1), these per-slot query/lease signals + // are superseded by per-slot is_processing. Only llama_server_running and + // model_switch_in_progress stay global. + const status = perSlotThreeOfFourFreeActiveQuery(); + // The new relaxed check: active_query/local_active_query/local_lease + // are NOT blocking in per-slot mode — 3 free >= N=2. + expect(evaluateIdle(status, 2)).toBe(true); // spare capacity: 3 >= 2 free + }); + + it('per-slot mode: local_lease_active on a busy slot does NOT block dispatch (AC1)', () => { + const status: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 3, + total_slots: 4, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + { slot_id: 'slot-4', is_processing: false }, + ], + }; + // local_lease_active is per-slot; in per-slot mode it does NOT block. + expect(evaluateIdle(status, 2)).toBe(true); // 3 free >= 2 + }); + + it('per-slot mode: local_active_query on a busy slot does NOT block dispatch (AC2)', () => { + const status: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_active_query: true, + local_lease_active: false, + available_slots: 2, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + }; + // 2 of 3 free with a mid-query busy slot — per-slot mode should fire. + expect(evaluateIdle(status, 2)).toBe(true); // AC2: mid-query busy slot + }); + + it('per-slot mode: model_switch_in_progress still blocks dispatch (global gate)', () => { + const status = perSlotThreeOfFourFreeActiveQuery(); + // model_switch_in_progress is ALWAYS global — even in per-slot mode. + expect(evaluateIdle({ ...status, model_switch_in_progress: true }, 2)).toBe(false); + }); + + it('per-slot mode: llama_server_running=false still blocks dispatch (global gate)', () => { + const status = perSlotThreeOfFourFreeActiveQuery(); + // server down is ALWAYS global — even in per-slot mode. + expect(evaluateIdle({ ...status, llama_server_running: false }, 2)).toBe(false); + }); +}); + +describe('spare-capacity dispatch: DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 2 (AC1)', () => { + it('DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS should be 2 for out-of-the-box spare-capacity', () => { + // The new default: 2 of 3 slots must be free for dispatch. + expect(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS).toBe(2); + }); +}); + +describe('spare-capacity dispatch: pre-fix proxy (no local_active_query) fails closed', () => { + it('pre-fix proxy (no local_active_query): absent signal → busy regardless of slot state', () => { + // A pre-fix proxy does not serve local_active_query. The worker + // fails closed (busy) on an absent signal — no silent degraded dispatch. + const noLocalQuery: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 2, + total_slots: 4, + }; + // Absent local_active_query → busy (fail-closed), not slot count. + expect(evaluateIdle(noLocalQuery, 2)).toBe(false); // absent → busy + expect(evaluateIdle(noLocalQuery, 4)).toBe(false); // absent → busy + // Even with all slots free, absent local_active_query → busy. + const allFree: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + expect(evaluateIdle(allFree, 2)).toBe(false); // absent → busy (fail-closed) + }); + + it('pre-fix proxy with N=0 (default): absent local_active_query → busy', () => { + const status: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 2, + total_slots: 4, + }; + // Absent local_active_query → busy (fail-closed), regardless of slot count. + expect(evaluateIdle(status, 0)).toBe(false); // absent → busy + }); +}); + +describe('spare-capacity dispatch: ambiguous payloads are busy (AC5)', () => { + it('missing slot_id fields are treated as busy (fail-closed)', () => { + const malformed: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 4, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + ], + }; + // The slots array has only 2 entries for 4 total — this is ambiguous. + // With per-slot mode active (0 < N < total), the free count is 1 < 2. + expect(evaluateIdle(malformed, 2)).toBe(false); + }); + + it('total_slots 0 is always busy', () => { + const status: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 0, + total_slots: 0, + }; + expect(evaluateIdle(status, 2)).toBe(false); + }); + + it('N > total can never be idle', () => { + const status: LlamaStatus = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: false }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + }; + // 3 free < 4 required — never idle. + expect(evaluateIdle(status, 4)).toBe(false); + }); +}); + +describe('slot ownership helpers (AC5)', () => { + it('isSlotOwned is true only for a non-empty owner_session_id', () => { + expect(isSlotOwned({ slot_id: 's', is_processing: false, owner_session_id: 'abc' })).toBe(true); + expect(isSlotOwned({ slot_id: 's', is_processing: false, owner_session_id: null })).toBe(false); + expect(isSlotOwned({ slot_id: 's', is_processing: false })).toBe(false); + expect(isSlotOwned({ slot_id: 's', is_processing: false, owner_session_id: '' })).toBe(false); + }); + + it('countFreeUnownedSlots excludes processing AND owned slots', () => { + expect( + countFreeUnownedSlots([ + { slot_id: 'a', is_processing: false }, + { slot_id: 'b', is_processing: true }, + { slot_id: 'c', is_processing: false, owner_session_id: 'live' }, + { slot_id: 'd', is_processing: false }, + ]), + ).toBe(2); + }); + + it('countFreeUnownedSlots is fail-closed for a missing is_processing', () => { + expect( + countFreeUnownedSlots([{ slot_id: 'a' } as unknown as LlamaSlot]), + ).toBe(0); + }); + + it('countFreeUnownedSlots is 0 when every slot is owned (AC3 all-owned condition)', () => { + // The literal all-owned case for the per-slot owner-lease gate + // (WL-0MU8807BI008C9ME AC3): every slot carries a live owner lease, so + // the per-slot free-unowned count — the gate signal in per-slot mode — + // is exactly zero. A slot that is idle by `is_processing` but owned is + // still NOT free. + expect( + countFreeUnownedSlots([ + { slot_id: 'a', is_processing: true, owner_session_id: 'owner-a' }, + { slot_id: 'b', is_processing: true, owner_session_id: 'owner-b' }, + { slot_id: 'c', is_processing: false, owner_session_id: 'owner-c' }, + ]), + ).toBe(0); + }); +}); + +describe('evaluateIdle: idle-but-owned slot is not dispatchable (AC3/AC5)', () => { + it('per-slot mode treats an owned free slot as busy', () => { + // 3 slots reported: slot-1 owned (idle), slot-2/slot-3 unowned-free → + // only 2 genuinely free, so N=2 is idle but N=3 would exceed the total. + expect(evaluateIdle(perSlotIdleOwned, 2)).toBe(true); + // Only 1 unowned slot free (slot-1 owned, slot-2 processing) → N=2 busy. + expect(evaluateIdle(perSlotOwnedOneUnowned, 2)).toBe(false); + }); + + it('a single idle-but-owned slot reports busy (count-based path)', () => { + // The global owner lease derives local_lease_active → the count-based + // path fails closed. + expect(evaluateIdle(singleSlotIdleOwned, 0)).toBe(false); + expect(evaluateIdle(singleSlotIdleOwned, 1)).toBe(false); + }); +}); + +describe('createPerSlotIdleTracker: owned slot timer resets (AC5)', () => { + it('an owned slot never accumulates idle time', () => { + const tracker = createPerSlotIdleTracker(); + const start = 1_000_000; + tracker.record( + [ + { slot_id: 'slot-1', is_processing: false, owner_session_id: 'live' }, + { slot_id: 'slot-2', is_processing: false }, + ], + start, + ); + expect(tracker.thresholdMetCount(1_000, start + 5_000)).toBe(1); + }); +}); + diff --git a/packages/herdr/src/downtime-worker.orchestrator.test.ts b/packages/herdr/src/downtime-worker.orchestrator.test.ts new file mode 100644 index 000000000..9e8e9b760 --- /dev/null +++ b/packages/herdr/src/downtime-worker.orchestrator.test.ts @@ -0,0 +1,1822 @@ +/** + * packages/herdr/src/downtime-worker.orchestrator.test.ts — worker + * orchestration, cooldowns, per-slot routing, rotation and probe-jitter + * tests + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4VJE8V00478ZF) to keep each test file focused + * on one concern. Covers: + * - the `createDowntimeWorker` orchestrator and its dispatch loop, + * - the no-candidate cooldown, + * - the three-strike rule on CLI errors, + * - `parseLlamaStatus` optional per-slot `slots` parsing, + * - the per-slot idle-tracking / routing modes, + * - `selectWithRotation` round-robin tie-break and its tier wiring, + * - the worker probe jitter (`jitterPollIntervalMs`). + * + * Shared scaffolding (`makeDeps`, status fixtures, etc.) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + */ + +import { writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + evaluateIdle, + parseLlamaStatus, + createDowntimePoller, + dispatchDowntimeWork, + dispatchFromCoordination, + createDowntimeWorker, + selectImplementCandidate, + selectNextCandidate, + selectAuditCandidate, + selectWithRotation, + DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + type LlamaStatus, + type DowntimeCandidate, + type DowntimeWorkerDeps, + type AuditCandidate, + type ImplementCandidate, +} from './downtime-worker.js'; +import { createRoundRobinRegistry } from './downtime-round-robin.js'; +import { LEASE_FILE, LEADER_LOCK_FILE, isLeaseValid } from './leader-election.js'; +import { writeCoordinationFile } from './coordination.js'; +import { + idleAllSlotsFree, + perSlotAllFree, + perSlotTwoFree, + perSlotOneProcessing, + jsonResponseFixture, + makeDeps, + createTempDirTracker, +} from './downtime-worker.fixtures.js'; + +// ── Worker orchestrator (AC1/AC5) ───────────────────────────────────── + +describe('downtime worker orchestrator (createDowntimeWorker)', () => { + function makeWorker(overrides: { + enabled?: boolean; + thresholdMs?: number; + cooldownMs?: number; + status?: unknown; + deps?: Partial; + mode?: 'cheap' | 'fast'; + concurrentDispatchCap?: number; + } = {}) { + const cfg = { + enabled: overrides.enabled ?? true, + thresholdMs: overrides.thresholdMs ?? DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: overrides.cooldownMs ?? 3_600_000, + // Mode-aware spawn config inputs (WL-0MT50S9JW001DHME). + ...(overrides.mode !== undefined ? { mode: overrides.mode } : {}), + ...(overrides.concurrentDispatchCap !== undefined + ? { concurrentDispatchCap: overrides.concurrentDispatchCap } + : {}), + }; + const fetcher = vi + .fn() + .mockResolvedValue(jsonResponseFixture(overrides.status ?? idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does nothing (no poll, no dispatch) when disabled', async () => { + const { worker, deps, fetcher } = makeWorker({ enabled: false }); + const result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + expect(fetcher).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('treats a busy status as busy and never dispatches', async () => { + const { worker, deps } = makeWorker({ status: { ...idleAllSlotsFree, local_active_query: true } }); + const result = await worker.tick(); + expect(result.idle).toBe(false); + expect(worker.idleSince).toBeNull(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('treats remote-only traffic as idle and dispatches after the threshold (local_active_query=false)', async () => { + // Integration (AC2): a stub status with local_active_query=false + // (remote streams in flight, local slots free) must be treated as idle + // and dispatch after the idle threshold. + const { worker, deps, cfg } = makeWorker({ + status: { ...idleAllSlotsFree, local_active_query: false }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch until the idle duration reaches the threshold (AC1)', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); + + vi.setSystemTime(start + cfg.thresholdMs - 1); + const before = await worker.tick(); + expect(before.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(worker.lastDispatchAt).toBe(start + cfg.thresholdMs); + }); + + it('tick forwards a mode-aware spawnConfig with the genuine free-slot snapshot (WL-0MT50S9JW001DHME)', async () => { + const { worker, deps, cfg } = makeWorker({ mode: 'cheap' }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + + vi.setSystemTime(start + cfg.thresholdMs); + await worker.tick(); + + // idleAllSlotsFree: 4 of 4 slots free, total 4 → forwarded verbatim so + // the spawn boundary never has to reach back into the worker. + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + spawnConfig: { mode: 'cheap', slotBudget: 4, freeSlots: 4, concurrentDispatchCap: 1 }, + }), + ); + }); + + it('tick omits spawnConfig when the proxy mode is unknown (conservative serial default)', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + + vi.setSystemTime(start + cfg.thresholdMs); + await worker.tick(); + + const call = (deps.spawnAgentPane as ReturnType).mock.calls[0]; + expect(call[1]).not.toHaveProperty('spawnConfig'); + }); + + it('requires a fresh full idle period after a dispatch (AC5)', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + cfg.thresholdMs); + await worker.tick(); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + + // Still idle at the next tick: the tracker was reset after dispatch, so + // no second dispatch until another full idle period has elapsed. + vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + const next = await worker.tick(); + expect(next.dispatched).toBe(false); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(worker.idleSince).toBe(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + + vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS + cfg.thresholdMs); + const after = await worker.tick(); + expect(after.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); + }); + + it('a busy poll (proxy busy after dispatch) resets the idle run', async () => { + const { worker, deps, cfg, fetcher } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + cfg.thresholdMs); + await worker.tick(); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + + fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...idleAllSlotsFree, local_active_query: true })); + vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + const busy = await worker.tick(); + expect(busy.idle).toBe(false); + expect(worker.idleSince).toBeNull(); + + // Back to idle: a full new run is required before the next dispatch. + vi.setSystemTime(start + cfg.thresholdMs + 60_000); + const reIdle = await worker.tick(); + expect(reIdle.idle).toBe(true); + expect(reIdle.dispatched).toBe(false); + vi.setSystemTime(start + cfg.thresholdMs + 60_000 + cfg.thresholdMs); + const reDispatch = await worker.tick(); + expect(reDispatch.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); + }); + + it('re-reads settings each tick so changes apply without a restart', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + + cfg.thresholdMs = 60_000; // operator changes the setting live + vi.setSystemTime(start + 60_000); + const result = await worker.tick(); + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('does not start a second dispatch while one is in flight (worker single-flight)', async () => { + let release!: () => void; + const gate = new Promise<{ ok: true }>((resolve) => { + release = () => resolve({ ok: true }); + }); + const { worker, deps } = makeWorker({ + deps: { spawnAgentPane: vi.fn().mockImplementation(() => gate) }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + const first = worker.tick(); + const second = await worker.tick(); + expect(second.dispatched).toBe(false); + + release(); + const firstResult = await first; + expect(firstResult.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); +}); + +// ── No-candidate cooldown (WL-0MSI7DQL10016QYX) ────────────────────── + +describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { + /** Empty backlog in BOTH stages → the worker should pause. */ + function makeEmptyBacklogWorker(overrides: { + cooldownMs?: number; + deps?: Partial; + } = {}) { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: overrides.cooldownMs ?? 3_600_000, + }; + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + // ── Coordination-dir worker fixture (F2 — cooldown lease maintenance) ── + + /** Tmp dirs created by makeCoordinationWorker — cleaned up in afterEach. */ + const coordinationTmpDirs = createTempDirTracker(); + + function makeCoordinationWorker(overrides: { + instanceId?: string; + leaseTtlSeconds?: number; + cooldownMs?: number; + deps?: Partial; + /** Pre-seed another instance's valid lease BEFORE the worker is created. */ + seedLease?: { instanceId: string; atMs: number }; + } = {}) { + const coordDir = coordinationTmpDirs.make('cd-wl-0MTF4A4V3-'); + const instanceId = overrides.instanceId ?? 'inst-a'; + const leaseTtlSeconds = overrides.leaseTtlSeconds ?? 300; + if (overrides.seedLease) { + writeLease(coordDir, overrides.seedLease.instanceId, leaseTtlSeconds, overrides.seedLease.atMs); + } + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: overrides.cooldownMs ?? 3_600_000, + }; + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + // fetchItem is REQUIRED for coordination dispatch (dispatchFromCoordination). + // Default: returns dispatchable info for any item id. + fetchItem: vi.fn().mockResolvedValue({ + ok: true, + info: { id: 'WL-FAKE', title: 'Fake', status: 'open', stage: 'intake_complete' }, + }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + coordinationDir: coordDir, + instanceId, + leaseTtlSeconds, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher, coordDir, instanceId, leaseTtlSeconds }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + coordinationTmpDirs.cleanup(); + }); + + it('enters the cooldown only after a genuine no-candidate outcome', async () => { + const { worker, deps } = makeEmptyBacklogWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // idle run starts + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met → dispatch attempt + + const result = await worker.tick(); + + expect(result.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(worker.paused).toBe(true); + }); + + it('dispatches a due scheduled prompt instead of entering the no-candidate cooldown (AC6)', async () => { + // Scheduled tier first (WL-0MSS1Q5ER007QDKX): even with a genuinely + // empty backlog behind it, a DUE prompt dispatches — it never reaches + // the backlog tiers, so it never triggers the no-candidate cooldown. + const { worker, deps } = makeEmptyBacklogWorker({ + deps: { + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: '/skill:refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, + }), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // idle run starts + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met → dispatch attempt + + const result = await worker.tick(); + + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + '/skill:refactor', + expect.objectContaining({ paneName: 'Downtime /skill:refactor' }), + ); + expect(worker.paused).toBe(false); // a dispatch — not the empty-backlog pause + expect(worker.errorStrikes).toBe(0); + }); + + it('performs no proxy polling, no idle tracking and no dispatch while paused', async () => { + const { worker, deps, fetcher } = makeEmptyBacklogWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); // enters cooldown + expect(worker.paused).toBe(true); + + const pollsBefore = fetcher.mock.calls.length; + const idleBefore = worker.idleSince; + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 60_000); // still within the pause + const result = await worker.tick(); + + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + expect(fetcher.mock.calls.length).toBe(pollsBefore); // no poll + expect(worker.idleSince).toBe(idleBefore); // no idle tracking + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(worker.paused).toBe(true); + }); + + it('resumes polling once the cooldown expires and requires a fresh full idle period', async () => { + let backlogEmpty = true; + const { worker, deps } = makeEmptyBacklogWorker({ + deps: { + getNextItem: vi.fn().mockImplementation(() => + Promise.resolve( + backlogEmpty + ? { ok: true, candidate: null } + : { ok: true, candidate: { id: 'WL-NEW', title: 'New item', stage: 'intake_complete' } }, + ), + ), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); // empty backlog → cooldown + expect(worker.paused).toBe(true); + + // The project's backlog fills back up while the worker is paused. + backlogEmpty = false; + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // pause expires + const resumed = await worker.tick(); + expect(resumed.polled).toBe(true); + expect(resumed.dispatched).toBe(false); // fresh idle run — no stale credit + expect(worker.paused).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + // A full new idle period must elapse before the next dispatch. + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + const afterFreshIdle = await worker.tick(); + expect(afterFreshIdle.dispatched).toBe(true); // candidate now available + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('does not enter the cooldown on a transient wl error (fail closed to busy)', async () => { + const { worker, deps } = makeEmptyBacklogWorker({ + deps: { getNextItem: vi.fn().mockResolvedValue({ ok: false }) }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + const result = await worker.tick(); + + expect(result.dispatched).toBe(false); + expect(worker.paused).toBe(false); // a single error is NOT an empty backlog + expect(worker.errorStrikes).toBe(1); // ...but it IS the first strike + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + // WL-0MTJPYM53003ORCV: every strike is logged (per-strike observability). + expect(deps.recordError).toHaveBeenCalledTimes(1); + }); + + it('does not enter the cooldown when the dispatch guard reports in-flight', async () => { + // Hold the module-level dispatch single-flight guard with an outer + // in-flight dispatch; the worker's own dispatch call then returns + // `dispatch-in-flight`, which must NOT trigger the cooldown. + let release!: () => void; + const gate = new Promise<{ ok: true }>((resolve) => { + release = () => resolve({ ok: true }); + }); + const outerDeps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-X', title: 'Outer', stage: 'intake_complete' }, + }), + spawnAgentPane: vi.fn().mockImplementation(() => gate), + }); + const outer = dispatchDowntimeWork(outerDeps, { model: 'plan', cwd: '/repo' }); + + const { worker } = makeEmptyBacklogWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + const result = await worker.tick(); + expect(result.dispatched).toBe(false); + expect(worker.paused).toBe(false); // in-flight guard is not an empty backlog + + release(); + await outer; + }); + + it('does not enter the cooldown on a code-freeze skip (the freeze never pauses the worker)', async () => { + // Frozen + empty plan/intake backlog → reason 'code-freeze' (never + // 'no-candidate'): the worker must keep polling so implement/audit + // dispatch resumes immediately when the freeze lifts (WL-0MSQ0RPQP00636JY). + const { worker, deps } = makeEmptyBacklogWorker({ + deps: { + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + const result = await worker.tick(); + + expect(result.dispatched).toBe(false); + expect(worker.paused).toBe(false); // a freeze skip is NOT an empty backlog + expect(worker.errorStrikes).toBe(0); // ...and not a CLI-error strike either + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('re-reads the cooldown setting each tick so a change applies on the next cooldown entry', async () => { + const { worker, cfg } = makeEmptyBacklogWorker({ cooldownMs: 3_600_000 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); // enters cooldown with 3_600_000 (expires at 4_840_000) + expect(worker.paused).toBe(true); + + // Operator lowers the cooldown live; the in-progress pause keeps its + // original expiry, but the NEXT cooldown entry uses the new value. + cfg.noCandidateCooldownMs = 60_000; + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // original pause expires + await worker.tick(); // resumes, starts a fresh idle run + + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // fresh threshold met + await worker.tick(); // empty backlog again → 60s cooldown (new value) + expect(worker.paused).toBe(true); + + // With the NEW 60s value the pause expires long before the old 60-min + // default would have: at +61s the worker has resumed polling. + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 61_000); + const resumed = await worker.tick(); + expect(resumed.polled).toBe(true); + expect(worker.paused).toBe(false); + }); + + // ── Cooldown lease maintenance (parent F2 — holes A & B regression) ── + + /** Read the current lease file from the coordination directory. */ + function readLease(coordDir: string) { + const raw = readFileSync(join(coordDir, LEASE_FILE), 'utf-8'); + return JSON.parse(raw) as { leaderId: string; acquiredAt: string; ttlSeconds: number }; + } + + /** Write a lease file directly (simulates another instance winning election). */ + function writeLease(coordDir: string, instanceId: string, ttlSeconds: number, nowMs: number) { + const lease = { + leaderId: instanceId, + acquiredAt: new Date(nowMs).toISOString(), + ttlSeconds, + }; + writeFileSync(join(coordDir, LEASE_FILE), JSON.stringify(lease)); + // Also write the lock file so hasLease() returns true. + writeFileSync(join(coordDir, LEADER_LOCK_FILE), instanceId); + } + + it('keeps refreshing the lease during the no-candidate cooldown pause (AC1)', async () => { + // While a leader is paused in the no-candidate cooldown, the lease file + // mtime/content keeps advancing (refresh continues during the pause). + // Sibling fix (e842208580) already runs the leader block BEFORE the + // cooldown gate, so lease refreshes each tick during the pause. + const { worker, coordDir } = makeCoordinationWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + + // Tick 1: election → A wins lease, refreshLease + await worker.tick(); + expect(worker.isLeader).toBe(true); + expect(worker.paused).toBe(false); + + const leaseAfterElection = readLease(coordDir); + expect(leaseAfterElection.leaderId).toBe('inst-a'); + + // Tick 2: threshold met → dispatch attempt → no-candidate → cooldown + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); + expect(worker.paused).toBe(true); + expect(worker.isLeader).toBe(true); + + // Tick 3 during cooldown: lease should refresh (leader block runs first) + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 60_000); + await worker.tick(); + + const leaseDuringPause = readLease(coordDir); + expect(leaseDuringPause.leaderId).toBe('inst-a'); + // Content-based pin (fake timers control Date.now — robust vs fs mtime + // granularity): the lease acquiredAt must have ADVANCED during the pause. + expect(new Date(leaseDuringPause.acquiredAt).getTime()).toBeGreaterThan( + new Date(leaseAfterElection.acquiredAt).getTime(), + ); + // Belt: the pause itself is still in effect (no premature resume). + expect(worker.paused).toBe(true); + }); + + it('renews the lease on cooldown exit BEFORE dispatch (even if expired mid-pause) (AC2)', async () => { + // On cooldown exit, the lease is renewed BEFORE any dispatch decision + // (belt: even if tick loop stalled mid-pause, the lease expired). + const { worker, coordDir } = makeCoordinationWorker({ + leaseTtlSeconds: 300, // 5 min TTL + }); + const start = 1_000_000; + vi.setSystemTime(start); + + // Tick 1: election → A wins lease + await worker.tick(); + expect(worker.isLeader).toBe(true); + + // Tick 2: threshold met → dispatch attempt → no-candidate → cooldown + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); + expect(worker.paused).toBe(true); + expect(worker.isLeader).toBe(true); + + // Stalled tick loop: advance time PAST lease TTL (300s) but still within + // the cooldown (3.6M ms) — simulate a frozen tick loop mid-pause. + // Lease TTL = 300s; we advance 400s into the cooldown → lease expired. + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 400_000); + // Do NOT tick — simulate the loop being frozen. + + // Now advance to cooldown expiry. + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + 1_000); + + // The resume tick: leader block runs (cached leaderState=true) + // but refreshLease no-ops on our expired lease (Hole A — isLeader returns + // false because lease expired). + // Cooldown gate clears. Then poll/dispatch. + await worker.tick(); + + // AC2 assertion: lease must be VALID after the exit tick (renewed BEFORE + // any dispatch decision). On current code the lease is still expired + // (refreshLease no-oped), so this FAILS — proving Hole A. + const leaseAfterExit = readLease(coordDir); + const leaseValid = isLeaseValid(leaseAfterExit); + expect(leaseValid).toBe(true); + expect(leaseAfterExit.leaderId).toBe('inst-a'); + // Belt: the lease was RENEWED at exit — its acquiredAt must postdate the + // stalled period (current code keeps the stale acquiredAt → FAILS). + expect(new Date(leaseAfterExit.acquiredAt).getTime()).toBeGreaterThan( + start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 400_000, + ); + }); + + it('re-derives leadership every tick: expired lease routes to election, no zombie dispatch (AC3)', async () => { + // leaderState is re-derived every tick: an expired lease while + // leaderState === true routes into the election/takeover path instead of + // zombie dispatch. + const { worker, deps, coordDir } = makeCoordinationWorker({ + leaseTtlSeconds: 300, + }); + const start = 1_000_000; + vi.setSystemTime(start); + + // Tick 1: election → A wins lease + await worker.tick(); + expect(worker.isLeader).toBe(true); + + // Tick 2: threshold met → dispatch attempt → no-candidate → cooldown + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); + expect(worker.paused).toBe(true); + expect(worker.isLeader).toBe(true); + + // Mid-pause, B wins the election with a FRESH lease and a FRESH + // coordination entry (a live instance refreshes its entry on its own + // check-in cadence — write it at resume time so pruneStaleEntries does + // NOT delete it before A's dispatch tick; the entry must be present for + // the zombie dispatch to be observable). + const resumeTime = start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + 1_000; + vi.setSystemTime(resumeTime); + writeLease(coordDir, 'inst-b', 300, resumeTime); + + const entryB = { + instanceId: 'inst-b', + workItemId: 'WL-B1', + directory: '/repo-b', + assignedAt: new Date(resumeTime).toISOString(), + lastUpdated: new Date(resumeTime).toISOString(), + }; + writeCoordinationFile(coordDir, { version: 1, entries: [entryB] }); + + // Resume tick at cooldown expiry: leader block runs (cached + // leaderState=true) → refreshLease no-ops (B holds the lease → isLeader + // false) → leaderState STAYS true (Hole B). Cooldown gate clears → a + // FRESH idle run starts (tracker was reset on cooldown entry), so no + // dispatch happens on THIS tick — the zombie dispatch is decided on the + // NEXT tick after the idle threshold. + await worker.tick(); + + // Second tick: idle threshold met → dispatch decision. On current code + // (leaderState cached true) A reads B's entry and spawns A PANE — the + // zombie. After F5 (per-tick re-derivation) leaderState=false → the + // non-leader short-circuit returns BEFORE poll/dispatch. + vi.setSystemTime(resumeTime + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); + + // AC3 assertion: A must NOT dispatch B's item (B is the live leader). + // On current code (cached leaderState=true): spawnAgentPane IS called → + // FAIL (RED). After F5: leaderState re-derived false → GREEN. + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + // Also assert A is no longer leader (re-derived state). + expect(worker.isLeader).toBe(false); + }); + + it('stays silent when a valid foreign lease is held by another instance (AC4)', async () => { + // Non-leader with a valid lease held by another live instance stays + // silent (existing AC3 behavior preserved — regression guard). B's lease + // is pre-seeded BEFORE worker creation, so leaderState=false at startup + // (fixture passes coordination opts TOP-LEVEL as required). + const start = 1_000_000; + vi.setSystemTime(start); + const { worker, deps, fetcher } = makeCoordinationWorker({ + seedLease: { instanceId: 'inst-b', atMs: start }, + }); + + // Tick: leaderState at creation = isLeader() → false (lease owned by B). + // Leader block: leaderState false → hasLease=true, detectStaleLeader=false + // (B's lease is valid) → no election. Then: if (!leaderState) return. + const result = await worker.tick(); + + expect(result.polled).toBe(false); + expect(result.dispatched).toBe(false); + expect(result.idle).toBe(false); + expect(worker.isLeader).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(fetcher).not.toHaveBeenCalled(); // no poll + }); +}); + +// ── Three-strike rule on CLI errors ─────────────────────────────────── + +describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { + /** Every dispatch attempt ends in a wl CLI error ({ok:false} both tiers). */ + function makeErrorWorker(overrides: { deps?: Partial } = {}) { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: false }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + it('pauses and records the persistent error after 3 consecutive CLI errors', async () => { + const { worker, deps } = makeErrorWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // idle run starts + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met + + const s1 = await worker.tick(); // strike 1 + expect(s1.dispatched).toBe(false); + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(1); + + const s2 = await worker.tick(); // strike 2 + expect(s2.dispatched).toBe(false); + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(2); + + const s3 = await worker.tick(); // strike 3 → pause + log + expect(s3.dispatched).toBe(false); + expect(worker.paused).toBe(true); + expect(worker.errorStrikes).toBe(0); // counter reset once paused + // WL-0MTJPYM53003ORCV: every strike is logged — 3 per-strike entries + // (attempt 1/2/3), not just the pause marker at strike 3. + expect(deps.recordError).toHaveBeenCalledTimes(3); + const event3 = (deps.recordError as ReturnType).mock.calls[2][0]; + expect(event3.cwd).toBe('/repo'); + expect(event3.message).toContain('3 consecutive'); + expect(event3.attempt).toBe(3); + expect(Number.isNaN(Date.parse(event3.at))).toBe(false); + }); + + it('an audit-tier wl failure counts toward the three-strike rule (WL-0MSLWJ2KP0002SV0)', async () => { + // The wl-error comes from the AUDIT tier ({ok:false}) while the + // plan/intake tiers are GENUINELY empty ({ok:true, candidate:null}): the + // only possible strike source is the audit lookup, proving a broken + // audit query cannot silently pass as "no audit candidates" forever — + // three consecutive failures pause the worker and log recordError. + const { worker, deps } = makeErrorWorker({ + deps: { + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: false }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + const s1 = await worker.tick(); + expect(s1.dispatched).toBe(false); + expect(worker.errorStrikes).toBe(1); + expect(worker.paused).toBe(false); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + + const s2 = await worker.tick(); + expect(s2.dispatched).toBe(false); + expect(worker.errorStrikes).toBe(2); + // WL-0MTJPYM53003ORCV: strikes 1 and 2 are already logged per-strike. + expect(deps.recordError).toHaveBeenCalledTimes(2); + + const s3 = await worker.tick(); // strike 3 → pause + durable trace + expect(s3.dispatched).toBe(false); + expect(worker.paused).toBe(true); + expect(worker.errorStrikes).toBe(0); + expect(deps.recordError).toHaveBeenCalledTimes(3); + expect(deps.recordError).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/repo' }), + ); + }); + + it('resumes with a fresh strike counter once the pause expires', async () => { + const { worker, deps } = makeErrorWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); // strike 1 + await worker.tick(); // strike 2 + await worker.tick(); // strike 3 → paused + expect(worker.paused).toBe(true); + + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // pause expires + const resumed = await worker.tick(); + expect(resumed.polled).toBe(true); + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(0); + + // A fresh error is strike 1 again — not a strike 4. + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); + expect(worker.errorStrikes).toBe(1); + expect(worker.paused).toBe(false); + }); + + it('resets the strike counter on a successful dispatch', async () => { + const { worker, deps } = makeErrorWorker({ + deps: { + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: false }) // tier 2 error + .mockResolvedValueOnce({ ok: false }) // tier 3 error → strike 1 + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' } }) + .mockResolvedValue({ ok: false }), // errors afterwards + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + await worker.tick(); // strike 1 + expect(worker.errorStrikes).toBe(1); + + await worker.tick(); // successful dispatch → strikes reset + expect(worker.errorStrikes).toBe(0); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + + // A fresh full idle period is required after the dispatch (AC5) before + // the next dispatch attempt. + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // fresh idle run starts + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 480_000); // fresh threshold met + await worker.tick(); // strike 1 (fresh — not a strike 2) + expect(worker.errorStrikes).toBe(1); + await worker.tick(); // strike 2 + expect(worker.paused).toBe(false); + expect(worker.errorStrikes).toBe(2); + + await worker.tick(); // strike 3 → paused + expect(worker.paused).toBe(true); + // WL-0MTJPYM53003ORCV: per-strike logging — 1 (pre-dispatch strike) + + // 3 (fresh strikes 1-3) = 4 recordError calls. + expect(deps.recordError).toHaveBeenCalledTimes(4); + }); + + it('does not strike on a no-candidate outcome (CLI answered — healthy)', async () => { + const { worker, deps } = makeErrorWorker({ + deps: { + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: false }) + .mockResolvedValueOnce({ ok: false }) // strike 1 + .mockResolvedValue({ ok: true, candidate: null }), // genuine empty backlog + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + await worker.tick(); // strike 1 → recordError logged + expect(worker.errorStrikes).toBe(1); + expect(deps.recordError).toHaveBeenCalledTimes(1); + + const result = await worker.tick(); // no-candidate → cooldown, strikes reset + expect(result.dispatched).toBe(false); + expect(worker.paused).toBe(true); // genuine empty backlog pauses the worker + expect(worker.errorStrikes).toBe(0); + // recordError was called for strike 1; no-candidate does NOT add a strike + expect(deps.recordError).toHaveBeenCalledTimes(1); + }); + + // ── Per-strike logging (WL-0MTJPYM53003ORCV) ──────────────────────── + + it('records a persistent error on EVERY strike (1, 2, and 3), not just strike 3', async () => { + const { worker, deps } = makeErrorWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + await worker.tick(); // strike 1 + expect(worker.errorStrikes).toBe(1); + expect(deps.recordError).toHaveBeenCalledTimes(1); + + await worker.tick(); // strike 2 + expect(worker.errorStrikes).toBe(2); + expect(deps.recordError).toHaveBeenCalledTimes(2); + + await worker.tick(); // strike 3 → pause + log + expect(worker.errorStrikes).toBe(0); + expect(deps.recordError).toHaveBeenCalledTimes(3); + }); + + it('per-strike log entries carry attempt/probeContext/timeoutMs on every strike (WL-0MTJPYM53003ORCV)', async () => { + const { worker, deps } = makeErrorWorker({ + deps: { + // Carry a stderr-ish message so stderrExcerpt is populated from the + // error string (fetcher.ts builds error from stderr||stdout||message). + getNextItem: vi.fn().mockResolvedValue({ ok: false, error: 'SQLITE_BUSY: database is locked' }), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + await worker.tick(); // strike 1 + const event1 = (deps.recordError as ReturnType).mock.calls[0][0]; + expect(event1.cwd).toBe('/repo'); + expect(event1.attempt).toBe(1); + expect(event1.probeContext).toBe('dispatch-cli'); + expect(event1.timeoutMs).toBe(10_000); + expect(event1.stderrExcerpt).toBe('SQLITE_BUSY: database is locked'); + expect(Number.isNaN(Date.parse(event1.at))).toBe(false); + + await worker.tick(); // strike 2 + const event2 = (deps.recordError as ReturnType).mock.calls[1][0]; + expect(event2.attempt).toBe(2); + expect(event2.probeContext).toBe('dispatch-cli'); + expect(event2.stderrExcerpt).toBe('SQLITE_BUSY: database is locked'); + + await worker.tick(); // strike 3 + const event3 = (deps.recordError as ReturnType).mock.calls[2][0]; + expect(event3.attempt).toBe(3); + expect(event3.probeContext).toBe('dispatch-cli'); + }); + + it('stderr excerpt is truncated to 200 chars with [truncated] marker (WL-0MTJPYM53003ORCV)', async () => { + const longStderr = 'database is locked '.repeat(50); // > 200 chars + const { worker, deps } = makeErrorWorker({ + deps: { + getNextItem: vi.fn().mockResolvedValue({ ok: false, error: longStderr }), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + await worker.tick(); // strike 1 + const event = (deps.recordError as ReturnType).mock.calls[0][0]; + expect(event.stderrExcerpt.length).toBeLessThanOrEqual(200 + '[truncated]'.length); + expect(event.stderrExcerpt.endsWith('[truncated]')).toBe(true); + expect(event.stderrExcerpt.length).toBe(200 + '[truncated]'.length); + }); + + it('probeContext is dispatch-cli for the legacy dispatch path (WL-0MTJPYM53003ORCV)', async () => { + // The makeErrorWorker fixture runs in legacy (non-coordination) mode, + // so all errors flow through the dispatch-cli path. + const { worker, deps } = makeErrorWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + + await worker.tick(); // strike 1 + const event1 = (deps.recordError as ReturnType).mock.calls[0][0]; + expect(event1.probeContext).toBe('dispatch-cli'); + + await worker.tick(); // strike 2 + const event2 = (deps.recordError as ReturnType).mock.calls[1][0]; + expect(event2.probeContext).toBe('dispatch-cli'); + }); +}); + +// ── Per-slot idle tracking (WL-0MSP28F5Z008DAUA, parent WL-0MSG7P9N8009PCKG) ── +// Test-first suite for same-slot idle tracking, deferred follow-up Q7 of +// WL-0MSF49FMW009M06K. The proxy feature LP-0MSG5TA7Y002GN39 serves per-slot +// details (`slots: [{slot_id, is_processing}]`) in GET /llama/local/status; +// when `downtimeRequiredFreeSlots` N < total slots, the worker must track idle +// duration PER SLOT IDENTITY so the SAME N slots are free for the full +// threshold — never any-N transient availability. +// +// The proxy (observability.py) serves `slot_id` as an INTEGER +// (`slot.get("id", i)`); `parseLlamaStatus` coerces numeric ids to strings +// and clamps negatives to 0 (WL-0MSVRMAWM007QNR5 — the Aug 15-16 +// zero-dispatch regression fix). Regression tests mirror the live payload +// at parse level and end-to-end through the worker tick. + +// ── parseLlamaStatus: optional per-slot fields (AC1/AC2) ─────────────── + +describe('parseLlamaStatus per-slot slots array', () => { + const base = { + llama_server_running: true, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + }; + + it('exposes the slots array when the payload serves it', () => { + const status = parseLlamaStatus({ + ...base, + slots: [ + { slot_id: 'slot-1', is_processing: false }, + { slot_id: 'slot-2', is_processing: true }, + ], + }); + expect(status).not.toBeNull(); + expect(status!.slots).toEqual([ + { slot_id: 'slot-1', is_processing: false }, + { slot_id: 'slot-2', is_processing: true }, + ]); + }); + + it('leaves slots undefined when the payload does not serve it (backward compatible)', () => { + const status = parseLlamaStatus(base); + expect(status).not.toBeNull(); + expect(status!.slots).toBeUndefined(); + }); + + it('parses an empty slots array as valid (zero free slots — never dispatches)', () => { + const status = parseLlamaStatus({ ...base, slots: [] }); + expect(status).not.toBeNull(); + expect(status!.slots).toEqual([]); + }); + + it('treats a non-array slots field as ambiguous → null (busy, fail-closed)', () => { + expect(parseLlamaStatus({ ...base, slots: 'not-an-array' })).toBeNull(); + expect(parseLlamaStatus({ ...base, slots: { slot_id: 'x', is_processing: false } })).toBeNull(); + }); + + it('treats an entry missing slot_id as malformed → null', () => { + expect(parseLlamaStatus({ ...base, slots: [{ is_processing: false }] })).toBeNull(); + }); + + it('treats an empty-string slot_id as malformed → null (zero-length guard preserved)', () => { + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: '', is_processing: false }] })).toBeNull(); + }); + + it('coerces numeric slot_ids to strings (proxy contract: int, WL-0MSVRMAWM007QNR5)', () => { + const status = parseLlamaStatus({ + ...base, + slots: [ + { slot_id: 0, is_processing: false }, + { slot_id: 3, is_processing: true }, + ], + }); + expect(status).not.toBeNull(); + expect(status!.slots).toEqual([ + { slot_id: '0', is_processing: false }, + { slot_id: '3', is_processing: true }, + ]); + }); + + it('clamps negative numeric slot_ids to 0 (WL-0MSVRMAWM007QNR5)', () => { + const status = parseLlamaStatus({ + ...base, + slots: [{ slot_id: -2, is_processing: false }], + }); + expect(status).not.toBeNull(); + expect(status!.slots).toEqual([{ slot_id: '0', is_processing: false }]); + }); + + it('treats a non-finite or non-integer numeric slot_id as malformed → null (fail-closed)', () => { + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: Number.NaN, is_processing: false }] })).toBeNull(); + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: Number.POSITIVE_INFINITY, is_processing: false }] })).toBeNull(); + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: 1.5, is_processing: false }] })).toBeNull(); + }); + + it('regression: parses the live proxy payload (integer slot_ids, WL-0MSVRMAWM007QNR5)', () => { + // observability.py serves `"slot_id": slot.get("id", i)` — an integer — + // per slot. Before the fix, parseLlamaStatus rejected the numeric + // slot_id and every 30s poll failed closed to busy — the Aug 15-16 + // zero-dispatch regression. Mirror the live payload exactly. + const livePayload = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + slots: [ + { slot_id: 0, is_processing: false }, + { slot_id: 1, is_processing: false }, + { slot_id: 2, is_processing: false }, + { slot_id: 3, is_processing: false }, + ], + }; + const status = parseLlamaStatus(livePayload); + expect(status).not.toBeNull(); + expect(status!.slots!.map((s) => s.slot_id)).toEqual(['0', '1', '2', '3']); + // The coerced payload is a valid idle status for the default N=0 AND + // for per-slot mode (0 < N < total) — dispatch can resume. + expect(evaluateIdle(status!, 0)).toBe(true); + expect(evaluateIdle(status!, 2)).toBe(true); + }); + + it('treats a non-boolean or missing is_processing as malformed → null', () => { + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: 'slot-1', is_processing: 'yes' }] })).toBeNull(); + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: 'slot-1' }] })).toBeNull(); + }); + + it('treats duplicate slot_ids as malformed → null', () => { + expect( + parseLlamaStatus({ + ...base, + slots: [ + { slot_id: 'slot-1', is_processing: false }, + { slot_id: 'slot-1', is_processing: true }, + ], + }), + ).toBeNull(); + }); +}); + +// ── Worker routing: per-slot mode vs fallback (WL-0MSG7P9N8009PCKG AC) ─ +describe('downtime worker per-slot routing (createDowntimeWorker)', () => { + function makePerSlotWorker(overrides: { + requiredFreeSlots?: number; + thresholdMs?: number; + status?: unknown; + deps?: Partial; + } = {}) { + const cfg = { + enabled: true, + thresholdMs: overrides.thresholdMs ?? DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: overrides.requiredFreeSlots ?? 2, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const fetcher = vi + .fn() + .mockResolvedValue(jsonResponseFixture(overrides.status ?? perSlotTwoFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('routes to per-slot mode: dispatches with only N of total slots free (same slots, full threshold)', async () => { + // perSlotTwoFree: 2 of 4 slots free. Legacy all-slots-free mode would + // NEVER dispatch (available 2 < 4); per-slot mode dispatches after the + // threshold because the SAME 2 slots have been free throughout. + const { worker, deps, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + // idleSince is null because slots 3,4 are processing — the title bar + // must show "busy", not "idle". Dispatch is unaffected (perSlotTracker + // still counts the 2 free slots for spare-capacity dispatch). + expect(worker.idleSince).toBeNull(); + + vi.setSystemTime(start + cfg.thresholdMs - 1); + const before = await worker.tick(); + expect(before.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('AC1 spare-capacity: 3 slots/1 busy with the operator session active (query+lease) still dispatches into the 2 free slots', async () => { + // parent ACL1: 2 of 3 slots continuously free for the full threshold → + // dispatch fires even though 1 slot is occupied by an active + // (query + lease) session — exactly the operator working in the TUI + // while the downtime worker uses the spare capacity. + const operatorSessionPayload = { + llama_server_running: true, + local_active_query: true, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 2, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + }; + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ + requiredFreeSlots: 2, + status: operatorSessionPayload, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // 2 free → per-slot idle run starts + // idleSince is null because slot-1 is processing — title bar shows + // "busy" even though dispatch fires into free slots. + expect(worker.idleSince).toBeNull(); + + vi.setSystemTime(start + cfg.thresholdMs - 1); + const before = await worker.tick(); + expect(before.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); // dispatch into the 2 free slots + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalled(); + }); + + it('AC2 spare-capacity: a mid-query (processing) busy slot does NOT reset the free slots\' timers', async () => { + // parent AC2: with N free of total in per-slot mode, idle is evaluated + // from the free slots only. A processing slot 1 does not reset slot-2 / + // slot-3 timers — dispatch still fires once the free slots have been + // continuously free for the full threshold (the busy slot is the + // operator's own mid-query session). + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // perSlotTwoFree (slot-1, slot-2 free) → timers start + + // slot-1 goes mid-query while slot-2 stays free; the global query signal + // also fires. Spare-capacity relaxation: only slot-1's timer resets. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotTwoFree)); + vi.setSystemTime(start + cfg.thresholdMs - 10_000); + const mid = await worker.tick(); + expect(mid.idle).toBe(true); // free count still ≥ N=2 (slot-1 busy) + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); // slot-2 ran the full threshold + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('AC3 tier minimums flow from the poll: 1 free slot skips the audit tier, plan dispatches (worker end-to-end)', async () => { + // N=1 with a single free slot: the idle gate is satisfied (1 ≥ N), but + // the AUDIT tier needs ≥2 free slots (parent WL-0MT32F90V008UAD2 AC3) — + // the worker must pass the polled free count into the tier selection so + // the audit candidate is skipped and the plan pane dispatches instead. + const oneFreePayload = { + llama_server_running: true, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 1, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: true }, + { slot_id: 'slot-3', is_processing: false }, + ], + }; + const { worker, deps, cfg } = makePerSlotWorker({ + requiredFreeSlots: 1, + status: oneFreePayload, + deps: { + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, + }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete', status: 'open' }, + }), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // 1 free slot ≥ N=1 → per-slot idle run starts + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + + // The worker saw freeSlots=1: the audit candidate was never consulted + // and the plan pane dispatched. + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(at.dispatched).toBe(true); + expect((deps.spawnAgentPane as ReturnType).mock.calls[0][0]).toContain('/skill:plan WL-PLAN'); + }); + + it('regression: a live proxy payload (integer slot_ids) idles and dispatches (WL-0MSVRMAWM007QNR5)', async () => { + // observability.py serves `"slot_id": slot.get("id", i)` — an integer — + // for each slot. Before the fix, parseLlamaStatus rejected the numeric + // slot_id, so every poll failed closed to busy and the worker never + // dispatched (the Aug 15-16 zero-dispatch regression). This mirrors the + // live payload end-to-end: poll → parse → idle run → threshold → dispatch. + const livePayload = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + slots: [ + { slot_id: 0, is_processing: false }, + { slot_id: 1, is_processing: false }, + { slot_id: 2, is_processing: false }, + { slot_id: 3, is_processing: false }, + ], + }; + const { worker, deps, cfg } = makePerSlotWorker({ status: livePayload }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince ?? null).not.toBeNull(); // idle run starts + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('a slot starting processing resets only its own timer: other slots keep their run and can dispatch', async () => { + // N=1. slot-1 goes processing at +10s while slot-2 stays free. The poll + // stays per-slot idle (free count 3 ≥ 1), slot-2's timer continues, and + // dispatch fires at the original threshold — not delayed by slot-1. + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 1 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // all 4 free → timers start + + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); + vi.setSystemTime(start + 10_000); + const mid = await worker.tick(); + expect(mid.idle).toBe(true); // free count 3 ≥ N=1 → still idle + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('a processing slot that must count toward N delays dispatch by its own fresh run', async () => { + // Per-slot mode with 4 slots, N=2: slot-1 is processing from +10s to + // +20s while slot-2 stays free. slot-1's OWN timer resets, so dispatch + // cannot fire at the original threshold (only slot-2 has a full + // continuous run); it fires only after slot-1's fresh run completes. + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // slot-1, slot-2 free → their timers start + + const fourSlots = (s1: boolean): LlamaStatus => ({ + ...perSlotAllFree, + available_slots: (s1 ? 0 : 1) + 1, // slot-2 free + slot-1 free + total_slots: 4, + slots: [ + { slot_id: 'slot-1', is_processing: s1 }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: true }, + { slot_id: 'slot-4', is_processing: true }, + ], + }); + fetcher.mockResolvedValueOnce(jsonResponseFixture(fourSlots(true))); + vi.setSystemTime(start + 10_000); + await worker.tick(); // slot-1 processing → its own timer resets + + fetcher.mockResolvedValueOnce(jsonResponseFixture(fourSlots(false))); + vi.setSystemTime(start + 20_000); + await worker.tick(); // slot-1 free again → restarts its run + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(false); // only slot-2 has a full continuous run + + vi.setSystemTime(start + 20_000 + cfg.thresholdMs); + const after = await worker.tick(); + expect(after.dispatched).toBe(true); // slot-1 completed its fresh run + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('a global busy poll resets ALL per-slot timers (fresh full idle period required)', async () => { + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // all free → timers start + + // model_switch_in_progress is a GLOBAL gate in per-slot mode (spare- + // capacity relaxation, parent WL-0MT32F90V008UAD2 AC2) — it resets + // every slot timer. A per-slot query/lease signal is NOT global + // anymore and is covered by the dedicated spare-capacity tests. + fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...perSlotAllFree, model_switch_in_progress: true })); + vi.setSystemTime(start + 10_000); + await worker.tick(); // global busy → every slot timer reset + + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + 20_000); + await worker.tick(); // free again → timers restart at +20s + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(false); // no slot has a full run since +20s + + vi.setSystemTime(start + 20_000 + cfg.thresholdMs); + const after = await worker.tick(); + expect(after.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('an ambiguous poll (null status) resets ALL per-slot timers (fail-closed)', async () => { + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // all free → timers start + + fetcher.mockResolvedValueOnce(jsonResponseFixture(null)); + vi.setSystemTime(start + 10_000); + await worker.tick(); // ambiguous → busy → every slot timer reset + + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + 20_000); + await worker.tick(); // free again → timers restart at +20s + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(false); + + vi.setSystemTime(start + 20_000 + cfg.thresholdMs); + const after = await worker.tick(); + expect(after.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('without usable per-slot data, fails closed (never dispatches on any-N availability)', async () => { + // No usable per-slot detail + 0 < N < total: the worker fails closed + // (WL-0MUXVPXAZ005RESW) — never dispatching on the untrusted count — + // and records the distinct `slots-unavailable` reason. + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ + requiredFreeSlots: 2, + status: { ...idleAllSlotsFree, slots: undefined, available_slots: 4 }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + const first = await worker.tick(); + expect(first.idle).toBe(false); + expect(worker.blockReason).toBe('no-slots'); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(fetcher).toHaveBeenCalled(); // still polling — just never dispatching + }); + + it('with per-slot data but N=0, falls back to all-slots-free (per-slot mode only when 0 < N < total)', async () => { + // Slots are present but N=0 (all slots): per-slot mode is NOT active — + // all-slots-free applies, so 2 of 4 free is busy and never dispatches. + const { worker, deps, cfg } = makePerSlotWorker({ + requiredFreeSlots: 0, + status: perSlotTwoFree, + }); + const start = 1_000_000; + vi.setSystemTime(start); + const first = await worker.tick(); + expect(first.idle).toBe(false); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + // ── Title bar idle display (parent WL-0MT65T14L002HTWB) ──────────── + // The global idle tracker (`worker.idleSince`) drives the title bar + // `[⏳ downtime idle Xs]` indicator. In per-slot mode the relaxed + // per-slot global gate deliberately ignores query/busy signals for + // DISPATCH; the DISPLAY must still reflect actual per-slot activity. + // idleSince (the display signal) is null whenever ANY slot is + // processing — the title bar shows `[downtime busy]` — while dispatch + // into free slots is unaffected. + + it('display: title bar shows busy when ANY slot is processing (incl. the operator\'s)', async () => { + // perSlotTwoFree: slots 3,4 processing. The title-bar display must show + // "busy" (idleSince null) even though the per-slot dispatch gate (2 + // free slots) is satisfied. + const { worker, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBeNull(); // → `[downtime busy]` in the title bar + + // Even after the dispatch threshold elapses, the display stays busy + // while a slot is processing (dispatch itself fires per usual). + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); // dispatch still fires (spare capacity) + expect(worker.idleSince).toBeNull(); // display never flips to idle mid-query + }); + + it('display: title bar shows idle ONLY when every slot is free', async () => { + // perSlotAllFree: all 4 slots free — no active processing anywhere, so + // the display can honestly show the idle counter. + const { worker, fetcher, cfg } = makePerSlotWorker({ + requiredFreeSlots: 2, + status: perSlotAllFree, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); // → `[⏳ downtime idle 0:00]` + + // Keep the status for the second tick (default is perSlotTwoFree which + // has processing slots). Check BELOW the dispatch threshold so the tick + // does not dispatch (which would reset idleSince by design). + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + cfg.thresholdMs - 10_000); + await worker.tick(); + expect(worker.idleSince).toBe(start); // run start stays fixed while free + }); + + it('display: a slot starting processing resets the idle counter; freeing it restarts a fresh run', async () => { + // Demonstrates that the display (idleSince) tracks actual per-slot + // processing state — completely independent of dispatch logic. + const { worker, fetcher, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // perSlotTwoFree: slots 3,4 processing → display busy + expect(worker.idleSince).toBeNull(); + + // All slots free → the idle counter starts. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + 10_000); + await worker.tick(); + expect(worker.idleSince).toBe(start + 10_000); // → `[⏳ downtime idle 0:00]` + + // slot-1 goes processing → display flips back to busy. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); + vi.setSystemTime(start + 20_000); + await worker.tick(); + expect(worker.idleSince).toBeNull(); // → `[downtime busy]` + + // Free slots (3) ≥ N=2 and their timers (since +10s) exceed threshold. + // Dispatch fires into the 3 free slots while display shows busy. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); + vi.setSystemTime(start + 20_000 + cfg.thresholdMs); + const at = await worker.tick(); + expect(worker.idleSince).toBeNull(); // display stays busy (slot still processing) + expect(at.dispatched).toBe(true); // dispatch fires (3 ≥ 2 free) + }); + + it('display: transition back to all-free after processing restarts the idle counter', async () => { + // After a busy period, the display should start counting again once + // all slots are free. + const { worker, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // perSlotTwoFree: slots 3,4 processing → busy + expect(worker.idleSince).toBeNull(); + + // All slots free → idle counter starts at current time. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + 10_000); + await worker.tick(); + expect(worker.idleSince).toBe(start + 10_000); + + // slot-1 goes processing → display resets to busy. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); + vi.setSystemTime(start + 20_000); + await worker.tick(); + expect(worker.idleSince).toBeNull(); + + // All slots free again → display should now count from +20s. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + 30_000); + await worker.tick(); + expect(worker.idleSince).toBe(start + 30_000); // fresh count from +30s + }); + + it('display: global busy (server down / model switch) also resets idleSince to busy', async () => { + const { worker, fetcher } = makePerSlotWorker({ + requiredFreeSlots: 2, + status: perSlotAllFree, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); + + // model_switch_in_progress is a GLOBAL gate — display shows busy. + fetcher.mockResolvedValueOnce( + jsonResponseFixture({ ...perSlotAllFree, model_switch_in_progress: true }), + ); + vi.setSystemTime(start + 10_000); + await worker.tick(); + expect(worker.idleSince).toBeNull(); // → `[downtime busy]` + }); +}); + +// ── Rotation-aware selection (WL-0MSSRED76008LGB6 / WL-0MSW6CKBY007RB7O) ── + +describe('selectWithRotation (round-robin tie-break)', () => { + const mk = (id: string, priority: string, sortIndex: number) => ({ + id, priority, sortIndex, + }); + + it('empty list → null', () => { + expect(selectWithRotation([])).toBeNull(); + }); + + it('single candidate → that candidate (no rotation)', () => { + const c = mk('a', 'high', 1); + expect(selectWithRotation([c])?.id).toBe('a'); + }); + + it('no registry → first by sortIndex (fail-open pre-rotation behaviour)', () => { + const items = [mk('a', 'high', 2), mk('b', 'high', 1)]; + expect(selectWithRotation(items)?.id).toBe('b'); // lowest sortIndex + }); + + it('candidates without priority → first by sortIndex (fail-open)', () => { + const items = [{ id: 'x', sortIndex: 5 }, { id: 'y', sortIndex: 3 }]; + expect(selectWithRotation(items)?.id).toBe('y'); + }); + + it('rotates through tied top-priority group, skipping lower priority', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-rotation', rng: () => 0.5 }); + const items = [ + mk('low1', 'low', 10), + mk('high1', 'high', 1), + mk('high2', 'high', 2), + mk('medium1', 'medium', 5), + ]; + // Cursor starts at 0 → selects high1; advances to 1 → high2; then back to high1 + expect(selectWithRotation(items, registry)?.id).toBe('high1'); + expect(selectWithRotation(items, registry)?.id).toBe('high2'); + expect(selectWithRotation(items, registry)?.id).toBe('high1'); + }); + + it('single-member top group → no rotation (higher priority still wins)', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-single', rng: () => 0.5 }); + const items = [mk('crit1', 'critical', 1), mk('high1', 'high', 2), mk('high2', 'high', 3)]; + // critical group has 1 member → always crit1, regardless of cursor + expect(selectWithRotation(items, registry)?.id).toBe('crit1'); + expect(selectWithRotation(items, registry)?.id).toBe('crit1'); + }); + + it('priority-first preserved: lower-priority candidate never selected while higher tier has members', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-priority', rng: () => 0.5 }); + const items = [ + mk('med1', 'medium', 10), + mk('med2', 'medium', 11), + mk('low1', 'low', 20), + ]; + // Only medium group has members → rotation within medium only + expect(selectWithRotation(items, registry)?.id).toBe('med1'); + expect(selectWithRotation(items, registry)?.id).toBe('med2'); + expect(selectWithRotation(items, registry)?.id).toBe('med1'); + }); +}); + +describe('rotation-aware selection wired into tiers', () => { + it('selectImplementCandidate rotates within tied priority when registry given', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-implement', rng: () => 0.5 }); + const candidates: ImplementCandidate[] = [ + { id: 'A', title: 'A', status: 'open', priority: 'high', sortIndex: 1, risk: 'low', effort: 'small' }, + { id: 'B', title: 'B', status: 'open', priority: 'high', sortIndex: 2, risk: 'low', effort: 'small' }, + ]; + expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('A'); + expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('B'); + expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('A'); + }); + + it('selectNextCandidate rotates within tied priority when registry given', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-next', rng: () => 0.5 }); + const candidates: DowntimeCandidate[] = [ + { id: 'X', title: 'X', stage: 'plan', status: 'open', priority: 'high', sortIndex: 1 }, + { id: 'Y', title: 'Y', stage: 'plan', status: 'open', priority: 'high', sortIndex: 2 }, + ]; + expect(selectNextCandidate(candidates, undefined, registry)?.id).toBe('X'); + expect(selectNextCandidate(candidates, undefined, registry)?.id).toBe('Y'); + }); + + it('selectAuditCandidate rotates within tied priority when registry given', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-audit', rng: () => 0.5 }); + const now = Date.now(); + const candidates: AuditCandidate[] = [ + { id: 'P', title: 'P', priority: 'high', sortIndex: 1, updatedAt: new Date(now).toISOString() }, + { id: 'Q', title: 'Q', priority: 'high', sortIndex: 2, updatedAt: new Date(now).toISOString() }, + ]; + expect(selectAuditCandidate(candidates, now, undefined, registry)?.id).toBe('P'); + expect(selectAuditCandidate(candidates, now, undefined, registry)?.id).toBe('Q'); + }); +}); + +// ── Probe jitter (WL-0MSSRED76008LGB6 / WL-0MSW6DEI9005XH8Q) ────────── + +describe('worker probe jitter (jitterPollIntervalMs)', () => { + it('returns jittered interval within ±50% when a registry is provided', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-deps', rng: () => 0.75 }); + const { worker } = (() => { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const w = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + registry, + config: () => ({ ...cfg }), + }); + return { worker: w }; + })(); + // rng 0.75 → factor 1.25 → 10_000 * 1.25 = 12_500 + expect(worker.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(12_500); + }); + + it('returns the static interval when no registry is provided (fail-open)', () => { + const { worker } = (() => { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const w = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ ...cfg }), + }); + return { worker: w }; + })(); + expect(worker.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(10_000); + }); + + it('two workers with different RNG produce different jitter values', () => { + const r1 = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-a', rng: () => 0.1 }); + const r2 = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-b', rng: () => 0.9 }); + const mk = (registry: typeof r1) => createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + registry, + config: () => ({ enabled: true, thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, requiredFreeSlots: 0, model: 'plan', cwd: '/repo', noCandidateCooldownMs: 3_600_000 }), + }); + const w1 = mk(r1); + const w2 = mk(r2); + // rng 0.1 → 0.6× = 6_000; rng 0.9 → 1.4× = 14_000 + expect(w1.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(6_000); + expect(w2.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(14_000); + expect(w1.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).not.toBe(w2.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)); + }); +}); diff --git a/packages/herdr/src/downtime-worker.pane.test.ts b/packages/herdr/src/downtime-worker.pane.test.ts new file mode 100644 index 000000000..d4aec9900 --- /dev/null +++ b/packages/herdr/src/downtime-worker.pane.test.ts @@ -0,0 +1,1361 @@ +/** + * packages/herdr/src/downtime-worker.pane.test.ts — pane spawn, pane-record + * helpers and pane-lifecycle tests + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4VHFM3003IP1Y) to keep each test file focused + * on one concern. + * + * Covers the pane-related surface: + * - `downtime pane spawn (send-to-pi.sh)` — pane arg/option building and + * the injectable spawn boundary, + * - `parseHerdrPaneListOutput / countRunningDowntimePanes (AC1)` — pane-list + * parsing, + * - `paneLabelItemId / runningDowntimePaneItemIds / evaluateCriticalFirstGuard` + * — item-scoped in-flight guard helpers, + * - `dispatchDowntimeWork: running-pane / owner / contention gates`, + * - `worker tick: the LLM idle check is the sole concurrency limiter` — + * including the `onProxyIdle` callback, + * - `pane-lifecycle monitor (WL-0MU308WSF0002JWN) [disabled behaviour]`, and + * - `worker tick pane-lifecycle integration (WL-0MU4US5MP001JFEN) [disabled]`. + * + * Shared scaffolding (`makeDeps`, temp-dir trackers, status fixtures) is + * imported from `downtime-worker.fixtures.ts`; no setup logic is + * re-implemented here. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + buildDowntimePaneArgs, + spawnDowntimePane, + buildDowntimeSpawnOptions, + scrubRepositoryOverrides, + REPOSITORY_OVERRIDE_ENV_VARS, + parseHerdrPaneListOutput, + countRunningDowntimePanes, + paneLabelItemId, + isSafeDowntimePaneToClose, + runningDowntimePaneItemIds, + evaluateCriticalFirstGuard, + resolveInFlightPanes, + monitorDispatchedPanes, + dispatchDowntimeWork, + createDowntimePoller, + createDowntimeWorker, + DOWNTIME_PANE_LABEL_PREFIX, + type LlamaStatus, + type DowntimeWorkerDeps, + type DowntimeSpawn, + type HerdrPaneRecord, + type InFlightPanes, +} from './downtime-worker.js'; +import { + appendDowntimeLogEntry, + appendPaneCloseLogEntry, + readDowntimeLogEntries, +} from './downtime-log.js'; +import { + herdrPaneListRaw, + idleAllSlotsFree, + busyActiveQuery, + idleWithContention, + idleWithCumulativeContention, + perSlotThreeOfFourFree, + jsonResponseFixture, + makeDeps, + createTempDirTracker, +} from './downtime-worker.fixtures.js'; + +// ── Pane spawn (AC4) ────────────────────────────────────────────────── + +describe('downtime pane spawn (send-to-pi.sh)', () => { + it('builds --pane-name / --no-focus / --cwd / --model args for a plan dispatch', () => { + const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toEqual([ + '--pane-name', + 'Downtime plan', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + 'Run /skill:plan WL-ABC — Some task.', + ]); + }); + + it('uses the intake pane name and forwards the configured model', () => { + const args = buildDowntimePaneArgs('intake', 'Run /skill:intake WL-DEF', { + model: 'code', + cwd: '/repo', + }); + expect(args).toContain('Downtime intake'); + expect(args).toContain('code'); + expect(args).not.toContain('Downtime plan'); + }); + + it('spawns via the injectable spawn and unrefs the child', async () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn(), once: vi.fn() })) as unknown as DowntimeSpawn; + + const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { cwd: '/repo' }, spawnFn); + + expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { + cwd: '/repo', + }); + const handle = (spawnFn as ReturnType).mock.results[0].value; + expect(handle.unref).toHaveBeenCalled(); + expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); + expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); + // No error/exit within the probe window → the pane is assumed opened. + expect(spawned).toEqual({ ok: true }); + }); + + it('resolves {ok:false} with the error trace on a handled spawn error (no unhandled-exception crash)', async () => { + const handle = { + unref: vi.fn(), + once: vi.fn((event: string, listener: (arg: unknown) => void) => { + if (event === 'error') listener(new Error('ENOENT: send-to-pi.sh')); + }), + }; + const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; + + const spawned = await spawnDowntimePane('/path/to/missing.sh', [], { cwd: '/repo' }, spawnFn); + + expect(spawned).toEqual({ ok: false, error: 'ENOENT: send-to-pi.sh' }); + expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); + expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); + }); + + it('resolves {ok:false} with the exit trace on a non-zero script exit within the probe window', async () => { + const handle = { + unref: vi.fn(), + once: vi.fn((event: string, listener: (arg: unknown) => void) => { + if (event === 'exit') listener(1); + }), + }; + const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; + + const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo' }, spawnFn); + + expect(spawned).toEqual({ ok: false, exitCode: 1 }); + }); + + it('treats a signal-killed child (exit code null) as a failure', async () => { + const handle = { + unref: vi.fn(), + once: vi.fn((event: string, listener: (arg: unknown) => void) => { + if (event === 'exit') listener(null); + }), + }; + const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; + + const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo' }, spawnFn); + + expect(spawned).toEqual({ ok: false, exitCode: null }); + }); + + it('resolves {ok:true} when the script exits 0 (pane opened — success path unchanged)', async () => { + const handle = { + unref: vi.fn(), + once: vi.fn((event: string, listener: (arg: unknown) => void) => { + if (event === 'exit') listener(0); + }), + }; + const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; + + const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo' }, spawnFn); + + expect(spawned).toEqual({ ok: true }); + }); + + it('buildDowntimeSpawnOptions uses detached/ignore options with the resolved cwd', () => { + const options = buildDowntimeSpawnOptions('/repo'); + expect(options).toEqual({ + detached: true, + stdio: 'ignore', + cwd: '/repo', + env: expect.objectContaining({ HERDR_RESOLVED_CWD: '/repo' }), + }); + }); + + it('buildDowntimeSpawnOptions bounds the dispatched audit fan-out (AUDIT_PHASE2_PARALLELISM=1)', () => { + const options = buildDowntimeSpawnOptions('/repo'); + // Parent audit + at most one sequential child deep-analysis call fits + // cheap mode's 2 local slots (WL-0MSORQ1RG005DGUS). + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + }); + + it('buildDowntimeSpawnOptions scrubs repository-override git vars (WL-0MV0TZEWZ003ZXEB)', () => { + const saved = new Map(); + for (const key of REPOSITORY_OVERRIDE_ENV_VARS) { + saved.set(key, process.env[key]); + process.env[key] = `/tmp/leaked/${key}`; + } + try { + const options = buildDowntimeSpawnOptions('/repo'); + for (const key of REPOSITORY_OVERRIDE_ENV_VARS) { + expect(options.env[key]).toBeUndefined(); + } + // The scrub must not discard unrelated forwarding. + expect(options.env.HERDR_RESOLVED_CWD).toBe('/repo'); + } finally { + for (const key of REPOSITORY_OVERRIDE_ENV_VARS) { + const previous = saved.get(key); + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + } + }); + + it('scrubRepositoryOverrides removes only override vars and never mutates its input', () => { + const input = { + PATH: '/usr/bin', + GIT_DIR: '/leak/.git', + GIT_WORK_TREE: '/leak', + HERDR_ENV: '1', + }; + + const cleaned = scrubRepositoryOverrides(input); + + expect(cleaned).toEqual({ PATH: '/usr/bin', HERDR_ENV: '1' }); + // Input mapping is untouched. + expect(input.GIT_DIR).toBe('/leak/.git'); + expect(input.GIT_WORK_TREE).toBe('/leak'); + }); + + // ── Mode-aware PARALLELISM (WL-0MT50S9JW001DHME) ────────────────── + + it('buildDowntimeSpawnOptions defaults to PARALLELISM=1 when config is absent', () => { + // Backward compatibility: no config → '1' + const options = buildDowntimeSpawnOptions('/repo'); + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + + const optionsWithEmptyConfig = buildDowntimeSpawnOptions('/repo', { config: undefined }); + expect(optionsWithEmptyConfig.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + }); + + it('buildDowntimeSpawnOptions returns PARALLELISM=1 in fast mode even with free slots', () => { + const options = buildDowntimeSpawnOptions('/repo', { + config: { mode: 'fast', slotBudget: 3, freeSlots: 3, concurrentDispatchCap: 1 }, + }); + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + }); + + it('buildDowntimeSpawnOptions returns PARALLELISM=2 for cheap mode with a genuinely free second slot (single-flight)', () => { + // Cheap mode, 2 slots, both genuinely free, single dispatch at a time → + // 2 children = 2 streams total, fitting the 2-slot pool. + const options = buildDowntimeSpawnOptions('/repo', { + config: { mode: 'cheap', slotBudget: 2, freeSlots: 2, concurrentDispatchCap: 1 }, + }); + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('2'); + }); + + it('buildDowntimeSpawnOptions returns PARALLELISM=1 when the second slot is NOT genuinely free', () => { + // The operative signal is freeSlots, never the total slotBudget: a 2-slot + // pool with only 1 free slot cannot run 2 children. + const options = buildDowntimeSpawnOptions('/repo', { + config: { mode: 'cheap', slotBudget: 2, freeSlots: 1, concurrentDispatchCap: 1 }, + }); + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + }); + + it('buildDowntimeSpawnOptions returns PARALLELISM=1 when the dispatch budget is unbounded (cap 0)', () => { + // 0 = unbounded → a second audit could run → 2 audits × 2 children would + // exceed the 2-slot cheap pool. Only cap === 1 is single-flight-safe. + const options = buildDowntimeSpawnOptions('/repo', { + config: { mode: 'cheap', slotBudget: 2, freeSlots: 2, concurrentDispatchCap: 0 }, + }); + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + }); + + it('buildDowntimeSpawnOptions returns PARALLELISM=1 when concurrent dispatch budget would exceed slot capacity', () => { + // 2+ concurrent dispatches × 2 children each = 4 streams, exceeds 2-slot budget + const options = buildDowntimeSpawnOptions('/repo', { + config: { mode: 'cheap', slotBudget: 2, freeSlots: 2, concurrentDispatchCap: 2 }, + }); + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + }); + + it('buildDowntimeSpawnOptions returns PARALLELISM=1 when cheap mode slot budget is insufficient', () => { + // Only 1 slot in the pool — cannot run 2 children + const options = buildDowntimeSpawnOptions('/repo', { + config: { mode: 'cheap', slotBudget: 1, freeSlots: 1, concurrentDispatchCap: 1 }, + }); + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + }); + + it('buildDowntimeSpawnOptions returns PARALLELISM=1 for cheap mode with high dispatch cap', () => { + // 3+ concurrent dispatches × 2 children = 6 streams, far exceeds budget + const options = buildDowntimeSpawnOptions('/repo', { + config: { mode: 'cheap', slotBudget: 2, freeSlots: 2, concurrentDispatchCap: 3 }, + }); + expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); + }); + + it('spawnDowntimePane forwards the spawn config to the spawn boundary (WL-0MT50S9JW001DHME)', async () => { + const handle = { + unref: vi.fn(), + once: vi.fn((event: string, listener: (arg: unknown) => void) => { + if (event === 'exit') listener(0); + }), + }; + const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; + const config = { + mode: 'cheap' as const, + slotBudget: 2, + freeSlots: 2, + concurrentDispatchCap: 1, + }; + + await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo', config }, spawnFn); + + expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', [], { cwd: '/repo', config }); + }); +}); + +describe('parseHerdrPaneListOutput / countRunningDowntimePanes (AC1)', () => { + it('parses the herdr pane list envelope', () => { + const panes = parseHerdrPaneListOutput(herdrPaneListRaw); + expect(panes).not.toBeNull(); + expect(panes!.length).toBe(5); + expect(panes![0].paneId).toBe('w1:p1'); + expect(panes![0].label).toContain('Downtime triggered'); + }); + + it('counts only live downtime panes (excludes manual panes and done agents)', () => { + const panes = parseHerdrPaneListOutput(herdrPaneListRaw)!; + const running = countRunningDowntimePanes(panes); + expect(running).toEqual(['w1:p1', 'w1:p2']); + expect(DOWNTIME_PANE_LABEL_PREFIX).toBe('Downtime'); + }); + + it('returns null for unparseable output (an unparseable probe is no longer a dispatch gate)', () => { + expect(parseHerdrPaneListOutput('not json at all')).toBeNull(); + }); + + it('tolerates log lines before the JSON envelope', () => { + const panes = parseHerdrPaneListOutput(`[herdr] starting\n${herdrPaneListRaw}`); + expect(panes).not.toBeNull(); + expect(countRunningDowntimePanes(panes!)).toHaveLength(2); + }); + + it('exposes cwd and agent_session on parsed records (WL-0MUYMBMCG000Z4WP AC1)', () => { + const raw = JSON.stringify({ + result: { + panes: [ + { + pane_id: 'w2Y:p87', + label: 'Manually triggered intake - WL-ABC', + agent: 'pi', + agent_status: 'working', + cwd: '/home/u/projects/OtherRoot', + agent_session: { kind: 'path', value: '/tmp/s.jsonl' }, + }, + ], + }, + }); + const panes = parseHerdrPaneListOutput(raw)!; + expect(panes[0].cwd).toBe('/home/u/projects/OtherRoot'); + expect(panes[0].agentSession).toEqual({ kind: 'path', value: '/tmp/s.jsonl' }); + }); + + it('omits cwd / agent_session on missing or malformed fields, without throwing (WL-0MUYMBMCG000Z4WP AC3)', () => { + const raw = JSON.stringify({ + panes: [ + { pane_id: 'p1', cwd: 7, agent_session: ['x'] }, + { pane_id: 'p2' }, + ], + }); + const panes = parseHerdrPaneListOutput(raw)!; + expect(panes).toHaveLength(2); + expect(panes[0].cwd).toBeUndefined(); + expect(panes[0].agentSession).toBeUndefined(); + expect(panes[1].cwd).toBeUndefined(); + expect(panes[1].agentSession).toBeUndefined(); + }); + + it('shares one parse contract: bare arrays and bracketed log prefixes parse (WL-0MUYMBMCG000Z4WP AC3)', () => { + const bare = JSON.stringify([{ pane_id: 'p1', cwd: '/r' }]); + expect(parseHerdrPaneListOutput(bare)![0].cwd).toBe('/r'); + const prefixed = '[herdr] starting\n' + JSON.stringify({ panes: [{ pane_id: 'p1' }] }); + expect(parseHerdrPaneListOutput(prefixed)![0].paneId).toBe('p1'); + }); +}); + +// ── Item-scoped in-flight guard helpers (F3 WL-0MUBVKXQJ000L8EO) ──────── +// The guard must be ITEM-SCOPED (never a global running-pane count, which +// would reintroduce the WL-0MU2EP6JL006A1U3 stall) and must only treat a +// `working` agent as in-flight (terminal/idle panes must not deadlock the +// item's next tier). + +describe('paneLabelItemId / runningDowntimePaneItemIds / evaluateCriticalFirstGuard (WL-0MUBEZ6PE002WLP4)', () => { + const pane = (overrides: Partial & { paneId: string }): HerdrPaneRecord => ({ + label: 'Downtime triggered implement Some item - WL-ABC', + agent: 'pi', + agentStatus: 'working', + ...overrides, + }); + + it('paneLabelItemId extracts the trailing work-item id (buildDowntimePaneTitle suffix contract)', () => { + expect(paneLabelItemId('Downtime triggered implement Some item - WL-ABC')).toBe('WL-ABC'); + // A title containing ` - ` earlier still yields the LAST segment. + expect(paneLabelItemId('Downtime triggered plan A - B - WL-XYZ')).toBe('WL-XYZ'); + // No suffix → null (manual pane). + expect(paneLabelItemId('Downtime intake')).toBeNull(); + expect(paneLabelItemId(undefined)).toBeNull(); + }); + + it('isSafeDowntimePaneToClose only accepts the downtime pane for the SAME item (AC7)', () => { + const live = [ + pane({ paneId: 'w1:p1', label: 'Downtime triggered implement Some item - WL-ABC' }), + pane({ paneId: 'w1:p2', label: 'pi - my operator session' }), + ]; + // Matching downtime pane for the item → safe. + expect(isSafeDowntimePaneToClose(live, 'w1:p1', 'WL-ABC')).toBe(true); + // A different item's downtime pane (id collision) → NOT safe. + expect(isSafeDowntimePaneToClose(live, 'w1:p1', 'WL-OTHER')).toBe(false); + // A non-downtime (operator) pane → NOT safe. + expect(isSafeDowntimePaneToClose(live, 'w1:p2', 'WL-ABC')).toBe(false); + // The pane is already gone → NOT safe (nothing to close). + expect(isSafeDowntimePaneToClose(live, 'w1:p9', 'WL-ABC')).toBe(false); + }); + + it('runningDowntimePaneItemIds returns only WORKING downtime panes, keyed by item id', () => { + const ids = runningDowntimePaneItemIds([ + pane({ paneId: 'w1:p1', label: 'Downtime triggered implement A - WL-A', agentStatus: 'working' }), + // terminal → excluded + pane({ paneId: 'w1:p2', label: 'Downtime triggered implement B - WL-B', agentStatus: 'done' }), + pane({ paneId: 'w1:p3', label: 'Downtime triggered implement C - WL-C', agentStatus: 'exited' }), + // idle/unknown → NOT working → excluded (no deadlock) + pane({ paneId: 'w1:p4', label: 'Downtime triggered implement D - WL-D', agentStatus: 'idle' }), + // manual pane (no downtime prefix) → excluded + pane({ paneId: 'w1:p5', label: 'Work Items', agentStatus: 'working' }), + // downtime pane with no agent → excluded + pane({ paneId: 'w1:p6', label: 'Downtime plan E - WL-E', agent: undefined, agentStatus: 'working' }), + ]); + expect([...ids]).toEqual(['WL-A']); + }); + + it('evaluateCriticalFirstGuard: live working pane → skip (in-flight-pane), regardless of marker freshness', () => { + const inFlight: InFlightPanes = { available: true, itemIds: new Set(['WL-ABC']) }; + const d = evaluateCriticalFirstGuard('WL-ABC', inFlight, new Date().toISOString(), Date.now(), 3_600_000); + expect(d).toEqual({ escalate: false, reason: 'in-flight-pane' }); + }); + + it('evaluateCriticalFirstGuard: query succeeded and found no pane → escalate (no-live-pane), even with a fresh marker', () => { + const inFlight: InFlightPanes = { available: true, itemIds: new Set() }; + const d = evaluateCriticalFirstGuard('WL-ABC', inFlight, new Date().toISOString(), Date.now(), 3_600_000); + expect(d).toEqual({ escalate: true, reason: 'no-live-pane' }); + }); + + it('evaluateCriticalFirstGuard: query unavailable + fresh marker → skip (in-flight-unverified)', () => { + const inFlight: InFlightPanes = { available: false, itemIds: new Set() }; + const d = evaluateCriticalFirstGuard('WL-ABC', inFlight, new Date().toISOString(), Date.now(), 3_600_000); + expect(d).toEqual({ escalate: false, reason: 'in-flight-unverified' }); + }); + + it('evaluateCriticalFirstGuard: query unavailable + stale/absent marker → escalate (bounded, never starved)', () => { + const inFlight: InFlightPanes = { available: false, itemIds: new Set() }; + const now = Date.now(); + expect(evaluateCriticalFirstGuard('WL-ABC', inFlight, new Date(now - 2 * 3_600_000).toISOString(), now, 3_600_000)) + .toEqual({ escalate: true, reason: 'marker-stale-escalation' }); + expect(evaluateCriticalFirstGuard('WL-ABC', inFlight, undefined, now, 3_600_000)) + .toEqual({ escalate: true, reason: 'marker-stale-escalation' }); + }); + + it('resolveInFlightPanes: unavailable dep / thrown resolver / no records all resolve unavailable (never throws)', async () => { + expect(await resolveInFlightPanes({}, '/repo')).toEqual({ available: false, itemIds: new Set() }); + expect(await resolveInFlightPanes( + { getRunningDowntimePanes: vi.fn().mockRejectedValue(new Error('boom')) }, + '/repo', + )).toEqual({ available: false, itemIds: new Set() }); + expect(await resolveInFlightPanes( + { getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: false, error: 'down' }) }, + '/repo', + )).toEqual({ available: false, itemIds: new Set() }); + // Legacy result with only count/paneIds → unavailable (a bare count must + // never be used as an item-scoped signal). + expect(await resolveInFlightPanes( + { getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 2, paneIds: ['a', 'b'] }) }, + '/repo', + )).toEqual({ available: false, itemIds: new Set() }); + }); + + it('resolveInFlightPanes: an available query with records resolves the working item ids', async () => { + const result = await resolveInFlightPanes( + { + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, count: 1, paneIds: ['w1:p1'], + records: [pane({ paneId: 'w1:p1', label: 'Downtime triggered implement A - WL-A', agentStatus: 'working' })], + }), + }, + '/repo', + ); + expect(result.available).toBe(true); + expect([...result.itemIds]).toEqual(['WL-A']); + }); +}); + +describe('dispatchDowntimeWork: running-pane / owner / contention gates', () => { + function dispatchableDeps() { + return makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + } + + it('dispatches without consulting any live-pane count (WL-0MU2EP6JL006A1U3)', async () => { + // Regression: panes deliberately stay open until an operator closes them, + // so the number of live panes must never gate dispatch. + const deps = dispatchableDeps(); + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd: '/repo', + }); + expect(outcome.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('refuses an owned slot (slot-owned)', async () => { + const deps = dispatchableDeps(); + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd: '/repo', + slotOwned: true, + }); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('slot-owned'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('backs off while the proxy reports contention (proxy-contention)', async () => { + const deps = dispatchableDeps(); + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd: '/repo', + contentionQueueDepth: 3, + }); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('proxy-contention'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + +}); + +describe('worker tick: the LLM idle check is the sole concurrency limiter (WL-0MU2EP6JL006A1U3)', () => { + function makeSingleSlotWorker(opts: { + status: LlamaStatus; + runningPanes: () => { ok: true; count: number } | { ok: false; error?: string }; + requiredFreeSlots?: number; + }) { + const cfg = { + enabled: true, + thresholdMs: 1_000, + requiredFreeSlots: opts.requiredFreeSlots ?? 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const poller = createDowntimePoller( + 'http://proxy:8000', + vi.fn().mockResolvedValue(jsonResponseFixture(opts.status)), + ); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + getRunningDowntimePanes: vi.fn().mockImplementation(async () => opts.runningPanes()), + }); + const worker = createDowntimeWorker({ poller, deps, config: () => ({ ...cfg }) }); + return { worker, deps }; + } + + it('live dispatch panes do not block dispatch while the LLM is idle (WL-0MU2EP6JL006A1U3)', async () => { + vi.useFakeTimers(); + try { + // Regression for the overnight stall: previously dispatched panes stay + // OPEN until an operator closes them, so a live-pane count must never be + // a dispatch gate. The LLM is idle and several panes are open → the next + // item still dispatches. + const { worker, deps } = makeSingleSlotWorker({ + status: idleAllSlotsFree, + runningPanes: () => ({ ok: true, count: 3 }), + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const outcome = await worker.tick(); + expect(outcome.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('a failed liveness query does not stall dispatch (WL-0MU2EP6JL006A1U3)', async () => { + vi.useFakeTimers(); + try { + // The pane-liveness probe now only feeds the owner-lease qualifier, so a + // `herdr pane list` failure must not silently stop the dispatcher. The + // proxy's own lease signal still gates slot capacity. + const { worker, deps } = makeSingleSlotWorker({ + status: idleAllSlotsFree, + runningPanes: () => ({ ok: false, error: 'herdr down' }), + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const outcome = await worker.tick(); + expect(outcome.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('an idle-but-owned slot prevents dispatch (AC2/AC3)', async () => { + vi.useFakeTimers(); + try { + // Single slot, idle by is_processing, but a live lease is held AND the + // slot is owned by a live pane → the owner gate refuses the dispatch. + const { worker, deps } = makeSingleSlotWorker({ + status: { + ...idleAllSlotsFree, + available_slots: 1, + total_slots: 1, + local_lease_active: true, + local_owner_session_id: 'session-live', + local_owner_lease_remaining_seconds: 120, + slots: [ + { slot_id: 'slot-1', is_processing: false, owner_session_id: 'session-live' }, + ], + }, + runningPanes: () => ({ ok: true, count: 1 }), + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const blocked = await worker.tick(); + expect(blocked.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('backs off while the proxy reports contention (AC6)', async () => { + vi.useFakeTimers(); + try { + const { worker, deps } = makeSingleSlotWorker({ + status: idleWithContention, + runningPanes: () => ({ ok: true, count: 0 }), + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const blocked = await worker.tick(); + expect(blocked.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('dispatches when only the cumulative contention counter is non-zero (depth 0, WL-0MU1DWXO600153OI)', async () => { + vi.useFakeTimers(); + try { + // Regression: `contention_queued_count` is cumulative and stays > 0 + // after any past queue event; it must never gate dispatch. With the + // live depth at 0 the worker dispatches. + const { worker, deps } = makeSingleSlotWorker({ + status: idleWithCumulativeContention, + runningPanes: () => ({ ok: true, count: 0 }), + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const result = await worker.tick(); + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('does not block operator spare-capacity dispatch when no dispatch pane is running', async () => { + vi.useFakeTimers(); + try { + // Operator holds a lease on a 3-slot setup (one slot processing) while + // the worker has NO running pane → spare-capacity dispatch still fires + // into the free slots. + const { worker, deps } = makeSingleSlotWorker({ + status: perSlotThreeOfFourFree, + runningPanes: () => ({ ok: true, count: 0 }), + requiredFreeSlots: 2, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const result = await worker.tick(); + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + // ── onProxyIdle callback (WL-0MU4MKVR4005WPBJ) ──────────────────────── + + describe('onProxyIdle callback', () => { + it('calls the callback with the proxy status when the proxy is idle', async () => { + vi.useFakeTimers(); + try { + const callback = vi.fn().mockResolvedValue(undefined); + const poller = createDowntimePoller( + 'http://proxy:8000', + async () => ({ + ok: true, + status: 200, + json: async () => idleAllSlotsFree, + }), + ); + const worker = createDowntimeWorker({ + poller, + deps: makeDeps(), + config: () => ({ + enabled: true, + thresholdMs: 0, // instant dispatch + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + browseItemCount: 20, + }), + onProxyIdle: callback, + }); + vi.setSystemTime(1_000_000); + const result = await worker.tick(); + expect(result.idle).toBe(true); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(idleAllSlotsFree); + } finally { + vi.useRealTimers(); + } + }); + + it('does not call the callback when the proxy is busy', async () => { + vi.useFakeTimers(); + try { + const callback = vi.fn().mockResolvedValue(undefined); + const poller = createDowntimePoller( + 'http://proxy:8000', + async () => ({ + ok: true, + status: 200, + json: async () => busyActiveQuery, + }), + ); + const worker = createDowntimeWorker({ + poller, + deps: makeDeps(), + config: () => ({ + enabled: true, + thresholdMs: 0, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + browseItemCount: 20, + }), + onProxyIdle: callback, + }); + vi.setSystemTime(1_000_000); + const result = await worker.tick(); + expect(result.dispatched).toBe(false); + expect(result.idle).toBe(false); + expect(callback).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('calls the callback when the proxy is idle but threshold not met', async () => { + vi.useFakeTimers(); + try { + const callback = vi.fn().mockResolvedValue(undefined); + const poller = createDowntimePoller( + 'http://proxy:8000', + async () => ({ + ok: true, + status: 200, + json: async () => idleAllSlotsFree, + }), + ); + const worker = createDowntimeWorker({ + poller, + deps: makeDeps(), + config: () => ({ + enabled: true, + thresholdMs: 60_000, // 60 s threshold — not yet met + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + browseItemCount: 20, + }), + onProxyIdle: callback, + }); + vi.setSystemTime(1_000_000); + const result = await worker.tick(); + expect(result.dispatched).toBe(false); + expect(result.idle).toBe(true); // proxy is idle, just threshold not met + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(idleAllSlotsFree); + } finally { + vi.useRealTimers(); + } + }); + + it('fires the callback BEFORE dispatching the next item (ordering)', async () => { + vi.useFakeTimers(); + try { + // The operator requirement (WL-0MU4MKVR4005WPBJ): the mode-switch + // check must run before a new item is sent, so the item is served by + // the (possibly newly) cheap pool. Record the invocation order. + const order: string[] = []; + const callback = vi.fn().mockImplementation(async () => { + order.push('onProxyIdle'); + }); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + spawnAgentPane: vi.fn().mockImplementation(async () => { + order.push('spawn'); + return { ok: true }; + }), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 0 }), + }); + const poller = createDowntimePoller( + 'http://proxy:8000', + vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)), + ); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ + enabled: true, + thresholdMs: 1_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + browseItemCount: 20, + }), + onProxyIdle: callback, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // starts the idle run + vi.setSystemTime(start + 5_000); + const outcome = await worker.tick(); // idle + ready → dispatch + expect(outcome.dispatched).toBe(true); + // The callback fires on EVERY idle poll (the mode-switch worker has + // its own operator-idle window), so both ticks trigger it. + expect(callback).toHaveBeenCalledTimes(2); + // The check fires before the item is dispatched. + expect(order).toEqual(['onProxyIdle', 'onProxyIdle', 'spawn']); + } finally { + vi.useRealTimers(); + } + }); + + it('does not crash when the callback throws', async () => { + vi.useFakeTimers(); + try { + const callback = vi.fn().mockRejectedValue(new Error('callback failed')); + const poller = createDowntimePoller( + 'http://proxy:8000', + async () => ({ + ok: true, + status: 200, + json: async () => idleAllSlotsFree, + }), + ); + const worker = createDowntimeWorker({ + poller, + deps: makeDeps(), + config: () => ({ + enabled: true, + thresholdMs: 0, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + browseItemCount: 20, + }), + onProxyIdle: callback, + }); + vi.setSystemTime(1_000_000); + const result = await worker.tick(); + // Callback rejected but the worker did not crash or block + expect(result.idle).toBe(true); + expect(callback).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + }); +}); + +// ── Pane-lifecycle monitor (WL-0MU308WSF0002JWN) — DISABLED ───────── +// +// Mechanism B is switched off in the worker (WL-0MUMEKDK0008LKH8): +// `PANE_LIFECYCLE_MONITOR_ENABLED` is false, so the tick never calls +// `monitorDispatchedPanes` (pinned by the tick integration suite below). +// These unit tests are RETAINED to pin the pure classifier contract — what +// the monitor WOULD do if re-enabled: it inspects every dispatched pane +// recorded in the rolling log (marker + post-spawn enrichment), classifies +// its lifecycle outcome, logs a pane-close entry, and closes the pane — +// EXCEPT `implement` panes, which are never auto-closed (AC6). Every boundary +// is fail-closed (AC7). +describe('pane-lifecycle monitor (WL-0MU308WSF0002JWN) [disabled behaviour]', () => { + const roots = createTempDirTracker(); + + function makeRoot(): string { + return roots.make('pane-lifecycle-'); + } + + afterEach(() => { + roots.cleanup(); + }); + + /** Seed a dispatch marker + its post-spawn enrichment (paneId) entry. */ + async function seedDispatch( + root: string, + opts: { itemId: string; kind: string; paneId: string; stage?: string; title?: string }, + ): Promise { + const marker = { + itemId: opts.itemId, + kind: opts.kind, + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd: root, + title: opts.title ?? opts.itemId, + stage: opts.stage ?? 'idea', + }; + await appendDowntimeLogEntry(root, JSON.stringify(marker)); + await appendDowntimeLogEntry(root, JSON.stringify({ ...marker, paneId: opts.paneId, enrichment: true })); + } + + /** Deps with a real (rolling-log-appending) recordPaneClose so idempotency works. */ + function monitorDeps(overrides: Partial = {}): DowntimeWorkerDeps { + return makeDeps({ + recordPaneClose: vi.fn(async (entry, cwd) => { + await appendPaneCloseLogEntry(cwd, entry); + }), + ...overrides, + }); + } + + async function paneCloseEntries(root: string) { + return (await readDowntimeLogEntries(root)).filter((e) => e.entryType === 'pane-close'); + } + + it('closes an intake pane and logs closed-as-intake-complete when the item reaches intake_complete', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-1', kind: 'intake', paneId: 'w1:p1', stage: 'idea', title: 'Intake me' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-1', title: 'Intake me', stage: 'intake_complete' }), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 1, records: [{ paneId: 'w1:p1' }] }), + closePane, + }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ checked: 1, logged: 1, closed: 1, errors: 0 }); + expect(closePane).toHaveBeenCalledWith('w1:p1', root, { itemId: 'WL-1', kind: 'intake' }); + const pc = await paneCloseEntries(root); + expect(pc).toHaveLength(1); + expect(pc[0]).toMatchObject({ + entryType: 'pane-close', + itemId: 'WL-1', + itemTitle: 'Intake me', + paneId: 'w1:p1', + kind: 'intake', + outcome: 'closed-as-intake-complete', + closed: true, + }); + }); + + it('closes a plan pane and logs closed-as-plan-complete when the item reaches plan_complete', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-2', kind: 'plan', paneId: 'w1:p2', stage: 'intake_complete' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-2', stage: 'plan_complete' }), + closePane, + }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ logged: 1, closed: 1 }); + expect(closePane).toHaveBeenCalledWith('w1:p2', root, { itemId: 'WL-2', kind: 'plan' }); + expect((await paneCloseEntries(root))[0]).toMatchObject({ outcome: 'closed-as-plan-complete', closed: true }); + }); + + it('closes an audit pane and records the pass/fail verdict (AC5)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-3', kind: 'audit', paneId: 'w1:p3', stage: 'in_review' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ + id: 'WL-3', stage: 'in_review', auditedAt: '2026-01-02T00:00:00.000Z', auditResult: false, + }), + closePane, + }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ logged: 1, closed: 1 }); + expect(closePane).toHaveBeenCalledWith('w1:p3', root, { itemId: 'WL-3', kind: 'audit' }); + expect((await paneCloseEntries(root))[0]).toMatchObject({ outcome: 'audit-failed', closed: true }); + }); + + it('NEVER closes an implement pane but still logs its in_review completion (AC4/AC6)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-4', kind: 'implement', paneId: 'w1:p4', stage: 'plan_complete' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-4', stage: 'in_review' }), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 1, records: [{ paneId: 'w1:p4' }] }), + closePane, + }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ logged: 1, closed: 0 }); + expect(closePane).not.toHaveBeenCalled(); + expect((await paneCloseEntries(root))[0]).toMatchObject({ + outcome: 'requires-attention', + reasonCode: 'reached-in-review', + closed: false, + }); + }); + + it('is idempotent: a second pass with no state change writes no duplicate entry', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-5', kind: 'plan', paneId: 'w1:p5', stage: 'intake_complete' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-5', stage: 'plan_complete' }), + closePane, + }); + + const first = await monitorDispatchedPanes(deps, root); + const second = await monitorDispatchedPanes(deps, root); + + expect(first).toMatchObject({ logged: 1, closed: 1 }); + expect(second).toMatchObject({ logged: 0, closed: 0 }); + expect(closePane).toHaveBeenCalledTimes(1); + expect(await paneCloseEntries(root)).toHaveLength(1); + }); + + it('logs a NEW entry when the item changes state again (agent-ended → plan_complete)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-6', kind: 'plan', paneId: 'w1:p6', stage: 'intake_complete' }); + const noLivePanes = vi.fn().mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }); + // First pass: the agent session has ended but the item is NOT terminal. + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-6', stage: 'intake_complete' }), + getRunningDowntimePanes: noLivePanes, + closePane: vi.fn().mockResolvedValue(true), + }); + await monitorDispatchedPanes(deps, root); + + // The item then advances — a second outcome must be recorded. + deps.getItemLifecycleState = vi.fn().mockResolvedValue({ id: 'WL-6', stage: 'plan_complete' }); + await monitorDispatchedPanes(deps, root); + + const outcomes = (await paneCloseEntries(root)).map((e) => e.outcome); + expect(outcomes).toEqual(['requires-attention', 'closed-as-plan-complete']); + }); + + it('does NOT act on unknown liveness when the item has not reached a terminal (fail-open)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-7', kind: 'plan', paneId: 'w1:p7', stage: 'intake_complete' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-7', stage: 'intake_complete' }), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: false, error: 'herdr down' }), + closePane, + }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ checked: 1, logged: 0, closed: 0, errors: 0 }); + expect(closePane).not.toHaveBeenCalled(); + expect(await paneCloseEntries(root)).toEqual([]); + }); + + it('is fail-closed: a throwing closePane still logs the outcome (closed:false) and never throws (AC7)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-8', kind: 'intake', paneId: 'w1:p8', stage: 'idea' }); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-8', stage: 'intake_complete' }), + closePane: vi.fn().mockRejectedValue(new Error('herdr: pane not found')), + }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ checked: 1, logged: 1, closed: 0, errors: 0 }); + expect((await paneCloseEntries(root))[0]).toMatchObject({ closed: false }); + }); + + it('is fail-closed: a throwing item lookup counts an error and never throws (AC7)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-9', kind: 'plan', paneId: 'w1:p9' }); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockRejectedValue(new Error('wl exploded')), + closePane: vi.fn().mockResolvedValue(true), + }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ checked: 1, logged: 0, errors: 1 }); + }); + + it('continues past a bad pane so later panes still process (AC7)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-BAD', kind: 'plan', paneId: 'w1:pA' }); + await seedDispatch(root, { itemId: 'WL-GOOD', kind: 'intake', paneId: 'w1:pB' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn(async (itemId: string) => { + if (itemId === 'WL-BAD') throw new Error('wl exploded'); + return { id: itemId, stage: 'intake_complete' }; + }), + closePane, + }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ checked: 2, logged: 1, closed: 1, errors: 1 }); + expect(closePane).toHaveBeenCalledTimes(1); + expect(closePane).toHaveBeenCalledWith('w1:pB', root, { itemId: 'WL-GOOD', kind: 'intake' }); + }); + + it('is a no-op when the item-state dep is absent (legacy callers)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-10', kind: 'plan', paneId: 'w1:p10', stage: 'intake_complete' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ closePane }); + + const res = await monitorDispatchedPanes(deps, root); + + expect(res).toMatchObject({ checked: 0, logged: 0, closed: 0, errors: 0 }); + expect(closePane).not.toHaveBeenCalled(); + }); + + it('logs requires-attention for an item awaiting producer review (AC3)', async () => { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-11', kind: 'plan', paneId: 'w1:p11', stage: 'intake_complete' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ + id: 'WL-11', stage: 'intake_complete', needsProducerReview: true, + }), + closePane, + }); + + await monitorDispatchedPanes(deps, root); + + expect(closePane).toHaveBeenCalledWith('w1:p11', root, { itemId: 'WL-11', kind: 'plan' }); + expect((await paneCloseEntries(root))[0]).toMatchObject({ + outcome: 'requires-attention', + reasonCode: 'producer-review', + closed: true, + }); + }); + + // ── Warn-level observability (WL-0MU4USJ07009JYL8 AC2/AC6) ────────── + // Every fail-closed catch must emit a stderr warn (never silent) so a + // production failure is diagnosable without a debugger. + function stderrMessages(spy: ReturnType): string { + return spy.mock.calls.map((c) => String(c[0])).join('\n'); + } + + it('warns on a failed pane close (AC2)', async () => { + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-W1', kind: 'intake', paneId: 'w1:pW1' }); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-W1', stage: 'intake_complete' }), + closePane: vi.fn().mockRejectedValue(new Error('herdr: boom')), + }); + await monitorDispatchedPanes(deps, root); + expect(stderrMessages(spy)).toContain('pane close failed for w1:pW1'); + } finally { + spy.mockRestore(); + } + }); + + it('warns on a failed item lookup (AC2)', async () => { + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-W2', kind: 'plan', paneId: 'w1:pW2' }); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockRejectedValue(new Error('wl exploded')), + }); + await monitorDispatchedPanes(deps, root); + expect(stderrMessages(spy)).toContain('item lookup failed for WL-W2'); + } finally { + spy.mockRestore(); + } + }); + + it('warns on a failed pane-close log write (AC2)', async () => { + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-W3', kind: 'plan', paneId: 'w1:pW3' }); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-W3', stage: 'plan_complete' }), + closePane: vi.fn().mockResolvedValue(true), + recordPaneClose: vi.fn().mockRejectedValue(new Error('disk full')), + }); + const res = await monitorDispatchedPanes(deps, root); + expect(res).toMatchObject({ logged: 0, errors: 1 }); + expect(stderrMessages(spy)).toContain('pane-close log write failed for w1:pW3'); + } finally { + spy.mockRestore(); + } + }); + + it('warns when the live-pane query fails (AC2) and still fails open', async () => { + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const root = makeRoot(); + await seedDispatch(root, { itemId: 'WL-W4', kind: 'plan', paneId: 'w1:pW4' }); + const closePane = vi.fn().mockResolvedValue(true); + const deps = monitorDeps({ + getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-W4', stage: 'plan_complete' }), + getRunningDowntimePanes: vi.fn().mockRejectedValue(new Error('herdr down')), + closePane, + }); + const res = await monitorDispatchedPanes(deps, root); + // The terminal-stage close still happens (liveness is only needed for + // agent-done detection) — fail-open, not fail-closed, on liveness. + expect(res).toMatchObject({ logged: 1, closed: 1 }); + expect(stderrMessages(spy)).toContain('live-pane query threw'); + } finally { + spy.mockRestore(); + } + }); +}); + +// ── Worker tick ↔ pane-lifecycle integration (WL-0MU4US5MP001JFEN) ──── +// +// Mechanism B is DISABLED (WL-0MUMEKDK0008LKH8): the worker tick must NOT +// invoke the dispatch monitor, so the scheduled reaper (Mechanism A) is the +// only active auto-close path. These tests pin that disabled behaviour and +// that Mechanism A still runs from the tick. +describe('worker tick pane-lifecycle integration (WL-0MU4US5MP001JFEN) [disabled]', () => { + const roots = createTempDirTracker(); + + afterEach(() => { + roots.cleanup(); + }); + + function makeRoot(): string { + return roots.make('pane-monitor-tick-'); + } + + async function seedDispatch(root: string, itemId: string, kind: string, paneId: string): Promise { + const marker = { + itemId, kind, dispatchedAt: new Date().toISOString(), cwd: root, title: itemId, stage: 'intake_complete', + }; + await appendDowntimeLogEntry(root, JSON.stringify(marker)); + await appendDowntimeLogEntry(root, JSON.stringify({ ...marker, paneId, enrichment: true })); + } + + function makeMonitorWorker( + root: string, + deps: Partial, + paneClose?: { enabled: boolean; idleThresholdMinutes: number }, + ) { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const worker = createDowntimeWorker({ + poller, + deps: makeDeps({ getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), ...deps }), + config: () => ({ + enabled: true, + thresholdMs: 1, + requiredFreeSlots: 0, + model: 'plan', + cwd: root, + noCandidateCooldownMs: 3_600_000, + paneLifecycleIntervalMs: 0, + ...(paneClose ? { paneClose } : {}), + }), + }); + return worker; + } + + it('does NOT invoke the dispatch monitor from the worker tick (Mechanism B disabled, WL-0MUMEKDK0008LKH8)', async () => { + const root = makeRoot(); + await seedDispatch(root, 'WL-TICK', 'plan', 'w9:p1'); + const closePane = vi.fn().mockResolvedValue(true); + const getItemLifecycleState = vi.fn().mockResolvedValue({ id: 'WL-TICK', stage: 'plan_complete' }); + const worker = makeMonitorWorker(root, { + getItemLifecycleState, + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 1, records: [{ paneId: 'w9:p1' }] }), + closePane, + }); + + await worker.tick(); + // Give a hypothetically-enabled (fire-and-forget) monitor time to act + // before asserting the negative, so the assertion is meaningful. + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(getItemLifecycleState).not.toHaveBeenCalled(); + expect(closePane).not.toHaveBeenCalled(); + const pc = (await readDowntimeLogEntries(root)).filter((e) => e.entryType === 'pane-close'); + expect(pc).toHaveLength(0); + }); + + it('still runs the scheduled reaper (Mechanism A) from the worker tick', async () => { + const root = makeRoot(); + const getItemLifecycleState = vi.fn().mockResolvedValue({ id: 'WL-TICK', stage: 'plan_complete' }); + const runPaneCloseReaper = vi.fn().mockResolvedValue(undefined); + const worker = makeMonitorWorker( + root, + { getItemLifecycleState, runPaneCloseReaper }, + { enabled: true, idleThresholdMinutes: 30 }, + ); + + await worker.tick(); + + await vi.waitFor(() => expect(runPaneCloseReaper).toHaveBeenCalledWith( + root, + { idleThresholdMinutes: 30, ledgerPath: undefined }, + )); + // Mechanism B remains off — the reaper is the only active auto-close path. + expect(getItemLifecycleState).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/herdr/src/downtime-worker.parse.test.ts b/packages/herdr/src/downtime-worker.parse.test.ts new file mode 100644 index 000000000..365454957 --- /dev/null +++ b/packages/herdr/src/downtime-worker.parse.test.ts @@ -0,0 +1,738 @@ +/** + * packages/herdr/src/downtime-worker.parse.test.ts — parse helpers, clamp + * functions, dispatch-log provenance, and per-slot owner-lease gate tests + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4VOH5R004XLD8) to keep each test file focused + * on one concern. + * + * Covers the parsing and clamping surface, plus the related per-slot + * owner-lease gate: + * - `parseLlamaStatus` contention fields and per-slot owner parsing, + * - `parseLlamaStatus` `local_owner_session_id` array/legacy + * normalisation (WL-0MU88086A0089US4), + * - `clampDowntimeMarkerStaleWindowMs`, + * - the dispatch-log selection path/reason + pane-id enrichment + * (WL-0MUBVL251006JAQ0 / F6), and + * - the per-slot owner-lease gate in `tick()` (WL-0MU8807BI008C9ME). + * + * Shared scaffolding (`makeDeps`, `jsonResponseFixture`, etc.) is imported + * from `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, it, expect, vi } from 'vitest'; +import { + parseLlamaStatus, + dispatchDowntimeWork, + createDowntimePoller, + createDowntimeWorker, + clampDowntimeMarkerStaleWindowMs, + DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, + type LlamaStatus, + type DowntimeHerdrItem, +} from './downtime-worker.js'; +import { + jsonResponseFixture, + makeDeps, +} from './downtime-worker.fixtures.js'; +import { + appendDowntimeLogEntry, + readDowntimeLogEntries, +} from './downtime-log.js'; + +// ── Owner lease + contention + pane liveness (parent WL-0MTYZXSLN008HZOW, +// pane bound removed by WL-0MU2EP6JL006A1U3) ────────────────────── +// +// Regression suite for single-slot over-dispatch. The dispatcher must honour +// the Local Proxy owner lease, exclude owned slots from the free count, and +// back off on proxy contention. The RUNNING-PANE BOUND that was added +// alongside these is deliberately GONE (WL-0MU2EP6JL006A1U3): dispatched +// panes stay open until an operator closes them, so a live-pane count can +// never be the concurrency limiter — the LLM idle check is. + +describe('parseLlamaStatus: contention + per-slot owner (AC5/AC6)', () => { + const base = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 1, + total_slots: 1, + }; + + it('parses contention_queue_depth (the live gate signal) when served', () => { + const status = parseLlamaStatus({ ...base, contention_queue_depth: 4 }); + expect(status).not.toBeNull(); + expect(status!.contention_queue_depth).toBe(4); + }); + + it('leaves contention_queue_depth undefined when absent (backward compatible)', () => { + const status = parseLlamaStatus(base); + expect(status).not.toBeNull(); + expect(status!.contention_queue_depth).toBeUndefined(); + }); + + it('treats a negative or non-finite contention_queue_depth as ambiguous → busy', () => { + expect(parseLlamaStatus({ ...base, contention_queue_depth: -1 })).toBeNull(); + expect(parseLlamaStatus({ ...base, contention_queue_depth: Number.NaN })).toBeNull(); + expect(parseLlamaStatus({ ...base, contention_queue_depth: 'many' })).toBeNull(); + }); + + it('parses the cumulative contention_queued_count as telemetry (not a gate)', () => { + const status = parseLlamaStatus({ ...base, contention_queued_count: 13 }); + expect(status).not.toBeNull(); + expect(status!.contention_queued_count).toBe(13); + }); + + it('leaves contention_queued_count undefined when absent (backward compatible)', () => { + const status = parseLlamaStatus(base); + expect(status).not.toBeNull(); + expect(status!.contention_queued_count).toBeUndefined(); + }); + + it('treats a negative or non-finite contention_queued_count as ambiguous → busy', () => { + expect(parseLlamaStatus({ ...base, contention_queued_count: -1 })).toBeNull(); + expect(parseLlamaStatus({ ...base, contention_queued_count: Number.NaN })).toBeNull(); + expect(parseLlamaStatus({ ...base, contention_queued_count: 'many' })).toBeNull(); + }); + + it('keeps the live depth distinct from the cumulative count', () => { + const status = parseLlamaStatus({ + ...base, + contention_queue_depth: 0, + contention_queued_count: 13, + }); + expect(status).not.toBeNull(); + expect(status!.contention_queue_depth).toBe(0); + expect(status!.contention_queued_count).toBe(13); + }); + + it('parses a per-slot owner_session_id', () => { + const status = parseLlamaStatus({ + ...base, + slots: [{ slot_id: 'slot-1', is_processing: false, owner_session_id: 'sess-1' }], + }); + expect(status).not.toBeNull(); + expect(status!.slots![0].owner_session_id).toBe('sess-1'); + }); + + it('omits owner_session_id from the slot shape when absent (backward compatible)', () => { + const status = parseLlamaStatus({ + ...base, + slots: [{ slot_id: 'slot-1', is_processing: false }], + }); + expect(status).not.toBeNull(); + expect(status!.slots![0]).toEqual({ slot_id: 'slot-1', is_processing: false }); + }); +}); + +// ── clampDowntimeMarkerStaleWindowMs (WL-0MU6UL0RJ008IHGT AC3) ──────── + +describe('clampDowntimeMarkerStaleWindowMs', () => { + it('defaults on non-finite/negative input', () => { + expect(clampDowntimeMarkerStaleWindowMs(NaN)).toBe(DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS); + expect(clampDowntimeMarkerStaleWindowMs(-1)).toBe(DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS); + expect(clampDowntimeMarkerStaleWindowMs(0)).toBe(60 * 60 * 1000); // clamped to 1h floor + }); + + it('clamps into the documented [1h, 7d] range', () => { + expect(clampDowntimeMarkerStaleWindowMs(30 * 60 * 1000)).toBe(60 * 60 * 1000); // below floor + expect(clampDowntimeMarkerStaleWindowMs(8 * 24 * 60 * 60 * 1000)).toBe(7 * 24 * 60 * 60 * 1000); // above ceiling + const mid = 6 * 60 * 60 * 1000; + expect(clampDowntimeMarkerStaleWindowMs(mid)).toBe(mid); + }); +}); + +// ── Dispatch-log selection provenance + pane enrichment (F6) ─────────── +// WL-0MUBVL251006JAQ0 (parent WL-0MUBEZ6PE002WLP4, AC6.1–AC6.3). + +describe('dispatch log: selection path/reason + pane id enrichment (WL-0MUBVL251006JAQ0 / F6)', () => { + const critical = (): DowntimeHerdrItem => ({ + id: 'CR-SEL', title: 'Crit sel', status: 'open', stage: 'plan_complete', + risk: 'Low', effort: 'S', priority: 'critical', sortIndex: 10, + }); + const nonCritical = (): DowntimeHerdrItem => ({ + id: 'IMP-SEL', title: 'Impl sel', status: 'open', stage: 'plan_complete', + risk: 'Low', effort: 'S', priority: 'high', sortIndex: 100, + }); + + it('AC6.1: a critical-first dispatch records selectionPath/selectionReason', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.recordDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + itemId: 'CR-SEL', + selectionPath: 'critical-first', + selectionReason: 'no-live-pane', + }), + ); + }); + + it('AC6.1: a normal-scan dispatch records selectionPath/selectionReason', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [nonCritical()] }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.recordDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + itemId: 'IMP-SEL', + selectionPath: 'normal-scan', + selectionReason: 'non-critical', + }), + ); + }); + + it('AC6.2/AC6.3: a successful dispatch appends an enrichment entry with the resolved paneId, preserving marker fields', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), + recordDispatch: vi.fn().mockResolvedValue(true), + recordDispatchEnrichment: vi.fn().mockResolvedValue(undefined), + // 1st call (pre-spawn in-flight guard): no pane. 2nd call (post-spawn + // enrichment resolver): the pane is live. + getRunningDowntimePanes: vi + .fn() + .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) + .mockResolvedValue({ + ok: true, count: 1, paneIds: ['w1:p1'], + records: [{ paneId: 'w1:p1', label: 'Downtime triggered implement Crit sel - CR-SEL', agent: 'pi', agentStatus: 'working' }], + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const markerCall = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + const enrichCall = (deps.recordDispatchEnrichment as ReturnType).mock.calls[0][0]; + expect(enrichCall).toMatchObject({ + itemId: markerCall.itemId, + kind: markerCall.kind, + stage: markerCall.stage, + dispatchedAt: markerCall.dispatchedAt, + paneId: 'w1:p1', + enrichment: true, + noItemComment: true, + }); + }); + + it('AC6.3: an unresolved pane id is recorded as null, and the dispatch still succeeds', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), + recordDispatch: vi.fn().mockResolvedValue(true), + recordDispatchEnrichment: vi.fn().mockResolvedValue(undefined), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect((deps.recordDispatchEnrichment as ReturnType).mock.calls[0][0].paneId).toBeNull(); + }); + + it('AC6.3: a throwing enrichment dep never affects the dispatch outcome (fail-open)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), + recordDispatch: vi.fn().mockResolvedValue(true), + recordDispatchEnrichment: vi.fn().mockRejectedValue(new Error('boom')), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('AC6.3: an absent enrichment dep is a no-op (legacy callers unchanged)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + // makeDeps omits recordDispatchEnrichment by default. + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(outcome.dispatched).toBe(true); + }); +}); +// ── AC Tests: per-slot owner-lease gate (WL-0MU8807BI008C9ME) ────────── + +// ── parseLlamaStatus: array local_owner_session_id (blocking dep fix) ── + +describe('parseLlamaStatus: array local_owner_session_id (WL-0MU88086A0089US4)', () => { + function makeBase(): Record { + return { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 3, + contention_queue_depth: 0, + contention_queued_count: 0, + }; + } + + it('AC1: legacy string payload preserves the owner (WL-0MU88086A0089US4)', () => { + const raw = { + ...makeBase(), + local_owner_session_id: 'herdr-1789775322-722064-10708', + }; + const result = parseLlamaStatus(raw); + expect(result).not.toBeNull(); + expect(result!.local_owner_session_id).toBe('herdr-1789775322-722064-10708'); + }); + + it('AC2: current 2-element array payload parses; URL element ignored, session recovered', () => { + const raw = { + ...makeBase(), + local_lease_active: undefined, // omit so it derives from array/lease_seconds + local_owner_session_id: ['http://localhost:8080', 'herdr-1789775322-722064-10708'], + local_owner_lease_remaining_seconds: 30, + }; + const result = parseLlamaStatus(raw); + expect(result).not.toBeNull(); + // The session ID (2nd element) is extracted; the URL (1st element) is dropped. + expect(result!.local_owner_session_id).toBe('herdr-1789775322-722064-10708'); + // local_lease_active derives from lease_seconds > 0. + expect(result!.local_lease_active).toBe(true); + }); + + it('AC3: malformed owner (number) → fail-closed (busy / undefined), no throw', () => { + const raw = { + ...makeBase(), + local_owner_session_id: 42, + }; + const result = parseLlamaStatus(raw); + // A non-string, non-array number is ambiguous → undefined for this field. + expect(result).not.toBeNull(); + expect(result!.local_owner_session_id).toBeUndefined(); + }); + + it('AC3b: malformed owner (object) → fail-closed', () => { + const raw = { + ...makeBase(), + local_owner_session_id: { id: 'bad' }, + }; + const result = parseLlamaStatus(raw); + expect(result).not.toBeNull(); + expect(result!.local_owner_session_id).toBeUndefined(); + }); + + it('AC3c: malformed owner (empty array) → fail-closed', () => { + const raw = { + ...makeBase(), + local_owner_session_id: [], + }; + const result = parseLlamaStatus(raw); + expect(result).not.toBeNull(); + expect(result!.local_owner_session_id).toBeUndefined(); + }); + + it('AC4: contract test pins exact array shape [url, session]', () => { + // Reproduces the exact live-proxy shape observed on 2026-09-19. + const raw = { + ...makeBase(), + local_owner_session_id: ['http://localhost:8080', 'herdr-1789776793-744802-10480'], + local_owner_lease_remaining_seconds: 29.99, + }; + const result = parseLlamaStatus(raw); + expect(result).not.toBeNull(); + expect(result!.local_owner_session_id).toBe('herdr-1789776793-744802-10480'); + }); + + it('AC5: fetchLocalStatus never throws on array shape', async () => { + const arrayPayload = { + ...makeBase(), + local_owner_session_id: ['http://localhost:8080', 'herdr-test'], + }; + const poller = createDowntimePoller( + 'http://proxy:8000', + vi.fn().mockResolvedValue(jsonResponseFixture(arrayPayload)), + ); + const worker = createDowntimeWorker({ + poller, + deps: makeDeps(), + config: () => ({ + enabled: true, + thresholdMs: 0, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }), + }); + // Should not throw — poller must handle the array shape. + const result = await worker.tick(); + expect(result).toBeDefined(); + }); +}); + +describe('parseLlamaStatus: local_owner_session_ids normalization (WL-0MU88086A0089US4)', () => { + function makeBase(): Record { + return { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 3, + contention_queue_depth: 0, + contention_queued_count: 0, + }; + } + + it('captures every session in the array and drops the proxy URL element', () => { + const result = parseLlamaStatus({ + ...makeBase(), + local_owner_session_id: ['http://localhost:8080', 'session-a', 'session-b'], + }); + expect(result).not.toBeNull(); + expect(result!.local_owner_session_ids).toEqual(['session-a', 'session-b']); + expect(result!.local_owner_session_id).toBe('session-a'); + }); + + it('normalises a legacy string owner to a single-element array', () => { + const result = parseLlamaStatus({ + ...makeBase(), + local_owner_session_id: 'legacy-session', + }); + expect(result).not.toBeNull(); + expect(result!.local_owner_session_ids).toEqual(['legacy-session']); + expect(result!.local_owner_session_id).toBe('legacy-session'); + }); + + it('yields an empty owner list for malformed input (fail-closed, no throw)', () => { + const malformedInputs: unknown[] = [ + 42, + { id: 'bad' }, + [], + ['http://localhost:8080'], + [42], + ['http://localhost:8080', 42], + ]; + for (const malformed of malformedInputs) { + const result = parseLlamaStatus({ + ...makeBase(), + local_owner_session_id: malformed, + }); + expect(result).not.toBeNull(); + expect(result!.local_owner_session_ids).toEqual([]); + expect(result!.local_owner_session_id).toBeUndefined(); + } + }); + + it('marks local_lease_active from a multi-session array even without lease seconds', () => { + const result = parseLlamaStatus({ + ...makeBase(), + local_lease_active: undefined, + local_owner_session_id: ['http://localhost:8080', 'session-a', 'session-b'], + }); + expect(result).not.toBeNull(); + expect(result!.local_lease_active).toBe(true); + }); +}); + +// ── Per-slot owner-lease gate in tick() (WL-0MU8807BI008C9ME AC1-AC4) ── + +describe('tick(): owner-lease gate must be per-slot (WL-0MU8807BI008C9ME)', () => { + function makePerSlotWorker(opts: { + status: LlamaStatus; + runningPanes: () => { ok: true; count: number } | { ok: false; error?: string }; + requiredFreeSlots?: number; + }) { + const cfg = { + enabled: true, + thresholdMs: 1_000, + requiredFreeSlots: opts.requiredFreeSlots ?? 2, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + browseItemCount: 20, + }; + const poller = createDowntimePoller( + 'http://proxy:8000', + vi.fn().mockResolvedValue(jsonResponseFixture(opts.status)), + ); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + getRunningDowntimePanes: vi.fn().mockImplementation(async () => opts.runningPanes()), + }); + const worker = createDowntimeWorker({ poller, deps, config: () => ({ ...cfg }) }); + return { worker, deps }; + } + + it('AC1: per-slot 3-slots: 1 owned+busy, 2 free unowned, held lease + runningPanes → dispatch proceeds (RCA regression)', async () => { + // Regression for the overnight stall: cheap mode (3 slots), one slot + // owned by a dispatched agent, 2 free unowned → dispatch MUST fire. + vi.useFakeTimers(); + try { + const ownedSlot = 'http://localhost:8080'; + const status: LlamaStatus = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 2, + total_slots: 3, + local_owner_session_id: ownedSlot, + local_owner_lease_remaining_seconds: 120, + slots: [ + { slot_id: 'slot-1', is_processing: true, owner_session_id: ownedSlot }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + contention_queue_depth: 0, + contention_queued_count: 0, + }; + const { worker, deps } = makePerSlotWorker({ + status, + runningPanes: () => ({ ok: true, count: 1 }), + requiredFreeSlots: 2, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // first tick — establish idle baseline + vi.setSystemTime(start + 5_000); + const outcome = await worker.tick(); + // THE FIX: dispatch MUST proceed because 2 free unowned slots exist. + expect(outcome.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('AC2: count-based status (no slots), owner lease held, runningPanes > 0 → dispatch refused (single-slot protection preserved)', async () => { + vi.useFakeTimers(); + try { + const status: LlamaStatus = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 1, + total_slots: 1, + local_owner_session_id: 'session-live', + local_owner_lease_remaining_seconds: 120, + }; + // No `slots` array → count-based mode. + const { worker, deps } = makePerSlotWorker({ + status, + runningPanes: () => ({ ok: true, count: 1 }), + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const blocked = await worker.tick(); + expect(blocked.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('AC3: per-slot status where every slot is owned → dispatch refused', async () => { + vi.useFakeTimers(); + try { + const ownerA = 'http://localhost:8080'; + const ownerB = 'http://localhost:8081'; + const ownerC = 'http://localhost:8082'; + const status: LlamaStatus = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 0, + total_slots: 3, + local_owner_session_id: ownerA, + local_owner_lease_remaining_seconds: 60, + // AC3: EVERY slot carries a live owner lease, including slot-3 which + // is idle by `is_processing` but owned. countFreeUnownedSlots === 0 + // is the per-slot gate signal — the worker must refuse. + slots: [ + { slot_id: 'slot-1', is_processing: true, owner_session_id: ownerA }, + { slot_id: 'slot-2', is_processing: true, owner_session_id: ownerB }, + { slot_id: 'slot-3', is_processing: false, owner_session_id: ownerC }, + ], + contention_queue_depth: 0, + contention_queued_count: 0, + }; + const { worker, deps } = makePerSlotWorker({ + status, + runningPanes: () => ({ ok: true, count: 1 }), + requiredFreeSlots: 2, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const blocked = await worker.tick(); + expect(blocked.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('AC4: audit-tier minimum (DOWNTIME_AUDIT_MIN_FREE_SLOTS = 2) evaluated against per-slot unowned-free count', async () => { + vi.useFakeTimers(); + try { + // Only 1 free unowned slot — below audit-tier minimum of 2. + const status: LlamaStatus = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 1, + total_slots: 3, + local_owner_session_id: 'http://localhost:8080', + local_owner_lease_remaining_seconds: 60, + slots: [ + { slot_id: 'slot-1', is_processing: true, owner_session_id: 'http://localhost:8080' }, + { slot_id: 'slot-2', is_processing: true }, // processing, not free + { slot_id: 'slot-3', is_processing: false }, // free unowned + ], + contention_queue_depth: 0, + contention_queued_count: 0, + }; + const { worker, deps } = makePerSlotWorker({ + status, + runningPanes: () => ({ ok: true, count: 0 }), + requiredFreeSlots: 2, // audit minimum + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const blocked = await worker.tick(); + // 1 free unowned < requiredFreeSlots=2 → blocked. + expect(blocked.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('AC1b: per-slot with 0 runningPanes but lease held → dispatch proceeds (operator lease on spare slots)', async () => { + // When the worker has NO running panes, even a held lease doesn't block. + vi.useFakeTimers(); + try { + const status: LlamaStatus = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 2, + total_slots: 3, + local_owner_session_id: 'http://localhost:8080', + local_owner_lease_remaining_seconds: 60, + slots: [ + { slot_id: 'slot-1', is_processing: true, owner_session_id: 'http://localhost:8080' }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + contention_queue_depth: 0, + contention_queued_count: 0, + }; + const { worker, deps } = makePerSlotWorker({ + status, + runningPanes: () => ({ ok: true, count: 0 }), + requiredFreeSlots: 2, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 5_000); + const result = await worker.tick(); + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('AC5: cheap-mode dispatch is recorded in downtime-dispatches.log while a previously dispatched agent is still working', async () => { + // Integration evidence for the RCA (WL-0MU8807BI008C9ME): with a + // previously dispatched agent STILL alive (`runningPanes > 0`) and + // holding the shared owner lease, the per-slot gate must still dispatch + // into the 2 free unowned slots AND record the dispatch in the rolling + // log at /.worklog/downtime-dispatches.log — the observable + // behaviour the RCA said never happened. + const root = mkdtempSync(join(tmpdir(), 'herdr-perslot-log-')); + vi.useFakeTimers(); + try { + const status: LlamaStatus = { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 2, + total_slots: 3, + // Array form served by the live proxy: [url, session]. + local_owner_session_id: ['http://localhost:8080', 'dispatched-pane-session'], + local_owner_lease_remaining_seconds: 90, + slots: [ + { slot_id: 'slot-1', is_processing: true, owner_session_id: 'dispatched-pane-session' }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + contention_queue_depth: 0, + contention_queued_count: 0, + }; + const cfg = { + enabled: true, + thresholdMs: 1_000, + requiredFreeSlots: 2, + model: 'plan', + cwd: root, + noCandidateCooldownMs: 3_600_000, + browseItemCount: 20, + }; + const poller = createDowntimePoller( + 'http://proxy:8000', + vi.fn().mockResolvedValue(jsonResponseFixture(status)), + ); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + // A previously dispatched agent is STILL working. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 1 }), + // Real rolling-log write (the plugin's recordDispatch contract): + // the dispatch marker must land in downtime-dispatches.log. + recordDispatch: vi.fn(async (event) => { + await appendDowntimeLogEntry(event.cwd, JSON.stringify(event)); + return true; + }), + }); + const worker = createDowntimeWorker({ poller, deps, config: () => ({ ...cfg }) }); + + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // baseline poll — start the per-slot timer + vi.setSystemTime(start + 5_000); + const outcome = await worker.tick(); + + expect(outcome.dispatched).toBe(true); + const entries = await readDowntimeLogEntries(root); + const marker = entries.find((e) => e.kind === 'plan' && e.itemId === 'WL-ABC'); + expect(marker).toBeDefined(); + expect(marker!.dispatchedAt).toBeDefined(); + } finally { + vi.useRealTimers(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/herdr/src/downtime-worker.settings.test.ts b/packages/herdr/src/downtime-worker.settings.test.ts new file mode 100644 index 000000000..18ecfa1fd --- /dev/null +++ b/packages/herdr/src/downtime-worker.settings.test.ts @@ -0,0 +1,686 @@ +/** + * packages/herdr/src/downtime-worker.settings.test.ts — settings, enable/ + * disable state, poller-failure, and configuration tests + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4VEQOZ005XH34) to keep each test file focused + * on one concern. + * + * Covers the settings/configuration surface and the worker enable/disable + * state contract: + * - endpoint failures and the poller (`createDowntimePoller`), + * - `parseLlamaStatus` local_active_query parsing, + * - the blocked-questions prompt instruction, + * - the downtime settings clamps, + * - `parseNextItemOutput` and `skillKindFromPrompt` wiring helpers, + * - the worker enabled state and per-instance toggle override, + * - the persisted `.herdr-downtime-disabled` marker. + * + * Shared scaffolding (`makeDeps`, status fixtures, etc.) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + */ + +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + parseLlamaStatus, + evaluateIdle, + createDowntimePoller, + createDowntimeWorker, + buildDowntimePrompt, + parseNextItemOutput, + skillKindFromPrompt, + clampDowntimePollInterval, + clampDowntimeIdleThresholdMs, + clampDowntimeRequiredFreeSlots, + clampDowntimeNoCandidateCooldownMs, + clampDowntimeNonTerminalCooldownMs, + BLOCKED_QUESTIONS_INSTRUCTION, + DOWNTIME_POLL_INTERVAL_FLOOR_MS, + DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS, + DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, + DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS, + DOWNTIME_NON_TERMINAL_COOLDOWN_MAX_MS, + DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, + type DowntimeCandidate, +} from './downtime-worker.js'; +import { + DOWNTIME_DISABLE_MARKER_FILE, + writeDisableMarker, +} from './downtime-disable-marker.js'; +import { + idleAllSlotsFree, + ambiguousMissingFieldsRaw, + networkErrorFixture, + timeoutErrorFixture, + httpErrorResponseFixture, + jsonResponseFixture, + makeDeps, + type LlamaStatusHttpResponse, +} from './downtime-worker.fixtures.js'; + +// ── Endpoint failures & poller (AC6) — implemented in F2 ────────────── + +describe('endpoint failures and poller', () => { + it('network errors are treated as busy and never throw', async () => { + const fetcher = vi.fn().mockRejectedValue(networkErrorFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('timeouts are treated as busy and never throw', async () => { + const fetcher = vi.fn().mockRejectedValue(timeoutErrorFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('HTTP error statuses are treated as busy', async () => { + const fetcher = vi.fn().mockResolvedValue(httpErrorResponseFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('polls GET {proxyUrl}/llama/local/status', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await poller.poll(); + expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); + }); + + it('normalises a trailing slash on the proxy URL', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000/', fetcher); + await poller.poll(); + expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); + }); + + it('returns the parsed status on a successful poll', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toEqual(idleAllSlotsFree); + }); + + it('treats invalid JSON as busy', async () => { + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + }); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('treats payloads with missing required fields as busy', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(ambiguousMissingFieldsRaw)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('derives local_lease_active from the lease fields when the boolean is absent', async () => { + const status = parseLlamaStatus({ + llama_server_running: true, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + local_owner_session_id: 'session-1', + local_owner_lease_remaining_seconds: 120, + }); + expect(status).not.toBeNull(); + expect(status!.local_lease_active).toBe(true); + expect(evaluateIdle(status!, 0)).toBe(false); + }); + + it('coalesces overlapping polls (single-flight, no overlapping fetches)', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = () => resolve(jsonResponseFixture(idleAllSlotsFree)); + }); + const fetcher = vi.fn().mockImplementation(() => gate); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + + const first = poller.poll(); + expect(poller.isPolling()).toBe(true); + const second = poller.poll(); + release(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(firstResult).toEqual(idleAllSlotsFree); + expect(secondResult).toBe(firstResult); // same coalesced result + expect(poller.isPolling()).toBe(false); + }); + + it('aborts the request after the per-poll timeout (fail closed)', async () => { + vi.useFakeTimers(); + let receivedSignal: unknown; + const fetcher = vi.fn((_url: string, init?: { signal?: unknown }) => { + receivedSignal = init?.signal; + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }); + }); + + const poller = createDowntimePoller('http://proxy:8000', fetcher, 5_000); + const pollPromise = poller.poll(); + expect(receivedSignal).toBeDefined(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(pollPromise).resolves.toBeNull(); + expect(poller.isPolling()).toBe(false); + vi.useRealTimers(); + }); +}); + +// ── local_active_query parsing (WL-0MSL2ZQIF006QB4Q) ────────────────── + +describe('parseLlamaStatus local_active_query', () => { + const base = { + llama_server_running: true, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + }; + + it('exposes local_active_query=false when the proxy serves it', () => { + const status = parseLlamaStatus({ ...base, local_active_query: false }); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBe(false); + }); + + it('exposes local_active_query=true when the proxy serves it', () => { + const status = parseLlamaStatus({ ...base, local_active_query: true }); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBe(true); + }); + + it('leaves local_active_query undefined when absent (pre-fix proxy, backward compatible)', () => { + const status = parseLlamaStatus(base); + expect(status).not.toBeNull(); + expect(status!.local_active_query).toBeUndefined(); + }); + + it('treats a malformed (non-boolean) local_active_query as ambiguous → busy', () => { + expect(parseLlamaStatus({ ...base, local_active_query: 'yes' })).toBeNull(); + }); +}); + +describe('blocked-questions prompt instruction', () => { + const candidate: DowntimeCandidate = { + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }; + + it('the plan prompt runs /skill:plan on the item id', () => { + const prompt = buildDowntimePrompt('plan', candidate); + expect(prompt).toContain('/skill:plan WL-ABC'); + }); + + it('the intake prompt runs /skill:intake on the item id', () => { + const prompt = buildDowntimePrompt('intake', { ...candidate, stage: 'idea' }); + expect(prompt).toContain('/skill:intake WL-ABC'); + }); + + it('instructs recording questions in a comment, flagging needs-producer-review, and stopping', () => { + const prompt = buildDowntimePrompt('plan', candidate); + expect(prompt).toContain('comment'); + expect(prompt).toContain('--needs-producer-review true'); + expect(prompt).toContain('stop'); + expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl comment add'); + expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl update --needs-producer-review true'); + }); + + it('BLOCKED_QUESTIONS_INSTRUCTION includes the final-summary directive', () => { + expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('repeat the questions in your final summary'); + }); + + it('buildDowntimePrompt output includes the final-summary directive', () => { + const prompt = buildDowntimePrompt('implement', candidate); + expect(prompt).toContain('repeat the questions in your final summary'); + }); + + // risk-effort prompt (WL-0MTTSWCJR003OMN7) + it('the risk-effort prompt runs /skill:effort-and-risk on the item id', () => { + const prompt = buildDowntimePrompt('risk-effort', candidate); + expect(prompt).toContain('/skill:effort-and-risk WL-ABC'); + }); +}); + +// ── Settings clamps (AC8) ───────────────────────────────────────────── + +describe('downtime settings clamps', () => { + it('clampDowntimePollInterval enforces the 10s floor and keeps valid values', () => { + expect(clampDowntimePollInterval(5_000)).toBe(DOWNTIME_POLL_INTERVAL_FLOOR_MS); + expect(clampDowntimePollInterval(10_000)).toBe(10_000); + expect(clampDowntimePollInterval(45_000)).toBe(45_000); + }); + + it('clampDowntimePollInterval falls back to the 30s default for non-finite values', () => { + expect(clampDowntimePollInterval(Number.NaN)).toBe(DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + expect(clampDowntimePollInterval(Infinity)).toBe(DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + }); + + it('clampDowntimeIdleThresholdMs rejects negative and non-finite values', () => { + expect(clampDowntimeIdleThresholdMs(-1)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + expect(clampDowntimeIdleThresholdMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + }); + + it('clampDowntimeIdleThresholdMs keeps valid values and floors at 1s', () => { + expect(clampDowntimeIdleThresholdMs(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + expect(clampDowntimeIdleThresholdMs(0)).toBe(1_000); + }); + + it('clampDowntimeRequiredFreeSlots maps negative/non-finite to the default N=2 and rounds', () => { + // Spare-capacity default (parent WL-0MT32F90V008UAD2): negative/non- + // finite input falls back to DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS (2) + // — the out-of-the-box spare-capacity default, still fail-closed safe. + expect(clampDowntimeRequiredFreeSlots(-3)).toBe(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS); + expect(clampDowntimeRequiredFreeSlots(Number.NaN)).toBe(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS); + expect(clampDowntimeRequiredFreeSlots(2.7)).toBe(3); + expect(clampDowntimeRequiredFreeSlots(2)).toBe(2); + }); + + it('clampDowntimeNoCandidateCooldownMs keeps valid values and floors at 60s', () => { + expect(clampDowntimeNoCandidateCooldownMs(3_600_000)).toBe(3_600_000); + expect(clampDowntimeNoCandidateCooldownMs(1_000)).toBe(DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS); + expect(clampDowntimeNoCandidateCooldownMs(60_000)).toBe(60_000); + }); + + it('clampDowntimeNoCandidateCooldownMs rejects negative and non-finite values', () => { + expect(clampDowntimeNoCandidateCooldownMs(-1)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); + expect(clampDowntimeNoCandidateCooldownMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); + expect(clampDowntimeNoCandidateCooldownMs(Infinity)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); + }); + + it('clampDowntimeNonTerminalCooldownMs keeps valid values and clamps to [60s, 24h]', () => { + expect(clampDowntimeNonTerminalCooldownMs(DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS)).toBe( + DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, + ); + // Below the 60s floor → clamped up (no immediate retry). + expect(clampDowntimeNonTerminalCooldownMs(1_000)).toBe(DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS); + expect(clampDowntimeNonTerminalCooldownMs(60_000)).toBe(60_000); + // Above the 24h ceiling → clamped down (cannot strand indefinitely). + expect(clampDowntimeNonTerminalCooldownMs(48 * 60 * 60 * 1000)).toBe( + DOWNTIME_NON_TERMINAL_COOLDOWN_MAX_MS, + ); + }); + + it('clampDowntimeNonTerminalCooldownMs rejects negative and non-finite values', () => { + expect(clampDowntimeNonTerminalCooldownMs(-1)).toBe(DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS); + expect(clampDowntimeNonTerminalCooldownMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS); + expect(clampDowntimeNonTerminalCooldownMs(Infinity)).toBe(DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS); + }); +}); + +// ── Wiring helpers (F4) ─────────────────────────────────────────────── + +describe('parseNextItemOutput', () => { + it('parses the workItem from `wl next --stage --json`', () => { + const stdout = JSON.stringify({ success: true, workItem: { id: 'WL-ABC', title: 'Some task' } }); + expect(parseNextItemOutput(stdout, 'intake_complete')).toEqual({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }); + }); + + it('returns null when wl next reports no item (workItem null)', () => { + const stdout = JSON.stringify({ success: false, workItem: null, reason: 'no items' }); + expect(parseNextItemOutput(stdout, 'idea')).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(parseNextItemOutput('not json', 'idea')).toBeNull(); + }); + + it('returns null when the workItem lacks an id', () => { + const stdout = JSON.stringify({ success: true, workItem: { title: 'no id' } }); + expect(parseNextItemOutput(stdout, 'idea')).toBeNull(); + }); +}); + +describe('skillKindFromPrompt', () => { + it('derives plan from a /skill:plan prompt and intake otherwise', () => { + expect(skillKindFromPrompt('Run /skill:plan WL-ABC — Task.')).toBe('plan'); + expect(skillKindFromPrompt('Run /skill:intake WL-DEF — Idea.')).toBe('intake'); + }); +}); + +describe('downtime worker enabled state', () => { + it('exposes the enabled flag from the per-tick config', async () => { + const cfg = { enabled: true }; + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ + enabled: cfg.enabled, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }), + }); + expect(worker.enabled).toBe(true); + cfg.enabled = false; + expect(worker.enabled).toBe(false); + }); +}); + +// ── Per-instance toggle override (parent WL-0MSZ4NSOE007AQEF) ───────── + +describe('downtime worker per-instance override', () => { + function makeWorker( + override: boolean | null = null, + globalEnabled: boolean = true, + ) { + const cfg = { + enabled: globalEnabled, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ ...cfg }), + override, + }); + return { worker, cfg }; + } + + it('defaults to null (follows global settings)', () => { + const { worker } = makeWorker(); + expect(worker.override).toBe(null); + expect(worker.enabled).toBe(true); + }); + + it('toggle() cycles null → false → null (no force-enable)', () => { + const { worker } = makeWorker(); + // null → false (first press disables) + expect(worker.override).toBe(null); + worker.toggle(); + expect(worker.override).toBe(false); + // false → null (second press returns to follow settings) + worker.toggle(); + expect(worker.override).toBe(null); + }); + + it('two consecutive toggles return to follow-settings (null→false→null)', () => { + const { worker, cfg } = makeWorker(); + // First press: disable + worker.toggle(); + expect(worker.override).toBe(false); + expect(worker.enabled).toBe(false); + // Second press: back to follow settings + worker.toggle(); + expect(worker.override).toBe(null); + expect(worker.enabled).toBe(cfg.enabled); // follows global + }); + + it('three presses cycle null → false → null → false', () => { + const { worker } = makeWorker(); + expect(worker.override).toBe(null); + worker.toggle(); // → false + expect(worker.override).toBe(false); + worker.toggle(); // → null + expect(worker.override).toBe(null); + worker.toggle(); // → false again (not true) + expect(worker.override).toBe(false); + }); + + it('tick() short-circuits after disable (override=false)', async () => { + const { worker } = makeWorker(); + worker.toggle(); // → false + const result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + }); + + it('tick() follows global setting after second press (null → false → null)', async () => { + vi.useFakeTimers(); + try { + const { worker, cfg } = makeWorker(null, true); + // First press: disable + worker.toggle(); + expect(worker.override).toBe(false); + let result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + + // Second press: back to follow settings + worker.toggle(); + expect(worker.override).toBe(null); + result = await worker.tick(); + expect(result.idle).toBe(false); // normal behavior (global busy) + } finally { + vi.useRealTimers(); + } + }); + + it('enabled returns override ?? cfg.enabled (null)', () => { + const { worker, cfg } = makeWorker(); + // null override → follows global + expect(worker.enabled).toBe(true); + cfg.enabled = false; + expect(worker.enabled).toBe(false); + cfg.enabled = true; + // toggle: null → false + worker.toggle(); + expect(worker.override).toBe(false); + expect(worker.enabled).toBe(false); + // toggle: false → null + worker.toggle(); + expect(worker.override).toBe(null); + expect(worker.enabled).toBe(cfg.enabled); // follows global + }); + + it('tick() short-circuits when override is false (even if global is true)', async () => { + const { worker } = makeWorker(false, true); + const result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + }); + + it('tick() behaves normally when override is null (follows global)', async () => { + vi.useFakeTimers(); + try { + const { worker } = makeWorker(null, true); + // Busy global → still busy + const result = await worker.tick(); + expect(result.idle).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('two workers with independent overrides toggle independently', () => { + const { worker: w1 } = makeWorker(); + const { worker: w2 } = makeWorker(); + w1.toggle(); + expect(w1.override).toBe(false); + expect(w2.override).toBe(null); + expect(w1.enabled).toBe(false); + expect(w2.enabled).toBe(true); + w2.toggle(); // → false + expect(w2.override).toBe(false); + w2.toggle(); // → null + expect(w2.override).toBe(null); + expect(w2.enabled).toBe(true); // follows global + }); + + it('override forces dispatch on when global setting is false', () => { + const { worker } = makeWorker(true, false); + expect(worker.override).toBe(true); + expect(worker.enabled).toBe(true); // override takes precedence + expect(worker.tick).toBeDefined(); + }); + + it('override set via config is respected (pre-toggled worker)', () => { + const { worker } = makeWorker(false, true); + expect(worker.override).toBe(false); + expect(worker.enabled).toBe(false); + }); +}); + +// ── Persisted disable marker (WL-0MT5SFP990001FNW) ─────────────────── + +describe('persisted downtime disable marker (.herdr-downtime-disabled)', () => { + let root: string; + let cwd: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'herdr-dt-marker-')); + cwd = join(root, 'worklog-root'); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function markerPath(): string { + return join(cwd, DOWNTIME_DISABLE_MARKER_FILE); + } + + function makeWorkerWithCwd( + extra: Partial[0]> = {}, + ) { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd, + noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + }; + const merged = { + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ ...cfg }), + ...extra, + }; + const worker = createDowntimeWorker(merged); + return { worker, cfg }; + } + + it('creates the marker file when toggle() disables (AC1)', () => { + const { worker } = makeWorkerWithCwd(); + expect(existsSync(markerPath())).toBe(false); + worker.toggle(); // disable + expect(worker.override).toBe(false); + expect(existsSync(markerPath())).toBe(true); + }); + + it('does not rewrite an existing marker (no write when already present, AC1)', () => { + writeFileSync(markerPath(), 'existing', 'utf8'); + // Direct helper-level check (a worker constructed with a marker present + // restores override=false, so toggle() would remove it — the no-rewrite + // guarantee of AC1 belongs to the write helper itself). + writeDisableMarker(cwd); + expect(readFileSync(markerPath(), 'utf8')).toBe('existing'); + }); + + it('removes the marker when toggle() returns to follow-settings (AC2)', () => { + const { worker } = makeWorkerWithCwd(); + worker.toggle(); // → false, marker written + expect(existsSync(markerPath())).toBe(true); + worker.toggle(); // → null, marker removed + expect(worker.override).toBe(null); + expect(existsSync(markerPath())).toBe(false); + }); + + it('removing an absent marker is a no-op (AC2)', () => { + const { worker } = makeWorkerWithCwd({ override: false }); + worker.toggle(); // → null → remove absent marker + expect(worker.override).toBe(null); + expect(existsSync(markerPath())).toBe(false); + }); + + it('restores override=false at init when the marker exists (AC3)', () => { + writeFileSync(markerPath(), '', 'utf8'); + const { worker } = makeWorkerWithCwd(); + expect(worker.override).toBe(false); + expect(worker.enabled).toBe(false); + }); + + it('explicit opts.override wins over the marker (AC3)', () => { + writeFileSync(markerPath(), '', 'utf8'); + const { worker } = makeWorkerWithCwd({ override: true }); + expect(worker.override).toBe(true); + expect(worker.enabled).toBe(true); + }); + + it('with no marker, override starts null and follows settings (AC4)', () => { + const { worker } = makeWorkerWithCwd(); + expect(worker.override).toBe(null); + expect(worker.enabled).toBe(true); + }); + + it('the marker is scoped to the worklog root (AC5)', () => { + const otherRoot = join(root, 'other-root'); + mkdirSync(otherRoot, { recursive: true }); + writeFileSync(markerPath(), '', 'utf8'); // marker in cwd only + const { worker: a } = makeWorkerWithCwd(); + const cfgB = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: otherRoot, + noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + }; + const b = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ ...cfgB }), + }); + expect(a.override).toBe(false); + expect(b.override).toBe(null); + expect(b.enabled).toBe(true); + }); + + it('marker-restored disable yields zero dispatch across idle + cooldown windows (AC1/AC6)', async () => { + writeFileSync(markerPath(), '', 'utf8'); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + }); + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps, + config: () => ({ + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd, + noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + }), + }); + expect(worker.override).toBe(false); // restored from marker + vi.useFakeTimers(); + try { + // Advance well past the idle threshold and the no-candidate cooldown: + // a disabled worker must short-circuit on EVERY tick (zero polls, + // zero dispatch, zero pane spawns) regardless of elapsed time. + vi.setSystemTime(Date.now() + DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS + 60_000); + const r1 = await worker.tick(); + expect(r1).toEqual({ polled: false, dispatched: false, idle: false }); + const r2 = await worker.tick(); + expect(r2).toEqual({ polled: false, dispatched: false, idle: false }); + expect(deps.getNextItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/herdr/src/downtime-worker.sprint-view.test.ts b/packages/herdr/src/downtime-worker.sprint-view.test.ts new file mode 100644 index 000000000..2b24eb859 --- /dev/null +++ b/packages/herdr/src/downtime-worker.sprint-view.test.ts @@ -0,0 +1,1420 @@ +/** + * packages/herdr/src/downtime-worker.sprint-view.test.ts — Herdr-head + * critical-first scan and sprint-view dispatch-window tests. + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4VMEHO005X1QQ) to keep each test file focused + * on one concern and under the 2,000-line cap. Covers: + * - the critical-first scan on the live Herdr-head dispatch path + * (`WL-0MU6UL3XY001M3VT`), + * - `needsProducerReview` threading on the Herdr-head dispatch path + * (`WL-0MU72WJ8C0005GIE`), + * - RCA in-flight critical re-dispatch and one-dispatch-per-in-flight + * guarantees (`WL-0MUBEZ6PE002WLP4`, `WL-0MUBVKS5I007LSWB`), + * - the sprint-view-only dispatch window (`WL-0MUNS8X97007C9H9`), + * - dispatched success-marker staleness (`WL-0MU6UL0RJ008IHGT`), and + * - the non-terminal pane-close cooldown (`WL-0MUKYERLZ006ELL5`). + * + * Shared scaffolding (`makeDeps` and the other fixtures) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + * The critical selection / review-queue tests live in + * `downtime-worker.critical-first.test.ts`. + */ + +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + computeMostImportantItem, + dispatchDowntimeWork, + fetchExtendedHerdrItems, + selectCriticalFirstCandidates, + DOWNTIME_DISPATCH_EXTEND_MAX, + DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, + type DowntimeHerdrItem, + type DowntimeWorkerDeps, +} from './downtime-worker.js'; +import { + ago, + createTempDirTracker, + makeDeps, + writeDowntimeLog, +} from './downtime-worker.fixtures.js'; + +// ── Critical-first scan on Herdr-head path (WL-0MU6UL3XY001M3VT) ────── +// Regression tests for the critical-first scan that bypasses non-safety +// filters (dispatched-marker, review-queue gate) for critical items on +// the Herdr-head dispatch path. + +describe('critical-first scan on Herdr-head path (WL-0MU6UL3XY001M3VT)', () => { + const now = Date.now(); + const fresh = new Date(now - 60_000).toISOString(); + + const criticalPlanned = (id: string): DowntimeHerdrItem => ({ + id, title: `Critical plan ${id}`, status: 'open', stage: 'plan_complete', + risk: 'Low', effort: 'S', priority: 'critical', + sortIndex: 10, + }); + const criticalIdea = (id: string): DowntimeHerdrItem => ({ + id, title: `Critical idea ${id}`, status: 'open', stage: 'idea', + priority: 'critical', + sortIndex: 20, + }); + const criticalIntake = (id: string): DowntimeHerdrItem => ({ + id, title: `Critical intake ${id}`, status: 'open', stage: 'intake_complete', + priority: 'critical', + sortIndex: 30, + }); + const nonCriticalImplement = (id: string): DowntimeHerdrItem => ({ + id, title: `Implement ${id}`, status: 'open', stage: 'plan_complete', + risk: 'Low', effort: 'S', priority: 'medium', + sortIndex: 100, + }); + + describe('AC4(a) — critical item bypasses non-safety filters', () => { + it('a critical item with a stale dispatched marker dispatches ahead of a non-critical item', async () => { + // End-to-end incident simulation: a critical plan item carries a + // STANDING implement dispatch marker in the rolling log (its + // implementation aborted → status reset to open, marker never + // cleared). The normal loop would skip it forever; the critical-first + // scan must bypass the marker and escalate it ahead of the + // non-critical item. + const root = mkdtempSync(join(tmpdir(), 'herdr-crit-scan-marker-')); + mkdirSync(join(root, '.worklog'), { recursive: true }); + writeFileSync( + join(root, '.worklog', 'downtime-dispatches.log'), + JSON.stringify({ + at: new Date(now - 3_600_000).toISOString(), + cwd: root, + kind: 'implement', + itemId: 'CR-1', + stage: 'plan_complete', + dispatchedAt: new Date(now - 3_600_000).toISOString(), + message: 'Dispatched CR-1', + }) + '\n', + ); + try { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + criticalPlanned('CR-1'), + nonCriticalImplement('IMP-1'), + ]}), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('CR-1'); + expect(deps.claimItem).toHaveBeenCalledWith( + 'CR-1', { status: 'open', stage: 'plan_complete' }, root, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('critical item dispatches via risk-effort tier when stage is in_progress', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + { + id: 'CR-2', title: 'Critical retired stage', status: 'open', + stage: 'in_progress', priority: 'critical', + risk: 'Low', effort: 'S', sortIndex: 10, + }, + nonCriticalImplement('IMP-1'), + ]}), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('risk-effort'); + expect(outcome.candidate?.id).toBe('CR-2'); + }); + + it('a critical plan item dispatches via the plan tier (intake_complete stage)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + criticalIntake('CR-PLAN'), + nonCriticalImplement('IMP-1'), + ]}), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('CR-PLAN'); + }); + + it('a critical idea item dispatches via the intake tier', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + criticalIdea('CR-INTAKE'), + nonCriticalImplement('IMP-1'), + ]}), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('CR-INTAKE'); + }); + }); + + describe('AC4(b) — safety gates block critical escalation', () => { + it('needsProducerReview === true blocks a critical item from critical-first scan', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + { + ...criticalPlanned('CR-PR'), + needsProducerReview: true, + }, + nonCriticalImplement('IMP-1'), + ]}), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Safety gate blocks critical → falls through to non-critical. + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('IMP-1'); + }); + + it('code-freeze blocks critical implement but allows critical plan/intake', async () => { + const freezeDeps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + criticalPlanned('CR-FREEZE-IMP'), + criticalIntake('CR-FREEZE-PLAN'), + nonCriticalImplement('IMP-1'), + ]}), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(freezeDeps, { model: 'plan', cwd: '/repo' }); + + // Critical implement is freeze-blocked; critical plan dispatches. + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('CR-FREEZE-PLAN'); + }); + + it('active-audit single-flight is audit-tier scoped: an in-flight audit does not block open critical escalation', async () => { + // The active-audit single-flight gate filters AUDIT-kind candidates + // (a second audit pane must not start). An OPEN critical item is never + // audit-kind, so — matching the legacy critical-tier ordering — it is + // escalated even while an audit is in flight. + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + criticalPlanned('CR-AUDIT'), + nonCriticalImplement('IMP-1'), + ]}), + getActiveAudit: vi.fn().mockResolvedValue({ ok: true, active: true }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('CR-AUDIT'); + }); + }); + + describe('lowest sortIndex wins when multiple critical items exist', () => { + it('dispatches the lowest-sortIndex critical item first', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + criticalPlanned('CR-2'), // sortIndex: 10 + criticalIdea('CR-1'), // sortIndex: 20 + nonCriticalImplement('IMP-1'), + ]}), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + // CR-2 has lower sortIndex (10) than CR-1 (20). + expect(outcome.candidate?.id).toBe('CR-2'); + }); + }); + + describe('no critical items — normal processing unchanged', () => { + it('non-critical items dispatch as usual when no critical items exist', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ + nonCriticalImplement('IMP-1'), + ]}), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('IMP-1'); + }); + }); +}); + +// ── Herdr-head dispatch threads needsProducerReview (WL-0MU72WJ8C0005GIE) ── +// +// The live Herdr-head dispatch path (`dispatchFromHerdrList` → normal scan) +// builds its `classifyItemForDispatch` input from the Herdr item fields. Before +// the fix (022f1b24) it OMITTED `needsProducerReview`, so a non-critical item +// flagged for producer review was dispatched anyway — even though the offer +// path (`computeMostImportantItem`, which passes the item object directly) +// already honoured the flag. These tests pin AC1–AC3: the flag blocks a +// non-critical plan_complete / idea candidate, the direct path agrees with the +// offer path, and clearing the flag restores dispatchability. + +describe('Herdr-head dispatch threads needsProducerReview (WL-0MU72WJ8C0005GIE)', () => { + const implementItem = (id: string, overrides: Partial = {}): DowntimeHerdrItem => ({ + id, + title: `Non-critical ${id}`, + status: 'open', + stage: 'plan_complete', + priority: 'medium', + risk: 'Low', + effort: 'S', + sortIndex: 10, + ...overrides, + }); + + const intakeItem = (id: string, overrides: Partial = {}): DowntimeHerdrItem => ({ + id, + title: `Idea ${id}`, + status: 'open', + stage: 'idea', + priority: 'medium', + sortIndex: 10, + ...overrides, + }); + + const dispatchDeps = (items: DowntimeHerdrItem[]): DowntimeWorkerDeps => makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + }); + + describe('AC2 — the flag blocks a non-critical candidate on dispatchDowntimeWork', () => { + it('blocks a plan_complete item (implement) and dispatches the next eligible item', async () => { + const deps = dispatchDeps([ + implementItem('NPR-GATED', { needsProducerReview: true, sortIndex: 1 }), + implementItem('NPR-ALLOWED', { needsProducerReview: false, sortIndex: 2 }), + ]); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('NPR-ALLOWED'); + // The gated item was never claimed. + expect(deps.claimItem).not.toHaveBeenCalledWith('NPR-GATED', expect.any(Object), '/repo'); + }); + + it('blocks an idea item (intake) and dispatches the next eligible item', async () => { + const deps = dispatchDeps([ + intakeItem('NPR-IDEA-GATED', { needsProducerReview: true, sortIndex: 1 }), + intakeItem('NPR-IDEA-ALLOWED', { needsProducerReview: false, sortIndex: 2 }), + ]); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('NPR-IDEA-ALLOWED'); + expect(deps.claimItem).not.toHaveBeenCalledWith('NPR-IDEA-GATED', expect.any(Object), '/repo'); + }); + + it('dispatches nothing when every candidate is review-gated', async () => { + const deps = dispatchDeps([ + implementItem('NPR-ONLY', { needsProducerReview: true }), + ]); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('clearing the flag restores dispatchability (same item shape)', async () => { + const gated = implementItem('NPR-CLEAR', { needsProducerReview: true }); + const blocked = await dispatchDowntimeWork(dispatchDeps([gated]), { model: 'plan', cwd: '/repo' }); + expect(blocked.dispatched).toBe(false); + + gated.needsProducerReview = false; + const released = await dispatchDowntimeWork(dispatchDeps([gated]), { model: 'plan', cwd: '/repo' }); + expect(released.dispatched).toBe(true); + expect(released.candidate?.id).toBe('NPR-CLEAR'); + }); + }); + + describe('AC3 — direct and offer paths agree on the producer-review gate', () => { + it('both the direct dispatch and the offer skip the gated item and pick the same next item', async () => { + const items = [ + implementItem('NPR-PARITY-GATED', { needsProducerReview: true, sortIndex: 1 }), + implementItem('NPR-PARITY-ALLOWED', { needsProducerReview: false, sortIndex: 2 }), + ]; + + const direct = await dispatchDowntimeWork(dispatchDeps(items), { model: 'plan', cwd: '/repo' }); + const offer = await computeMostImportantItem(dispatchDeps(items), '/repo'); + + expect(direct.dispatched).toBe(true); + expect(direct.candidate?.id).toBe('NPR-PARITY-ALLOWED'); + expect(offer).toMatchObject({ ok: true, candidate: { id: 'NPR-PARITY-ALLOWED' } }); + }); + + it('the offer reports no candidate when every candidate is review-gated', async () => { + const offer = await computeMostImportantItem( + dispatchDeps([implementItem('NPR-OFFER-ONLY', { needsProducerReview: true })]), + '/repo', + ); + + expect(offer).toMatchObject({ ok: true, noCandidate: true }); + }); + }); +}); + +// ── RCA: duplicate re-dispatch of a critical in-flight item ───────────── +// Parent WL-0MUBEZ6PE002WLP4 / F1 WL-0MUBVKPLP006RRDJ (AC1.1). +// +// RED WITNESS (pre-fix). The critical-first scan +// (`dispatchFromHerdrList` → `selectCriticalFirstCandidates`) bypasses the +// dispatched-marker exclusion UNCONDITIONALLY, so the only thing preventing a +// second dispatch is the CAS claim inside `dispatchClaimedTier`. When the +// item's status has reverted to `open` at its marker's stage (H5 — e.g. an +// aborted `implement.py start` reset it, or `wl reviewed true` released +// it) while the first dispatch's pane is still live, the CAS succeeds and a +// SECOND pane is spawned for the same in-flight item. H1 (marker bypass) +// therefore cannot duplicate on its own; H1+H5 is the confirmed pair. +// +// The item is modelled as `open` at `plan_complete` on BOTH cycles (the +// status revert) with a live `working` downtime pane whose label suffix is +// the item id (the in-flight signal F3 consumes) and a fresh marker in the +// rolling log (written by cycle 1's dispatch). The claim is stubbed to +// succeed on both cycles, matching the genuine DB `open` state. +// +// `it.fails` keeps the suite green while the defect is open: the test body +// asserts the FIXED behaviour and therefore fails pre-fix. F3 +// (WL-0MUBVKXQJ000L8EO) flips it to `it` once the item-scoped in-flight +// guard lands; the RED→GREEN transition is the AC5.5 dependency evidence. +describe('RCA: critical in-flight re-dispatch with a live pane (WL-0MUBEZ6PE002WLP4 / AC1.1)', () => { + const criticalInFlight = (id: string): DowntimeHerdrItem => ({ + id, + title: `Critical in-flight ${id}`, + status: 'open', + stage: 'plan_complete', + risk: 'Low', + effort: 'S', + priority: 'critical', + sortIndex: 10, + }); + + it( + 'two idle cycles over a critical item open at its marker stage with a live pane dispatch exactly once (RED pre-fix)', + async () => { + const root = mkdtempSync(join(tmpdir(), 'herdr-crit-inflight-')); + mkdirSync(join(root, '.worklog'), { recursive: true }); + // A fresh implement marker written by cycle 1's dispatch — the item is + // STILL at the marker's dispatched-at stage (`plan_complete`) on cycle 2. + writeFileSync( + join(root, '.worklog', 'downtime-dispatches.log'), + JSON.stringify({ + at: new Date().toISOString(), + cwd: root, + kind: 'implement', + itemId: 'CR-DUP', + stage: 'plan_complete', + dispatchedAt: new Date().toISOString(), + message: 'Dispatched CR-DUP', + }) + '\n', + ); + try { + const deps = makeDeps({ + // The item is re-observed OPEN at its marker's stage on every cycle + // (the status revert, H5). The critical-first scan re-selects it. + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalInFlight('CR-DUP')] }), + // The CAS claim succeeds on both cycles because the DB genuinely + // says `open` at `plan_complete` (H5 is the necessary partner of H1). + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + // Cycle 1 sees NO pane (the dispatch has not happened yet); by cycle + // 2 the spawned pane is live and `working`. Pre-fix + // `dispatchFromHerdrList` never consults this, so cycle 2 duplicates. + getRunningDowntimePanes: vi + .fn() + .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) + .mockResolvedValue({ + ok: true, + count: 1, + paneIds: ['w1:p1'], + records: [ + { + paneId: 'w1:p1', + label: 'Downtime triggered implement Critical in-flight CR-DUP - CR-DUP', + agent: 'pi', + agentStatus: 'working', + }, + ], + }), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + + // Cycle 1 dispatches the critical item. + expect(first.dispatched).toBe(true); + expect(first.candidate?.id).toBe('CR-DUP'); + // Cycle 2 must NOT dispatch: a live working pane for the item exists. + // Pre-fix this observes 2 spawns (the duplicate); post-fix exactly 1. + expect(second.dispatched).toBe(false); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); + +// ── Regression: one dispatch per in-flight critical item (F2) ────────── +// Parent WL-0MUBEZ6PE002WLP4 / F2 WL-0MUBVKS5I007LSWB (AC5.1, AC5.3, +// AC5.4, AC5.6). Promotes F1's RED witness +// (WL-0MUBVKPLP006RRDJ) into the permanent, path-covering suite. +// +// The critical-first scan bypasses the dispatched-marker exclusion +// UNCONDITIONALLY and the CAS claim succeeds when the item's status has +// reverted to `open` at its marker's stage (H5), so a live pane does not +// currently prevent a second dispatch. These cases are RED for the in-flight +// variants before F3 (WL-0MUBVKXQJ000L8EO) lands and GREEN after. + +describe('one dispatch per in-flight critical item (WL-0MUBVKS5I007LSWB / AC5)', () => { + const criticalPlannedInFlight = (id: string): DowntimeHerdrItem => ({ + id, title: `Critical in-flight ${id}`, status: 'open', stage: 'plan_complete', + risk: 'Low', effort: 'S', priority: 'critical', sortIndex: 10, + }); + const nonCriticalStale = (id: string): DowntimeHerdrItem => ({ + id, title: `Stale ${id}`, status: 'open', stage: 'plan_complete', + risk: 'Low', effort: 'S', priority: 'medium', sortIndex: 100, + }); + + /** A `herdr pane list` record set with one pane for *itemId* at *status*. */ + const paneRecord = (itemId: string, agentStatus: string) => ({ + paneId: 'w1:p1', + label: `Downtime triggered implement Critical in-flight ${itemId} - ${itemId}`, + agent: 'pi', + agentStatus, + }); + + /** + * A live working pane result. `records` is the item-scoped signal F3 + * consumes (additive on RunningPanesResult); `count` stays the owner-lease + * qualifier only — never a dispatch limit (WL-0MU2EP6JL006A1U3). + */ + const liveWorkingPane = (itemId: string) => ({ + ok: true as const, + count: 1, + paneIds: ['w1:p1'], + records: [paneRecord(itemId, 'working')], + }); + + describe('AC5.1 — Herdr-head path: two cycles, exactly one dispatch', () => { + it('two idle cycles over a critical item open at its marker stage with a live working pane dispatch once (RED pre-fix)', async () => { + const root = mkdtempSync(join(tmpdir(), 'herdr-head-inflight-')); + mkdirSync(join(root, '.worklog'), { recursive: true }); + writeFileSync( + join(root, '.worklog', 'downtime-dispatches.log'), + JSON.stringify({ + at: new Date().toISOString(), cwd: root, kind: 'implement', itemId: 'CR-INFLIGHT', + stage: 'plan_complete', dispatchedAt: new Date().toISOString(), message: 'Dispatched CR-INFLIGHT', + }) + '\n', + ); + try { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-INFLIGHT')] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + // Cycle 1: no pane yet. Cycle 2: the spawned pane is live and working. + getRunningDowntimePanes: vi + .fn() + .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) + .mockResolvedValue(liveWorkingPane('CR-INFLIGHT')), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + + expect(first.dispatched).toBe(true); + expect(second.dispatched).toBe(false); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + describe('AC5.4 — live-pane variants', () => { + it('a live `working` pane blocks the second cycle (stage advancement irrelevant while open)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-W')] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + // Cycle 1: no pane yet. Cycle 2: the spawned pane is live and working. + getRunningDowntimePanes: vi + .fn() + .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) + .mockResolvedValue(liveWorkingPane('CR-W')), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(first.dispatched).toBe(true); + expect(second.dispatched).toBe(false); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('a `done` pane does NOT block dispatch (no idle-pane deadlock, AC2.3)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-DONE')] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, count: 0, paneIds: [], records: [paneRecord('CR-DONE', 'done')], + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.candidate?.id).toBe('CR-DONE'); + }); + + it('an `exited` pane does NOT block dispatch (no idle-pane deadlock, AC2.3)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-EXIT')] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, count: 0, paneIds: [], records: [paneRecord('CR-EXIT', 'exited')], + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.candidate?.id).toBe('CR-EXIT'); + }); + + it('a failed/unparseable pane query never duplicating: the fresh marker still holds the second cycle (AC2.2/AC3.3)', async () => { + const root = mkdtempSync(join(tmpdir(), 'herdr-head-panefail-')); + mkdirSync(join(root, '.worklog'), { recursive: true }); + writeFileSync( + join(root, '.worklog', 'downtime-dispatches.log'), + JSON.stringify({ + at: new Date().toISOString(), cwd: root, kind: 'implement', itemId: 'CR-PANEFAIL', + stage: 'plan_complete', dispatchedAt: new Date().toISOString(), message: 'Dispatched CR-PANEFAIL', + }) + '\n', + ); + try { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-PANEFAIL')] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + // Cycle 1: pane query succeeds (none) so the dispatch proceeds. + // Cycle 2: herdr is unavailable — the in-flight state cannot be + // proven, so the fresh marker must hold the second dispatch. + getRunningDowntimePanes: vi + .fn() + .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) + .mockResolvedValue({ ok: false, error: 'herdr down' }), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + + expect(first.dispatched).toBe(true); + expect(second.dispatched).toBe(false); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + describe('AC5.3 — stale-marker coverage beyond the critical tier', () => { + it('a non-critical item at the same stage with a fresh marker and a live pane is not re-dispatched', async () => { + const root = mkdtempSync(join(tmpdir(), 'herdr-head-noncrit-')); + mkdirSync(join(root, '.worklog'), { recursive: true }); + writeFileSync( + join(root, '.worklog', 'downtime-dispatches.log'), + JSON.stringify({ + at: new Date().toISOString(), cwd: root, kind: 'implement', itemId: 'IMP-STALE', + stage: 'plan_complete', dispatchedAt: new Date().toISOString(), message: 'Dispatched IMP-STALE', + }) + '\n', + ); + try { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [nonCriticalStale('IMP-STALE')] }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, count: 1, paneIds: ['w1:p1'], + records: [{ paneId: 'w1:p1', label: 'Downtime triggered implement Stale IMP-STALE - IMP-STALE', agent: 'pi', agentStatus: 'working' }], + }), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); + + expect(first.dispatched).toBe(false); + expect(second.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + describe('AC5.6 — no idle-pane deadlock: a completed skill still advances to its next tier', () => { + it('an item whose previous skill completed (pane present, agent not working) still dispatches its next tier', async () => { + // The pane for the item is still OPEN but its agent is `done`: the item + // is at intake_complete with a completed intake pane. It must dispatch + // the plan tier, not deadlock on the stale pane. + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ + ok: true, + items: [{ id: 'WL-NEXT', title: 'Next tier', status: 'open', stage: 'intake_complete', priority: 'high', risk: 'Low', effort: 'S', sortIndex: 10 }], + }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, count: 0, paneIds: [], + records: [{ paneId: 'w1:p1', label: 'Downtime triggered intake Next tier - WL-NEXT', agent: 'pi', agentStatus: 'done' }], + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-NEXT'); + }); + }); +}); + +// ── Dispatch-window extension — Herdr head-cap starvation (WL-0MU6UL3GQ0015AA5) ── +// The dispatcher iterates the Herdr list head, which is windowed: mandatory +// items (critical + completed/in_review) are always included and consume +// slots, so when they are numerous the first genuinely dispatchable candidate +// can fall beyond the window. These tests reproduce the 2026-09-18 starvation +// (a 30-item head, all blocked; the only dispatchable item was the 22nd +// "other", cut off by the cap) and assert the bounded window extension finds +// it in the SAME ranking order. +// +// Blocking is done by CLASSIFICATION SHAPE (a non-dispatchable stage for +// open items, out-of-recency for completed/in_review) rather than by the +// `needsProducerReview` flag: the window-extension tests deliberately isolate +// the window mechanism from the review gate. (The flag now blocks on this +// path too — the field is threaded into `classifyItemForDispatch` as of +// WL-0MU72WJ8C0005GIE / 022f1b24.) + +describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0MU6UL3GQ0015AA5 AC1/AC4)', () => { + const OLD = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); + + /** + * A non-dispatchable filler item: `open` status but a stage with no dispatch + * kind (`in_review`). Previously these filler items were made + * non-dispatchable by an above-cap risk, but the implement risk cap was + * removed (WL-0MUBUA9C8009N0K5); the non-dispatchable stage is now the + * stable filler shape. + */ + const blockedImplement = (id: string, overrides: Partial = {}): DowntimeHerdrItem => ({ + id, + title: `Blocked ${id}`, + status: 'open', + stage: 'in_review', + priority: 'medium', + risk: 'High', + effort: 'S', + sortIndex: 100, + ...overrides, + }); + + /** A completed/in_review item outside the audit recency window (never dispatchable). */ + const blockedReview = (id: string, overrides: Partial = {}): DowntimeHerdrItem => ({ + id, + title: `Blocked review ${id}`, + status: 'completed', + stage: 'in_review', + priority: 'medium', + updatedAt: OLD, + sortIndex: 2, + ...overrides, + }); + + /** 1 blocked critical + 8 blocked completed/in_review = the 9 mandatory slots. */ + const mandatorySet = (): DowntimeHerdrItem[] => [ + blockedImplement('CG-CRIT', { priority: 'critical', sortIndex: 1 }), + ...Array.from({ length: 8 }, (_, i) => blockedReview(`CG-REV${i + 1}`, { sortIndex: 2 + i })), + ]; + + /** 21 blocked "other" items fill the remaining window slots (total head = 30). */ + const blockedOthers = (): DowntimeHerdrItem[] => + Array.from({ length: 21 }, (_, i) => blockedImplement(`CG-BLK${i + 1}`, { sortIndex: 20 + i })); + + /** The only genuinely dispatchable item (high, idea, no markers, not review-gated). */ + const dispatchableIntake = (id: string): DowntimeHerdrItem => ({ + id, + title: `Dispatchable ${id}`, + status: 'open', + stage: 'idea', + priority: 'high', + sortIndex: 999, + }); + + /** A dispatchable CRITICAL implement item (the AC4 escape-hatch shape). */ + const dispatchableCriticalImplement = (id: string): DowntimeHerdrItem => ({ + id, + title: `Critical ${id}`, + status: 'open', + stage: 'plan_complete', + priority: 'critical', + risk: 'Low', + effort: 'S', + sortIndex: 500, + }); + + const fullHead = (): DowntimeHerdrItem[] => [...mandatorySet(), ...blockedOthers()]; + + describe('AC1 — head limit equals the live sprint-view browseItemCount', () => { + it('direct dispatch passes the effective browseItemCount to getHerdrListHead', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 20); + }); + + it('clamps an out-of-range browseItemCount to the supported [1, 50] range', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 999 }); + expect(getHerdrListHead).toHaveBeenLastCalledWith('/repo', 50); + + getHerdrListHead.mockClear(); + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 0 }); + expect(getHerdrListHead).toHaveBeenLastCalledWith('/repo', 1); + }); + + it('defaults to the sprint-view default (20) when browseItemCount is undefined', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 20); + }); + + it('computeMostImportantItem passes the effective browseItemCount to getHerdrListHead', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await computeMostImportantItem(deps, '/repo', Date.now(), 33); + + expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 33); + }); + + it('re-reads the live browseItemCount on every dispatch cycle (settings change applies without restart)', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 15 }); + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 40 }); + + expect(getHerdrListHead).toHaveBeenNthCalledWith(1, '/repo', 15); + expect(getHerdrListHead).toHaveBeenNthCalledWith(2, '/repo', 40); + }); + }); + + describe('AC3 — regression: the dispatchable item beyond the window cap is found', () => { + it('regression (WL-0MU6UL3GQ0015AA5 updated): a non-critical item beyond the sprint view is NOP, not dispatched', async () => { + const initialHead = fullHead(); + const target = dispatchableIntake('CG-0MTZO0YIM000VAIQ'); + const getHerdrListHead = vi.fn() + .mockResolvedValueOnce({ ok: true, items: initialHead }) + .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); + const deps = makeDeps({ getHerdrListHead }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + // The out-of-window item is NON-critical: the sprint-view-only contract + // reports no-candidate instead of dispatching hidden backlog work + // (supersedes WL-0MU6UL3GQ0015AA5 for non-critical work). + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.claimItem).not.toHaveBeenCalled(); + // The critical-only extension still consulted the SAME ranking path + // (never a second ranking). + expect(getHerdrListHead).toHaveBeenCalledTimes(2); + expect(getHerdrListHead).toHaveBeenNthCalledWith( + 2, + '/repo', + initialHead.length + DOWNTIME_DISPATCH_EXTEND_MAX, + ); + }); + + it('the coordination check-in offer (computeMostImportantItem) also no-ops on a non-critical out-of-window item', async () => { + const initialHead = fullHead(); + const target = dispatchableIntake('CG-OFFER'); + const getHerdrListHead = vi.fn() + .mockResolvedValueOnce({ ok: true, items: initialHead }) + .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); + const deps = makeDeps({ getHerdrListHead }); + + const result = await computeMostImportantItem(deps, '/repo'); + + expect(result).toMatchObject({ ok: true, noCandidate: true }); + expect(getHerdrListHead).toHaveBeenCalledTimes(2); + }); + + it('prefers an in-window candidate over the extended window (no extra lookup when the head suffices)', async () => { + const inWindow = dispatchableIntake('CG-INWINDOW'); + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [inWindow, ...blockedOthers()] }); + const deps = makeDeps({ getHerdrListHead }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.candidate?.id).toBe('CG-INWINDOW'); + // The head already yielded a candidate — the extension never runs. + expect(getHerdrListHead).toHaveBeenCalledTimes(1); + }); + + it('stays code-freeze under a freeze even when a non-critical item lies beyond the window', async () => { + // Under a freeze, audit/implement candidates are paused; a non-critical + // item beyond the sprint view is not an escape hatch, so the terminal + // reason stays derived from the in-view flags. + const initialHead = fullHead(); + const target = dispatchableIntake('CG-FROZEN-OUT-OF-WINDOW'); + const getHerdrListHead = vi.fn() + .mockResolvedValueOnce({ ok: true, items: initialHead }) + .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getHerdrListHead, + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); + }); + }); + + describe('AC2 — bounded extension, no worklist regression, terminal reasons preserved', () => { + it('stays no-candidate when the extended window adds nothing (mock ignores the limit)', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: fullHead() }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.claimItem).not.toHaveBeenCalled(); + }); + + it('reports code-freeze when a frozen head plus extension still yields no candidate', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + // Both the head and the extension return the same blocked items. + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: fullHead() }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); + }); + + it('the extension never invents a new failure: a failed extended lookup degrades to no-candidate', async () => { + const getHerdrListHead = vi.fn() + .mockResolvedValueOnce({ ok: true, items: fullHead() }) + .mockResolvedValueOnce({ ok: false, error: 'boom' }); + const deps = makeDeps({ getHerdrListHead }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(getHerdrListHead).toHaveBeenCalledTimes(2); + }); + }); + + describe('AC4 — critical items remain dispatchable', () => { + it('a critical item in the head still dispatches while every non-critical item is filtered', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ + ok: true, + items: [dispatchableCriticalImplement('CG-CRIT-DISPATCH'), ...blockedOthers()], + }); + const deps = makeDeps({ getHerdrListHead }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('CG-CRIT-DISPATCH'); + }); + + it('the critical-only extension still reaches an out-of-window critical item', async () => { + const initialHead = fullHead(); + const target = dispatchableCriticalImplement('CG-CRIT-OUT-OF-WINDOW'); + const getHerdrListHead = vi.fn() + .mockResolvedValueOnce({ ok: true, items: initialHead }) + .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); + const deps = makeDeps({ getHerdrListHead }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('CG-CRIT-OUT-OF-WINDOW'); + }); + }); + + describe('fetchExtendedHerdrItems — critical-only escape hatch primitive', () => { + const item = (id: string, priority = 'medium'): DowntimeHerdrItem => ({ + id, + title: `Item ${id}`, + status: 'open', + stage: 'idea', + priority, + }); + + it('returns only newly visible CRITICAL items, preserving the canonical order', async () => { + const current = [item('A')]; + const dep = vi.fn().mockResolvedValue({ + ok: true, + items: [item('A'), item('B'), item('C', 'critical'), item('D'), item('E', 'critical')], + }); + const deps = makeDeps({ getHerdrListHead: dep }); + + const extended = await fetchExtendedHerdrItems(deps, '/repo', current); + + expect(extended.map((i) => i.id)).toEqual(['C', 'E']); + expect(extended.every((i) => i.priority === 'critical')).toBe(true); + expect(dep).toHaveBeenCalledWith('/repo', 1 + DOWNTIME_DISPATCH_EXTEND_MAX); + }); + + it('never returns a non-critical item beyond the sprint view (AC4)', async () => { + const current = [item('A')]; + const dep = vi.fn().mockResolvedValue({ + ok: true, + items: [item('A'), item('B', 'high'), item('C'), item('D', 'low')], + }); + const deps = makeDeps({ getHerdrListHead: dep }); + + expect(await fetchExtendedHerdrItems(deps, '/repo', current)).toEqual([]); + }); + + it('returns [] when the extended head adds nothing (limit-ignoring dep)', async () => { + const current = [item('A')]; + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [item('A')] }), + }); + + expect(await fetchExtendedHerdrItems(deps, '/repo', current)).toEqual([]); + }); + + it('fails open to [] on a {ok:false} extended lookup', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: false, error: 'boom' }), + }); + + expect(await fetchExtendedHerdrItems(deps, '/repo', [item('A')])).toEqual([]); + }); + + it('fails open to [] when the extended lookup throws', async () => { + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockRejectedValue(new Error('boom')), + }); + + expect(await fetchExtendedHerdrItems(deps, '/repo', [item('A')])).toEqual([]); + }); + }); +}); + +// ── Dispatched success-marker staleness — live Herdr-head path +// (WL-0MU6UL0RJ008IHGT AC1/AC2/AC4) ───────────────────────────── +// +// A SUCCESS dispatch marker (no `outcome`) historically excluded its item +// forever: a pane that spawned but whose agent never advanced the item +// (crash, manual close, silent failure) stranded it permanently — the item +// was invisible to the tier that would retry it. These tests pin the live +// `dispatchFromHerdrList`/`computeMostImportantItem` marker filter: a marker +// excludes ONLY while the item is STILL at the marker's dispatched-at stage +// AND the marker is fresh (age <= the configured staleness window). The +// times are relative to REAL `Date.now()` because the dispatch path reads +// the clock itself. Items here are NON-critical so they exercise the normal +// (non-critical-first) filter path. +describe('dispatched success-marker staleness on the live path (WL-0MU6UL0RJ008IHGT)', () => { + const WINDOW_MS = 24 * 60 * 60 * 1000; // default 24h + const tempCwds = createTempDirTracker(); + + afterEach(() => { + tempCwds.cleanup(); + }); + + function makeCwd(): string { + return tempCwds.make('dt-stale-marker-'); + } + + const writeLog = writeDowntimeLog; + + const planHead = (id: string): DowntimeHerdrItem => ({ + id, + title: `Plan ${id}`, + status: 'open', + stage: 'intake_complete', + priority: 'medium', + sortIndex: 30, + }); + const implementHead = (id: string): DowntimeHerdrItem => ({ + id, + title: `Implement ${id}`, + status: 'open', + stage: 'plan_complete', + risk: 'Low', + effort: 'S', + priority: 'medium', + sortIndex: 20, + }); + + it('AC1: a stale success marker at the unchanged stage no longer excludes — the item is re-dispatched', async () => { + const cwd = makeCwd(); + // Plan marker written >24h ago for an item STILL at intake_complete — the + // pane spawned but never advanced it. Must be released on the live path. + writeLog(cwd, [ + { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(WINDOW_MS + 60_000) }, + ]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('PLN-1'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan PLN-1'), + expect.anything(), + ); + }); + + it('a FRESH success marker at the unchanged stage still excludes (no double dispatch)', async () => { + const cwd = makeCwd(); + // Plan marker 1h ago for an item still at intake_complete → genuinely + // in-flight; the tier must skip it and report the neutral no-candidate. + writeLog(cwd, [ + { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60 * 60 * 1000) }, + ]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('AC2: an item that ADVANCED past the marker stage is released regardless of age', async () => { + const cwd = makeCwd(); + // Marker says intake_complete but the item is now at plan_complete — the + // history released it via the change-guard; it must still release today. + writeLog(cwd, [ + { itemId: 'IMP-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60 * 1000) }, + ]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('IMP-1'); + }); + + it('id-guard tier: a stale IMPLEMENT marker at the unchanged stage is released', async () => { + const cwd = makeCwd(); + writeLog(cwd, [ + { itemId: 'IMP-1', kind: 'implement', stage: 'plan_complete', dispatchedAt: ago(WINDOW_MS + 60_000) }, + ]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('IMP-1'); + }); + + it('id-guard tier: a fresh IMPLEMENT marker still excludes (duplicate-dispatch protection)', async () => { + const cwd = makeCwd(); + writeLog(cwd, [ + { itemId: 'IMP-1', kind: 'implement', stage: 'plan_complete', dispatchedAt: ago(60 * 60 * 1000) }, + ]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('id-guard tier: a legacy implement marker WITHOUT a stage keeps excluding while fresh, releases when stale', async () => { + const cwd = makeCwd(); + // Fresh legacy marker (no stage field — pre-change-guard entry): the + // id-guard must NOT weaken protection → still excluded. + writeLog(cwd, [{ itemId: 'IMP-1', kind: 'implement', dispatchedAt: ago(60 * 60 * 1000) }]); + const freshDeps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + }); + const freshOutcome = await dispatchDowntimeWork(freshDeps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); + expect(freshOutcome.dispatched).toBe(false); + expect(freshOutcome.reason).toBe('no-candidate'); + + // Same legacy marker but stale → the age TTL releases it. + writeLog(cwd, [{ itemId: 'IMP-1', kind: 'implement', dispatchedAt: ago(WINDOW_MS + 60_000) }]); + const staleDeps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), + }); + const staleOutcome = await dispatchDowntimeWork(staleDeps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); + expect(staleOutcome.dispatched).toBe(true); + expect(staleOutcome.kind).toBe('implement'); + }); + + it('computeMostImportantItem releases a stale marker at the unchanged stage (offers re-triable item)', async () => { + const cwd = makeCwd(); + writeLog(cwd, [ + { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(WINDOW_MS + 60_000) }, + ]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, WINDOW_MS); + expect(result.ok).toBe(true); + if (result.ok && 'candidate' in result) { + expect(result.candidate.id).toBe('PLN-1'); + expect(result.kind).toBe('plan'); + } else { + throw new Error(`expected a candidate offer, got ${JSON.stringify(result)}`); + } + }); + + it('computeMostImportantItem keeps excluding a FRESH marker at the unchanged stage (no offer)', async () => { + const cwd = makeCwd(); + writeLog(cwd, [ + { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60 * 60 * 1000) }, + ]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, WINDOW_MS); + expect(result.ok).toBe(true); + if (result.ok) expect('noCandidate' in result).toBe(true); + }); +}); + +// ── Non-terminal pane-close cooldown — live paths (WL-0MUKYERLZ006ELL5) ── + +describe('non-terminal pane-close cooldown on the live paths (WL-0MUKYERLZ006ELL5)', () => { + const COOLDOWN_MS = DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS; + const tempCwds = createTempDirTracker(); + + afterEach(() => { + tempCwds.cleanup(); + }); + + function makeCwd(): string { + return tempCwds.make('dt-nonterminal-cooldown-'); + } + + const writeLog = writeDowntimeLog; + + const planHead = (id: string): DowntimeHerdrItem => ({ + id, + title: `Plan ${id}`, + status: 'open', + stage: 'intake_complete', + priority: 'medium', + sortIndex: 30, + }); + + /** A non-terminal pane-close entry for the plan candidate. */ + function closeEntry(overrides: Record = {}): Record { + return { + entryType: 'pane-close', + itemId: 'PLN-1', + kind: 'plan', + stage: 'intake_complete', + reasonCode: 'agent-ended-no-terminal', + outcome: 'requires-attention', + timestamp: ago(60_000), + closed: true, + ...overrides, + }; + } + + it('AC1: a recent non-terminal close holds re-dispatch with the neutral cooldown reason', async () => { + const cwd = makeCwd(); + writeLog(cwd, [closeEntry()]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); + + expect(outcome.dispatched).toBe(false); + // Distinguishable from 'no-candidate' so the no-candidate cooldown is + // not entered and no strike is recorded (AC3/AC5). + expect(outcome.reason).toBe('non-terminal-cooldown'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.recordError).not.toHaveBeenCalled(); + expect(deps.recordDispatchFailure).not.toHaveBeenCalled(); + }); + + it('AC2: a close older than cooldownMs releases the item (dispatches normally)', async () => { + const cwd = makeCwd(); + writeLog(cwd, [closeEntry({ timestamp: ago(COOLDOWN_MS + 1) })]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('PLN-1'); + }); + + it('AC3: stage advancement mid-cooldown releases the item immediately', async () => { + const cwd = makeCwd(); + // Dispatched at `idea`; the item has since advanced to `intake_complete`. + writeLog(cwd, [closeEntry({ stage: 'idea' })]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.candidate?.id).toBe('PLN-1'); + }); + + it('AC4: a terminal close (reasonCode none) does not apply the cooldown', async () => { + const cwd = makeCwd(); + writeLog(cwd, [closeEntry({ reasonCode: 'none', outcome: 'closed-as-plan-complete' })]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); + + expect(outcome.dispatched).toBe(true); + }); + + it('applies the cooldown per-kind: an intake close does not hold a plan re-dispatch', async () => { + const cwd = makeCwd(); + writeLog(cwd, [closeEntry({ kind: 'intake', itemId: 'PLN-1' })]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + it('AC7: computeMostImportantItem reports a distinct cooldown hold, never noCandidate', async () => { + const cwd = makeCwd(); + writeLog(cwd, [closeEntry()]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + + const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, undefined, COOLDOWN_MS); + + expect(result.ok).toBe(true); + if (result.ok) { + expect('nonTerminalCooldownHold' in result).toBe(true); + expect('noCandidate' in result).toBe(false); + // The offer-computation hold is NOT a candidate offer. + expect('candidate' in result).toBe(false); + } + }); + + it('AC7: computeMostImportantItem offers a cooldown-elapsed item normally', async () => { + const cwd = makeCwd(); + writeLog(cwd, [closeEntry({ timestamp: ago(COOLDOWN_MS + 1) })]); + const deps = makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), + }); + + const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, undefined, COOLDOWN_MS); + + expect(result.ok).toBe(true); + if (result.ok && 'candidate' in result) { + expect(result.candidate.id).toBe('PLN-1'); + expect(result.kind).toBe('plan'); + } else { + throw new Error(`expected a candidate offer, got ${JSON.stringify(result)}`); + } + }); +}); diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index c9e94ca5d..9345590db 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -55,7 +55,7 @@ * regression) until the implementation slice lands (WL-0MT47BQAT00375VB). */ -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -65,21 +65,11 @@ import { parseLlamaStatus, fetchLocalStatus, createDowntimePoller, - createIdleTracker, - createPerSlotIdleTracker, dispatchDowntimeWork, computeMostImportantItem, dispatchFromCoordination, fetchExtendedHerdrItems, createDowntimeWorker, - buildDowntimePrompt, - buildDowntimePaneArgs, - spawnDowntimePane, - buildDowntimeSpawnOptions, - scrubRepositoryOverrides, - REPOSITORY_OVERRIDE_ENV_VARS, - BLOCKED_QUESTIONS_INSTRUCTION, - parseNextItemOutput, parseNextCandidatesOutput, parseImplementCandidatesOutput, selectImplementCandidate, @@ -92,11632 +82,81 @@ import { criticalSkillKind, resolveDependencyFrontier, parseAuditCandidatesOutput, - parseInProgressOutput, selectAuditCandidate, selectWithRotation, - toDowntimeCandidate, classifyItemForDispatch, selectCriticalFirstCandidates, - skillKindFromPrompt, - buildDowntimeDispatchComment, - clampDowntimePollInterval, - clampDowntimeIdleThresholdMs, - clampDowntimeRequiredFreeSlots, - clampDowntimeNoCandidateCooldownMs, - clampDowntimeNonTerminalCooldownMs, - clampDowntimeMarkerStaleWindowMs, clampDowntimeMaxAttempts, resumeStalledWorkIfAble, DEFAULT_DOWNTIME_MAX_ATTEMPTS, DOWNTIME_MAX_ATTEMPTS_MIN, DOWNTIME_MAX_ATTEMPTS_MAX, countFreeUnownedSlots, - isSlotOwned, - parseHerdrPaneListOutput, - countRunningDowntimePanes, - paneLabelItemId, - isSafeDowntimePaneToClose, - runningDowntimePaneItemIds, - evaluateCriticalFirstGuard, - resolveInFlightPanes, - monitorDispatchedPanes, - DOWNTIME_PANE_LIFECYCLE_INTERVAL_MS, - DOWNTIME_POLL_INTERVAL_FLOOR_MS, DEFAULT_DOWNTIME_POLL_INTERVAL_MS, DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, - DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS, - DOWNTIME_NON_TERMINAL_COOLDOWN_MAX_MS, - DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, - DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS, - DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, - DOWNTIME_PANE_LABEL_PREFIX, type LlamaStatus, - type LlamaSlot, - type LlamaStatusFetcher, type DowntimeCandidate, type DowntimeWorkerDeps, - type DowntimeSpawn, - type DowntimeStage, type AuditCandidate, type ImplementCandidate, type CriticalCandidate, type ScheduledPrompt, - type DowntimeActiveAuditResult, type DowntimeItemInfo, type DowntimeHerdrItem, - type HerdrPaneRecord, - type InFlightPanes, - isTransientDowntimeError, - withTransientRetry, - MIN_BROWSE_ITEM_COUNT, - MAX_BROWSE_ITEM_COUNT, DOWNTIME_DISPATCH_EXTEND_MAX, DOWNTIME_DECISION_LOG_MIN_INTERVAL_MS, decisionHeaderToken, LOCAL_DISPATCH_LEASE_MAX_SECONDS, } from './downtime-worker.js'; -import { - clampBrowseItemCount, - clampDowntimeRequiredFreeSlots as clampDowntimeRequiredFreeSlotsSetting, -} from './settings.js'; -import { - DOWNTIME_DISABLE_MARKER_FILE, - disableMarkerPath, - writeDisableMarker, -} from './downtime-disable-marker.js'; -import { createRoundRobinRegistry } from './downtime-round-robin.js'; -import { - DOWNTIME_LOG_FILE, - appendDowntimeLogEntry, - appendPaneCloseLogEntry, - readDowntimeLogEntries, - readCoordinationLogEntries, - type CoordinationLogEntry, -} from './downtime-log.js'; -import { - LEASE_FILE, - LEADER_LOCK_FILE, - isLeaseValid, -} from './leader-election.js'; -import { - writeCoordinationFile, - type CoordinationEntry, -} from './coordination.js'; -import { - statusFixtures, - ambiguousMissingFieldsRaw, - idleAllSlotsFree, - perSlotAllFree, - perSlotTwoFree, - perSlotOneProcessing, - perSlotThreeOfFourFree, - perSlotOneOfThreeFree, - singleSlotIdleOwned, - perSlotIdleOwned, - perSlotOwnedOneUnowned, - idleWithContention, - idleWithCumulativeContention, - herdrPaneListRaw, - networkErrorFixture, - timeoutErrorFixture, - httpErrorResponseFixture, - jsonResponseFixture, - type LlamaStatusHttpResponse, -} from './downtime-worker.fixtures.js'; -import { - resumeStalledPane, - _resetStalledResumeInFlight, - type ResumeStalledPaneDeps, - type StalledPaneCandidate, -} from './stalled-work.js'; - - -/** Shared deps mock for dispatch tests. */ -function makeDeps(overrides: Partial = {}): DowntimeWorkerDeps { - return { - // Herdr list head (WL-0MTK1ILM2009QYB2): empty by default so legacy tests that - // stub per-tier lookups keep passing (the head path is inert on empty). - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [] }), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - // Audit tier answers a GENUINELY empty tier by default ({ok:true, - // candidate:null}); a wl/parse failure is {ok:false} (WL-0MSLWJ2KP0002SV0). - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - // Active-audit single-flight (WL-0MT3PHW4I002SNOV): no active audit by - // default ({ok:true, active:false}), so the existing tier tests exercise - // the unchanged audit tier (backward-compatible default — the audit - // tier proceeds). The dispatcher consults this dep only after F2 lands; - // existing tests are unaffected either way. - getActiveAudit: vi.fn().mockResolvedValue({ ok: true, active: false }), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - recordDispatchFailure: vi.fn().mockResolvedValue(undefined), - // Claim rollback (WL-0MT32F908002YFFA): default success so the recovery - // paths report 'claim-rolled-back' unless a test forces a failed rollback. - rollbackClaim: vi.fn().mockResolvedValue(true), - recordError: vi.fn().mockResolvedValue(undefined), - // Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX): no due prompt by - // default, so existing tier tests exercise the unchanged backlog tiers. - getDueScheduledPrompt: vi.fn().mockResolvedValue(null), - recordScheduledPromptTrigger: vi.fn().mockResolvedValue(true), - // Critical-first tier (WL-0MT3FM8VA005XBHE): no critical candidate by - // default, so the existing tier tests exercise the unchanged backlog - // tiers (regression guard — after F4 the critical tier must fall - // through to implement → plan → intake when the critical tier is - // genuinely empty). - getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - // Code-freeze gate (WL-0MSQ0RPQP00636JY): not frozen by default, so - // existing dispatch tests exercise the unchanged audit/implement tiers. - readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), - // Interim freshness re-check (WL-0MT8KSTOE00871E7): no fresh audit by - // default, so existing dispatch tests exercise the unchanged path. - hasFreshAudit: vi.fn().mockResolvedValue(false), - // Review-queue depth gate (WL-0MT2UQWOR007CYY9): empty queue by default - // so the gate is inactive and existing tests exercise the unchanged path. - getReviewQueueCount: vi.fn().mockResolvedValue(0), - // Item-scoped in-flight guard (WL-0MUBEZ6PE002WLP4 / F3): an available - // query reporting NO live panes by default, so the guard is inert and - // existing tests escalate critical items unchanged. Tests that exercise - // the guard override this with `records` carrying a live working pane. - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }), - ...overrides, - }; -} - -// ── Fixture coherence (AC1) ─────────────────────────────────────────── - -describe('downtime worker fixtures', () => { - it.each(statusFixtures)( - '$name is classified idle=$expectedIdle by isIdleStatus (N=0)', - (fixture) => { - expect(isIdleStatus(fixture.status, 0)).toBe(fixture.expectedIdle); - }, - ); - - it('a raw payload with missing slot fields is treated as busy (fail-closed)', () => { - const partial = ambiguousMissingFieldsRaw as unknown as LlamaStatus; - expect(isIdleStatus(partial, 0)).toBe(false); - }); -}); - -// ── Idle detection (AC2) ────────────────────────────────────────────── - -describe('idle detection (isIdleStatus)', () => { - const idle: LlamaStatus = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 4, - total_slots: 4, - }; - - it('is idle when the server is up, nothing active, and all slots free', () => { - expect(isIdleStatus(idle, 0)).toBe(true); - }); - - it('is busy when llama-server is not running', () => { - expect(isIdleStatus({ ...idle, llama_server_running: false }, 0)).toBe(false); - }); - - it('is busy when local_active_query=true (a local query is in flight)', () => { - expect(isIdleStatus({ ...idle, local_active_query: true }, 0)).toBe(false); - }); - - it('is busy when local_active_query is absent (fail-closed on pre-fix proxy)', () => { - // A payload without local_active_query (pre-fix proxy) fails closed: - // the worker must never dispatch on an unverifiable busy signal. - const partial: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 4, - total_slots: 4, - }; - expect(isIdleStatus(partial, 0)).toBe(false); - }); - - it('is idle during remote-only traffic when local_active_query=false', () => { - // Remote provider streams keep the global query signal true while the - // local model is idle with free slots; the proxy's local_active_query is - // the local-only signal (LP-0MSL2ZLLS009RVKR) and must not block dispatch. - expect(isIdleStatus({ ...idle, local_active_query: false }, 0)).toBe(true); - }); - - it('is busy while a model switch is in progress', () => { - expect(isIdleStatus({ ...idle, model_switch_in_progress: true }, 0)).toBe(false); - }); - - it('is busy while a local lease is active', () => { - expect(isIdleStatus({ ...idle, local_lease_active: true }, 0)).toBe(false); - }); - - it('N=0 (default) requires ALL slots free', () => { - expect(isIdleStatus({ ...idle, available_slots: 3 }, 0)).toBe(false); - expect(isIdleStatus({ ...idle, available_slots: 4 }, 0)).toBe(true); - }); - - it('a positive N requires at least N slots free', () => { - expect(isIdleStatus({ ...idle, available_slots: 2 }, 2)).toBe(true); - expect(isIdleStatus({ ...idle, available_slots: 1 }, 2)).toBe(false); - }); - - it('N > total_slots can never be idle (never dispatches)', () => { - expect(isIdleStatus({ ...idle, available_slots: 4 }, 5)).toBe(false); - }); - - it('ambiguous responses (total_slots 0 or missing fields) are busy', () => { - expect(isIdleStatus({ ...idle, total_slots: 0, available_slots: 0 }, 0)).toBe(false); - expect(isIdleStatus({ llama_server_running: true } as LlamaStatus, 0)).toBe(false); - }); - - it('non-finite or negative slot counts are busy', () => { - expect(isIdleStatus({ ...idle, total_slots: Number.NaN }, 0)).toBe(false); - expect(isIdleStatus({ ...idle, available_slots: -1 }, 0)).toBe(false); - }); -}); - -// ── Threshold timing (AC1/AC3) ──────────────────────────────────────── - -describe('threshold timing (idle-duration tracker)', () => { - const thresholdMs = DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS; - - it('dispatches only after idle has lasted the full threshold continuously', () => { - const tracker = createIdleTracker(); - const start = 1_000_000; - - tracker.record(true, start); - expect(tracker.idleSince).toBe(start); - expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs - 1)).toBe(false); - expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); - }); - - it('any busy poll resets the idle-since timestamp', () => { - const tracker = createIdleTracker(); - const start = 1_000_000; - - tracker.record(true, start); - tracker.record(false, start + 120_000); - expect(tracker.idleSince).toBeNull(); - expect(tracker.isThresholdMet(thresholdMs, start + 360_000)).toBe(false); - }); - - it('keeps the idle run start fixed across consecutive idle polls', () => { - const tracker = createIdleTracker(); - const start = 1_000_000; - tracker.record(true, start); - tracker.record(true, start + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - tracker.record(true, start + 2 * DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - expect(tracker.idleSince).toBe(start); - expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs - 1_000)).toBe(false); - expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); - }); -}); - -// ── Dispatch selection (AC2) ────────────────────────────────────────── - -describe('dispatch selection', () => { - it('runs /skill:plan on the next intake_complete item', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); - expect(deps.getNextItem).toHaveBeenCalledTimes(1); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('WL-ABC'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-ABC'), - { model: 'plan', cwd: '/repo', itemId: 'WL-ABC', itemTitle: 'Some task' }, - ); - }); - - it('claims the item BEFORE the pane spawns with the tier CAS guard', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // The claim carries the expected state the tier selected the item in - // (RCA WL-0MSRBFFLN005W3VT design point 1 — compare-and-swap). - expect(deps.claimItem).toHaveBeenCalledWith('WL-ABC', { status: 'open', stage: 'intake_complete' }, '/repo'); - const claimOrder = (deps.claimItem as ReturnType).mock.invocationCallOrder[0]; - const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; - expect(claimOrder).toBeLessThan(spawnOrder); - }); - - it('falls back to /skill:intake on the next idea item when none is intake_complete', async () => { - const deps = makeDeps({ - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: null }) - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-DEF', title: 'An idea', stage: 'idea', status: 'open' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); - expect(outcome.kind).toBe('intake'); - expect(outcome.candidate?.id).toBe('WL-DEF'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-DEF', { status: 'open', stage: 'idea' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:intake WL-DEF'), - expect.anything(), - ); - }); - - it('does not dispatch when no item exists in either stage', async () => { - const deps = makeDeps(); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - it('fails closed to wl-error when the intake_complete lookup errors AND the idea lookup errors', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ ok: false }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Tier 2 error does NOT short-circuit: tier 3 is still attempted. - expect(deps.getNextItem).toHaveBeenCalledTimes(2); - expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('wl-error'); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('still checks the idea tier after an intake_complete error and dispatches its candidate', async () => { - const deps = makeDeps({ - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea', status: 'open' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('intake'); - expect(outcome.candidate?.id).toBe('WL-IDE'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-IDE', { status: 'open', stage: 'idea' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:intake WL-IDE'), - expect.anything(), - ); - }); - - it('fails closed to wl-error when the intake_complete lookup errors and idea answers empty', async () => { - const deps = makeDeps({ - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - // Backlog is not provably empty (intake_complete state unknown) → the - // worker must NOT treat this as a genuine no-candidate (no cooldown). - expect(outcome.reason).toBe('wl-error'); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('fails closed to wl-error when the idea lookup errors after an empty intake_complete stage', async () => { - const deps = makeDeps({ - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: null }) - .mockResolvedValueOnce({ ok: false }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('wl-error'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); -}); - -// ── Dispatcher anchor wiring (C0 WL-0MTR01EU7005SYZG / F2 WL-0MTR2HLLJ009PTPJ) ── - -describe('dispatcher anchor wiring (C0 dispatcher workspace)', () => { - it('AC1: buildDowntimePaneArgs appends --anchor when an anchorId is provided', () => { - const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { - model: 'plan', - cwd: '/repo', - anchorId: 'wD:pANCHOR', - }); - expect(args).toContain('--anchor'); - expect(args).toContain('wD:pANCHOR'); - expect(args).toContain('--no-focus'); - expect(args).toContain('--cwd'); - expect(args).toContain('/repo'); - expect(args).toContain('--model'); - expect(args).toContain('plan'); - }); - - it('AC1 backward-compat: buildDowntimePaneArgs omits --anchor when no anchorId is given', () => { - const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { - model: 'plan', - cwd: '/repo', - }); - expect(args).not.toContain('--anchor'); - expect(args).not.toContain('wD:pANCHOR'); - expect(args[0]).toBe('--pane-name'); - }); - - it('AC3: dispatchDowntimeWork resolves the anchor and passes its pane id to spawnAgentPane', async () => { - const deps = makeDeps({ - getDispatcherAnchor: vi.fn().mockResolvedValue({ - paneId: 'wD:pANCHOR', - workspaceId: 'wD', - }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(deps.getDispatcherAnchor).toHaveBeenCalled(); - // The resolved anchor pane id flows into the spawn opts (→ --anchor in - // the send-to-pi.sh args built by buildDowntimePaneArgs). - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-ABC'), - expect.objectContaining({ anchorId: 'wD:pANCHOR' }), - ); - }); - - it('AC1 legacy wiring: without the getDispatcherAnchor dep no anchorId key is added (backward compat)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-ABC'), - expect.not.objectContaining({ anchorId: expect.anything() }), - ); - }); - - it('fail-safe: a null anchor (provisioning failed) aborts the dispatch with anchor-unavailable', async () => { - const deps = makeDeps({ - getDispatcherAnchor: vi.fn().mockResolvedValue(null), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('anchor-unavailable'); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('scheduled-prompt spawns resolve the anchor and forward anchorId (dispatchDowntimeWork path)', async () => { - const deps = makeDeps({ - getDispatcherAnchor: vi.fn().mockResolvedValue({ - paneId: 'wD:pANCHOR', - workspaceId: 'wD', - }), - getDueScheduledPrompt: vi.fn().mockResolvedValue({ - id: 'prompt-1', - prompt: 'Run the nightly sweep', - frequencyMinutes: 60, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('scheduled'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - 'Run the nightly sweep', - expect.objectContaining({ anchorId: 'wD:pANCHOR' }), - ); - }); -}); - -// ── Per-prefix tab dispatch wiring (C1 fallback, WL-0MUR5FUWD00024XN) ─────── - -describe('per-prefix Dispatcher tab dispatch wiring (C1 fallback)', () => { - const candidate = { - id: 'WL-ABC', - title: 'Some task', - stage: 'intake_complete' as const, - status: 'open', - }; - - it('AC1: resolves the per-prefix tab and forwards its anchor pane (primary path)', async () => { - const getDispatcherTabAnchor = vi.fn().mockResolvedValue({ - workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1', - }); - const deps = makeDeps({ - getDispatcherTabAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(getDispatcherTabAnchor).toHaveBeenCalledWith('/repo', 'WL'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-ABC'), - expect.objectContaining({ anchorId: 'wD:tWL:p1' }), - ); - }); - - it('AC1: each prefix routes to its own tab (WL vs TCE)', async () => { - const wlDeps = makeDeps({ - getDispatcherTabAnchor: vi.fn().mockResolvedValue({ - workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1', - }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-1', title: 'wl', stage: 'intake_complete', status: 'open' }, - }), - }); - const tceDeps = makeDeps({ - getDispatcherTabAnchor: vi.fn().mockResolvedValue({ - workspaceId: 'wD', tabId: 'wD:tTCE', paneId: 'wD:tTCE:p1', - }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'TCE-2', title: 'tce', stage: 'intake_complete', status: 'open' }, - }), - }); - - await dispatchDowntimeWork(wlDeps, { model: 'plan', cwd: '/repo-wl' }); - await dispatchDowntimeWork(tceDeps, { model: 'plan', cwd: '/repo-tce' }); - - expect(wlDeps.getDispatcherTabAnchor).toHaveBeenCalledWith('/repo-wl', 'WL'); - expect(tceDeps.getDispatcherTabAnchor).toHaveBeenCalledWith('/repo-tce', 'TCE'); - expect(wlDeps.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ anchorId: 'wD:tWL:p1', cwd: '/repo-wl' }), - ); - expect(tceDeps.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ anchorId: 'wD:tTCE:p1', cwd: '/repo-tce' }), - ); - }); - - it('AC2/AC4: a resolved project workspace wins and the per-prefix dep is not called', async () => { - const resolveProjectWorkspace = vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }); - const getItemTabAnchor = vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }); - const getDispatcherTabAnchor = vi.fn().mockResolvedValue({ - workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1', - }); - const deps = makeDeps({ - getDispatcherTabAnchor, - resolveProjectWorkspace, - getItemTabAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); - expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); - expect(getDispatcherTabAnchor).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ anchorId: 'wC:tWL-ABC:p1' }), - ); - }); - - it('AC5: the per-prefix Dispatcher path is used when the project workspace resolves to null', async () => { - const getDispatcherTabAnchor = vi.fn().mockResolvedValue({ - workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1', - }); - const resolveProjectWorkspace = vi.fn().mockResolvedValue(null); - const getItemTabAnchor = vi.fn(); - const getDispatcherAnchor = vi - .fn() - .mockResolvedValue({ paneId: 'wD:LEGACY', workspaceId: 'wD' }); - const deps = makeDeps({ - getDispatcherTabAnchor, - resolveProjectWorkspace, - getItemTabAnchor, - getDispatcherAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); - expect(getItemTabAnchor).not.toHaveBeenCalled(); - expect(getDispatcherTabAnchor).toHaveBeenCalledWith('/repo', 'WL'); - expect(getDispatcherAnchor).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ anchorId: 'wD:tWL:p1' }), - ); - }); - - it('AC1 fail-safe: a null per-prefix anchor aborts with anchor-unavailable (no fallback)', async () => { - const getDispatcherTabAnchor = vi.fn().mockResolvedValue(null); - const getDispatcherAnchor = vi - .fn() - .mockResolvedValue({ paneId: 'wD:LEGACY', workspaceId: 'wD' }); - const deps = makeDeps({ - getDispatcherTabAnchor, - getDispatcherAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('anchor-unavailable'); - expect(getDispatcherAnchor).not.toHaveBeenCalled(); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('AC1 fail-safe: a throwing per-prefix resolver aborts with anchor-unavailable', async () => { - const deps = makeDeps({ - getDispatcherTabAnchor: vi.fn().mockRejectedValue(new Error('herdr down')), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('anchor-unavailable'); - expect(deps.claimItem).not.toHaveBeenCalled(); - }); -}); - -// ── Project workspace + item-ID tab dispatch wiring (WL-0MU321YK70035AYT) ── - -// These tests exercise the PRIMARY project-workspace + item-ID tab path, -// reaffirmed as primary in WL-0MUR5FUWD00024XN. The per-prefix Dispatcher tab -// and the legacy single anchor are fallbacks used only when no project -// workspace resolves for the item's root. -describe('project workspace + item-ID tab dispatch wiring (primary path)', () => { - const candidate = { - id: 'WL-ABC', - title: 'Some task', - stage: 'intake_complete' as const, - status: 'open', - }; - - it('AC1/AC2: resolves the project workspace, ensures the item-ID tab and forwards its anchor pane', async () => { - const resolveProjectWorkspace = vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }); - const getItemTabAnchor = vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }); - const getDispatcherAnchor = vi - .fn() - .mockResolvedValue({ paneId: 'wD:LEGACY', workspaceId: 'wD' }); - const deps = makeDeps({ - resolveProjectWorkspace, - getItemTabAnchor, - getDispatcherAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(resolveProjectWorkspace).toHaveBeenCalledWith('/repo'); - expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); - expect(getDispatcherAnchor).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-ABC'), - expect.objectContaining({ anchorId: 'wC:tWL-ABC:p1' }), - ); - }); - - it('AC4: no project workspace → falls back to the Dispatcher anchor', async () => { - const resolveProjectWorkspace = vi.fn().mockResolvedValue(null); - const getItemTabAnchor = vi.fn(); - const getDispatcherAnchor = vi - .fn() - .mockResolvedValue({ paneId: 'wD:pFALLBACK', workspaceId: 'wD' }); - const deps = makeDeps({ - resolveProjectWorkspace, - getItemTabAnchor, - getDispatcherAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(getDispatcherAnchor).toHaveBeenCalledWith('/repo'); - expect(getItemTabAnchor).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ anchorId: 'wD:pFALLBACK' }), - ); - }); - - it('AC4 fail-safe: workspace resolves but item tab fails → anchor-unavailable, no Dispatcher fallback', async () => { - const resolveProjectWorkspace = vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1' }); - const getItemTabAnchor = vi.fn().mockResolvedValue(null); - const getDispatcherAnchor = vi - .fn() - .mockResolvedValue({ paneId: 'wD:LEGACY', workspaceId: 'wD' }); - const deps = makeDeps({ - resolveProjectWorkspace, - getItemTabAnchor, - getDispatcherAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('anchor-unavailable'); - expect(getDispatcherAnchor).not.toHaveBeenCalled(); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('AC4 fail-safe: no project workspace and Dispatcher anchor null → anchor-unavailable, no marker/spawn', async () => { - const deps = makeDeps({ - resolveProjectWorkspace: vi.fn().mockResolvedValue(null), - getDispatcherAnchor: vi.fn().mockResolvedValue(null), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('anchor-unavailable'); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('AC4 fail-safe: a throwing project-workspace resolver falls back to the Dispatcher anchor', async () => { - const getDispatcherAnchor = vi - .fn() - .mockResolvedValue({ paneId: 'wD:pFALLBACK', workspaceId: 'wD' }); - const deps = makeDeps({ - resolveProjectWorkspace: vi.fn().mockRejectedValue(new Error('herdr down')), - getDispatcherAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(getDispatcherAnchor).toHaveBeenCalled(); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ anchorId: 'wD:pFALLBACK' }), - ); - }); - - it('AC4 fail-safe: a throwing item-tab resolver aborts with anchor-unavailable', async () => { - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1' }), - getItemTabAnchor: vi.fn().mockRejectedValue(new Error('tab create blew up')), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('anchor-unavailable'); - expect(deps.claimItem).not.toHaveBeenCalled(); - }); - - it('legacy wiring: without the per-prefix dep the item-ID tab path is used (backward compat)', async () => { - const getItemTabAnchor = vi.fn().mockResolvedValue({ tabId: 'wC:tX', paneId: 'wC:tX:p1' }); - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1' }), - getItemTabAnchor, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(getItemTabAnchor).toHaveBeenCalledWith('/repo', 'wC', 'WL-ABC'); - expect('getDispatcherTabAnchor' in deps).toBe(false); - }); - - it('AC1/AC2 per-project separation: each root resolves its own workspace and tab', async () => { - const wl = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1' }), - getItemTabAnchor: vi.fn().mockResolvedValue({ tabId: 'wC:tWL-1', paneId: 'wC:tWL-1:p1' }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-1', title: 'wl', stage: 'intake_complete', status: 'open' }, - }), - }); - const tce = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wY:pW', workspaceId: 'wY', tabId: 'wY:t1' }), - getItemTabAnchor: vi.fn().mockResolvedValue({ tabId: 'wY:tTCE-2', paneId: 'wY:tTCE-2:p1' }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'TCE-2', title: 'tce', stage: 'intake_complete', status: 'open' }, - }), - }); - - const first = await dispatchDowntimeWork(wl, { model: 'plan', cwd: '/repo-wl' }); - const second = await dispatchDowntimeWork(tce, { model: 'plan', cwd: '/repo-tce' }); - - expect(first.dispatched).toBe(true); - expect(second.dispatched).toBe(true); - expect(wl.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ anchorId: 'wC:tWL-1:p1', cwd: '/repo-wl' }), - ); - expect(tce.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ anchorId: 'wY:tTCE-2:p1', cwd: '/repo-tce' }), - ); - }); - - it('scheduled-prompt spawns keep the Dispatcher anchor (no work-item id)', async () => { - const getDispatcherAnchor = vi - .fn() - .mockResolvedValue({ paneId: 'wD:pSCHED', workspaceId: 'wD' }); - const resolveProjectWorkspace = vi.fn(); - const deps = makeDeps({ - getDispatcherAnchor, - resolveProjectWorkspace, - getDueScheduledPrompt: vi - .fn() - .mockResolvedValue({ id: 'prompt-1', prompt: 'Run the nightly sweep', frequencyMinutes: 60 }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('scheduled'); - expect(resolveProjectWorkspace).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - 'Run the nightly sweep', - expect.objectContaining({ anchorId: 'wD:pSCHED' }), - ); - }); - - it('AC2 audit-log context: the marker records the item and its project root before the spawn', async () => { - const recordDispatch = vi.fn().mockResolvedValue(true); - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wY:pW', workspaceId: 'wY', tabId: 'wY:t1' }), - getItemTabAnchor: vi.fn().mockResolvedValue({ tabId: 'wY:tTCE-9', paneId: 'wY:tTCE-9:p1' }), - recordDispatch, - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'TCE-9', title: 'Engine task', stage: 'intake_complete', status: 'open' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo-tce' }); - - expect(outcome.dispatched).toBe(true); - expect(recordDispatch).toHaveBeenCalledWith( - expect.objectContaining({ itemId: 'TCE-9', kind: 'plan', cwd: '/repo-tce' }), - ); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ cwd: '/repo-tce', anchorId: 'wY:tTCE-9:p1' }), - ); - const markerOrder = recordDispatch.mock.invocationCallOrder[0]; - const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; - expect(markerOrder).toBeLessThan(spawnOrder); - }); - - // ── Root-pane cleanup (WL-0MU2EOHK900425VU) ──────────────────────── - - it('AC1: closes the project-workspace item tab root pane after a successful dispatch', async () => { - const closePane = vi.fn().mockResolvedValue(true); - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), - getItemTabAnchor: vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), - // No live downtime pane carries the anchor id → the anchor IS the root - // pane and must be closed. - getRunningDowntimePanes: vi - .fn() - .mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }), - closePane, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(closePane).toHaveBeenCalledTimes(1); - expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); - }); - - it('AC2: NEVER closes the Dispatcher-fallback anchor (would re-provision a blank pane)', async () => { - const closePane = vi.fn().mockResolvedValue(true); - const deps = makeDeps({ - resolveProjectWorkspace: vi.fn().mockResolvedValue(null), - getDispatcherAnchor: vi - .fn() - .mockResolvedValue({ paneId: 'wD:pFALLBACK', workspaceId: 'wD' }), - closePane, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(closePane).not.toHaveBeenCalled(); - }); - - it('AC2: NEVER closes an anchor that is itself a live downtime dispatch pane', async () => { - const closePane = vi.fn().mockResolvedValue(true); - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), - getItemTabAnchor: vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), - // The anchor id is a recorded downtime pane → it is a previous - // dispatch's agent pane and must stay open. - getRunningDowntimePanes: vi.fn().mockResolvedValue({ - ok: true, - count: 1, - paneIds: ['wC:tWL-ABC:p1'], - records: [{ paneId: 'wC:tWL-ABC:p1', label: 'Downtime triggered plan X - WL-ABC' }], - }), - closePane, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(closePane).not.toHaveBeenCalled(); - }); - - it('AC5 fail-safe: a throwing closePane never blocks the dispatch outcome', async () => { - const closePane = vi.fn().mockRejectedValue(new Error('herdr close blew up')); - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), - getItemTabAnchor: vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), - closePane, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(closePane).toHaveBeenCalledTimes(1); - }); - - it('AC5 backward-compat: without the closePane dep no cleanup call is attempted', async () => { - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), - getItemTabAnchor: vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(deps.closePane).toBeUndefined(); - }); - - // ── Audit-rejection regressions (WL-0MU2EOHK900425VU) ───────────────── - // The producer rejected the first fix because the empty shell pane was - // still present after creating a tab in an existing workspace. Root cause: - // the cleanup preferred `records` (the FULL machine-wide pane set, which - // always contains the anchor itself) over `paneIds` (the running DOWNTIME - // panes), so `liveIds.includes(anchorId)` was always true and every close - // was suppressed. The cleanup now uses the running-downtime set. - - it('AC1 regression: closes the root pane even when records contains the anchor (full pane set)', async () => { - const closePane = vi.fn().mockResolvedValue(true); - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), - getItemTabAnchor: vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), - // Production `records` = the FULL pane list: it contains the blank - // anchor (which is not a downtime pane). `paneIds` (running downtime - // panes) is empty → the anchor must be closed. - getRunningDowntimePanes: vi.fn().mockResolvedValue({ - ok: true, - count: 0, - paneIds: [], - records: [ - { paneId: 'wC:tWL-ABC:p1', label: 'bash' }, - { paneId: 'wC:other', label: 'bash' }, - ], - }), - closePane, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(closePane).toHaveBeenCalledTimes(1); - expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); - }); - - it('AC1 regression: records-only payload is filtered to downtime panes (anchor still closed)', async () => { - const closePane = vi.fn().mockResolvedValue(true); - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), - getItemTabAnchor: vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), - // Legacy payload without `paneIds`; `records` holds the blank anchor - // (not a downtime pane) → filtering yields no live downtime pane. - getRunningDowntimePanes: vi.fn().mockResolvedValue({ - ok: true, - count: 0, - records: [{ paneId: 'wC:tWL-ABC:p1', label: 'bash' }], - }), - closePane, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(closePane).toHaveBeenCalledTimes(1); - expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); - }); - - it('AC2 fail-safe regression: leaves the anchor open when liveness fails (ok:false)', async () => { - const closePane = vi.fn().mockResolvedValue(true); - const deps = makeDeps({ - resolveProjectWorkspace: vi - .fn() - .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), - getItemTabAnchor: vi - .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), - // Liveness query FAILED → cannot confirm → leave the anchor open. - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: false, error: 'herdr unavailable' }), - closePane, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(closePane).not.toHaveBeenCalled(); - }); -}); - - -// ── Audit-tier dispatch (WL-0MSI8H3HP000K0RG) ───────────────────────── - -describe('dispatch audit tier', () => { - const staleCandidate: DowntimeCandidate = { - id: 'WL-AUD', - title: 'Audit me', - stage: 'audit', - }; - - it('dispatches /skill:audit on the audit candidate as the first tier', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: staleCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - expect(outcome.candidate?.id).toBe('WL-AUD'); - expect(deps.getNextItem).not.toHaveBeenCalled(); - expect(deps.claimItem).toHaveBeenCalledWith('WL-AUD', { status: 'completed', stage: 'in_review' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:audit WL-AUD'), - { model: 'plan', cwd: '/repo', itemId: 'WL-AUD', itemTitle: 'Audit me' }, - ); - }); - - it('records the audit dispatch with kind audit', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: staleCandidate }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; - expect(event.itemId).toBe('WL-AUD'); - expect(event.kind).toBe('audit'); - expect(event.cwd).toBe('/repo'); - }); - - it('when no audit candidate, falls back to audit → plan → intake in order', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('intake'); - expect(outcome.candidate?.id).toBe('WL-IDE'); - expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); - expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); - }); - - it('fails closed to no-candidate when the audit tier answers empty and no plan/intake candidate exists', async () => { - // The audit tier distinguishes a GENUINELY empty tier ({ok:true, - // candidate:null}) from a wl failure ({ok:false}, WL-0MSLWJ2KP0002SV0): - // only the former falls through — all tiers empty -> no dispatch, the - // no-candidate cooldown unchanged. - const deps = makeDeps(); // all three tiers empty - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('fails closed to wl-error when the audit-tier lookup errors (a strike, never a silent fall-through)', async () => { - // WL-0MSLWJ2KP0002SV0: a wl/parse failure in getNextAuditCandidate must - // NOT fall through to the implement/plan/intake tiers looking healthy — - // it is a CLI-error strike exactly like the plan/intake tiers' {ok:false}. - // The audit query state is UNKNOWN, so no lower tier is consulted this - // cycle (fail-closed to busy; the three-strike rule governs retries). - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: false }), - getNextImplementCandidate: vi.fn().mockResolvedValue({ id: 'WL-IMP', title: 'Implement me', stage: 'implement' }), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('wl-error'); - expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); - // No fall-through: the implement/plan/intake tiers are never consulted. - expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); - expect(deps.getNextItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - }); - - it('falls through to the implement tier when the audit tier answers a genuinely empty set', async () => { - // {ok:true, candidate:null} (genuinely empty audit tier) falls through to - // the implement tier — only {ok:false} (wl failure) strikes. - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue({ id: 'WL-IMP', title: 'Implement me', stage: 'implement' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); - }); -}); - -// ── Interim freshness re-check (WL-0MT8KSTOE00871E7) ───────────────── - -describe('audit tier interim freshness re-check (WL-0MT8KSTOE00871E7)', () => { - const auditCandidate: DowntimeCandidate = { - id: 'WL-AUD', - title: 'Audit me', - stage: 'audit', - }; - - it('downgrades to a no-op when a fresh audit was recorded in the interim (no comment, no marker, no spawn)', async () => { - // AC1: select a stale/un-audited candidate, then a valid audit lands - // between selection and dispatch — the re-check sees it and the - // dispatch becomes a no-op; behaviour falls through to the next tier - // without triggering the no-candidate cooldown. - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - // A fresh audit exists in the interim — dispatch must be skipped. - hasFreshAudit: vi.fn().mockResolvedValue(true), - // A plan candidate exists below — the audit tier skip falls through to it. - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Falls through to the next tier (plan), NOT the no-candidate cooldown. - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('WL-PLN'); - // No AUDIT dispatch side-effects: the audit candidate is never claimed, - // no audit comment/marker, no audit spawn. - expect(deps.claimItem).toHaveBeenCalledWith('WL-PLN', { status: 'open', stage: 'intake_complete' }, '/repo'); - expect(deps.recordDispatch).not.toHaveBeenCalledWith( - expect.objectContaining({ itemId: 'WL-AUD', kind: 'audit' }), - ); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-PLN'), - expect.any(Object), - ); - // The re-check was performed exactly once on the candidate. - expect(deps.hasFreshAudit).toHaveBeenCalledWith('WL-AUD', '/repo'); - }); - - it('skips dispatch entirely and leaves the cooldown untouched when a fresh audit lands and no next-tier candidate exists', async () => { - // AC1 (no-candidate cooldown guard): a fresh-audit skip with an empty - // remaining backlog is NOT the no-candidate outcome — the worker keeps - // polling (the item is already audited; there is simply nothing to do). - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - hasFreshAudit: vi.fn().mockResolvedValue(true), - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty - .mockResolvedValueOnce({ ok: true, candidate: null }), // idea empty - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - // Fresh-audit skip is treated as "already audited" — no cooldown, no strike. - expect(outcome.reason).toBe('fresh-audit-skip'); - expect(outcome.reason).not.toBe('no-candidate'); - expect(outcome.reason).not.toBe('wl-error'); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('records no spawn-failure trace when a fresh audit was recorded during the interim (AC2)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - // Selection re-check: no fresh audit → dispatch proceeds. Spawn-failure - // re-check: a fresh audit appeared in the interim → no failure trace. - hasFreshAudit: vi - .fn() - .mockResolvedValueOnce(false) // selection: not fresh → dispatch proceeds - .mockResolvedValueOnce(true), // spawn failure: fresh audit → skip trace - claimItem: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT', exitCode: 1 }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Spawn failed, but a valid audit exists — the push is moot, no failure trace. - expect(recordDispatchFailureCalled(deps)).toBe(false); - expect(deps.recordDispatch).toHaveBeenCalledTimes(1); // marker written (unchanged) - expect(outcome.dispatched).toBe(false); - }); - - it('still appends a spawn-failure trace when no fresh audit was recorded during the interim (unchanged path)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - hasFreshAudit: vi.fn().mockResolvedValue(false), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT', exitCode: 1 }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // No fresh audit — the failure trace is recorded exactly as before (AC3). - expect((deps.recordDispatchFailure as ReturnType).mock.calls.length).toBe(1); - expect(outcome.dispatched).toBe(false); - }); - - it('a genuinely unaudited item still dispatches exactly as today (comment + marker + spawn)', async () => { - // AC3: no fresh audit in the interim — the audit dispatch proceeds - // unchanged (comment + marker + spawn). - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - hasFreshAudit: vi.fn().mockResolvedValue(false), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-AUD', { status: 'completed', stage: 'in_review' }, '/repo'); - expect(deps.recordDispatch).toHaveBeenCalledTimes(1); // marker/comment written - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:audit WL-AUD'), - { model: 'plan', cwd: '/repo', itemId: 'WL-AUD', itemTitle: 'Audit me' }, - ); - }); - - it('a hasFreshAudit failure (wl error) is fail-open — the dispatch proceeds as today (conservative default)', async () => { - // The re-check must never block dispatch on an unanswerable check: a - // wl/CLI failure in hasFreshAudit fails open (proceed) — never a strike, - // never a no-candidate, never a silent skip. - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - // Fail-open: the re-check throws but the dispatching continues. - hasFreshAudit: vi.fn().mockRejectedValue(new Error('wl failed')), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - expect(deps.claimItem).toHaveBeenCalled(); - expect(deps.spawnAgentPane).toHaveBeenCalled(); - }); -}); - -function recordDispatchFailureCalled(deps: DowntimeWorkerDeps): boolean { - const fn = deps.recordDispatchFailure as ReturnType; - return fn.mock.calls.length > 0; -} - -// ── Single-active-audit enforcement (WL-0MT3PHW4I002SNOV) ───────────── - -describe('dispatch active-audit single-flight (parent WL-0MT3PHW4I002SNOV)', () => { - const auditCandidate: DowntimeCandidate = { - id: 'WL-AUD', - title: 'Audit me', - stage: 'audit', - }; - const implementCandidate: DowntimeCandidate = { - id: 'WL-IMP', - title: 'Implement me', - stage: 'implement', - }; - - // Contract of the injected active-audit check (implemented in F2, - // WL-0MT47BQAT00375VB): {ok:true, active:true} = a non-stale kind=audit - // dispatch marker maps to an item still in_progress (an audit is running - // — the audit tier must be skipped); {ok:true, active:false} = none - // (audit tier proceeds); {ok:false} = the check could not complete - // (fail-open — skip the audit tier, never block all dispatch). - const noActiveAudit: DowntimeActiveAuditResult = { ok: true, active: false }; - const activeAudit: DowntimeActiveAuditResult = { ok: true, active: true }; - const checkFailed: DowntimeActiveAuditResult = { ok: false }; - - it('dispatches /skill:audit when no non-stale audit marker maps to an in_progress item (AC1/AC3)', async () => { - // No active audit — including a marker older than the 2h stale window - // (treated as expired: the audit pane may have crashed without updating - // the work item) — the audit tier proceeds as today. The stale-window - // arithmetic itself is pinned in downtime-log.test.ts - // (recentAuditDispatchedItemIds); here the dep reports the resolved - // state (active:false). - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(noActiveAudit), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // The single-flight check runs first, then the candidate lookup. - expect(deps.getActiveAudit).toHaveBeenCalledWith('/repo'); - expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - expect(outcome.candidate?.id).toBe('WL-AUD'); - }); - - it('one active audit skips the audit tier and falls through to the implement tier (AC2/AC4)', async () => { - // A non-stale kind=audit marker maps to an in_progress item: an audit is - // running. The audit tier is skipped (audits strictly sequential — no - // fan-out), the candidate lookup is never consulted, and dispatch falls - // through to the implement tier. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(activeAudit), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getActiveAudit).toHaveBeenCalledTimes(1); - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('WL-IMP'); - }); - - it('an active-audit-check failure fails open: audit tier skipped, dispatch falls through to implement', async () => { - // Fail-safe (parent constraint): if the active-audit check cannot - // complete (e.g. worklog query fails) the audit tier is skipped and - // dispatch falls through to the next tier — it never blocks all - // dispatch. No audit candidate is consulted this cycle. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(checkFailed), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('WL-IMP'); - }); - - it('an audit-in-flight skip with an empty remaining backlog reports reason audit-in-flight (never no-candidate)', async () => { - // Mirrors the code-freeze precedent (WL-0MSQ0RPQP00636JY): a skip that - // is NOT a genuine empty backlog must never enter the no-candidate - // cooldown — polling continues and the next idle tick re-checks while - // the audit is still running. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(activeAudit), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - // plan (intake_complete) and intake (idea) tiers answer genuinely empty. - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('audit-in-flight'); - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - }); - - it('logs the audit-in-flight skip reason to stderr for observability (AC4/AC5)', async () => { - // The skip must be observable: the worker writes the skip reason to - // stderr (established observability pattern, cf. scheduler.test.ts - // 'logs an abandonment to stderr'), so operators can trace why the - // audit tier did not dispatch instead of seeing a silent fall-through. - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - try { - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(activeAudit), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('implement'); - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Downtime audit tier skipped: audit-in-flight'), - ); - } finally { - stderrSpy.mockRestore(); - } - }); - - it('a failed active-audit check with an empty remaining backlog is a wl-error strike, never no-candidate', async () => { - // Partial information must not pause the worker: when the check failed - // and every fallback tier answered empty, the backlog is NOT provably - // empty (an audit may be in flight we could not see) — fail closed to - // busy (a strike, three-strike rule), never the no-candidate cooldown. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(checkFailed), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('wl-error'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('runs the active-audit check BEFORE the audit candidate lookup (single check per tick)', async () => { - // Ordering guard: the single-flight check gates the tier — a candidate - // is never selected while an active audit exists, and the check is - // consulted exactly once per tick. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(noActiveAudit), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - const checkOrder = (deps.getActiveAudit as ReturnType).mock.invocationCallOrder[0]; - const lookupOrder = (deps.getNextAuditCandidate as ReturnType).mock.invocationCallOrder[0]; - expect(checkOrder).toBeLessThan(lookupOrder); - expect(deps.getActiveAudit).toHaveBeenCalledTimes(1); - }); - - it('during a code freeze the audit block is skipped without consulting the active-audit check (guard composition)', async () => { - // Existing guards preserved (AC6): the code-freeze gate (WL-0MSQ0RPQP00636JY) - // short-circuits the entire audit tier before the single-flight check — - // no audits run during a release, active or not. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(activeAudit), - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getActiveAudit).not.toHaveBeenCalled(); - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - // Empty plan/intake backlog during a freeze reports code-freeze (the - // freeze never enters the no-candidate cooldown — unchanged). - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('code-freeze'); - }); - - it('is not consulted when the audit tier is ineligible (1 free slot < the 2-slot audit minimum)', async () => { - // Free-slot minimum preserved (parent WL-0MT32F90V008UAD2 AC3): below the - // audit tier's 2-slot minimum the whole audit block is skipped — the - // single-flight check is not even run; plan (≥1 slot) dispatches. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(activeAudit), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete', status: 'open' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); - - expect(deps.getActiveAudit).not.toHaveBeenCalled(); - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - }); -}); - -// ── Host-wide audit saturation (WL-0MUIVE0YG000UVIA) ────────────────── -// -// The per-worklog active-audit check (WL-0MT3PHW4I002SNOV) only sees audits -// dispatched by THIS project's dispatcher. The 2026-09-26 incident ran four -// downtime audits concurrently on one host — dispatched by DIFFERENT -// projects — saturating the shared Local Proxy and the audit runner's -// host-wide slot. These tests pin the host-wide serialisation gate: when a -// machine-wide active-audit marker is present (`source: 'host-wide'`), a -// second audit is NOT started; the candidate falls through/deferred. -describe('dispatch host-wide audit saturation (WL-0MUIVE0YG000UVIA)', () => { - const auditCandidate: DowntimeCandidate = { - id: 'WL-AUD', - title: 'Audit me', - stage: 'audit', - }; - const implementCandidate: DowntimeCandidate = { - id: 'WL-IMP', - title: 'Implement me', - stage: 'implement', - }; - - const hostSaturated: DowntimeActiveAuditResult = { - ok: true, - active: true, - source: 'host-wide', - }; - const perWorklogActive: DowntimeActiveAuditResult = { - ok: true, - active: true, - source: 'per-worklog', - }; - - it('a host-wide active audit skips the audit tier and falls through to the implement tier (AC1/AC2)', async () => { - // A second instance/project on the same host sees the machine-wide - // marker and must NOT start another audit. The audit candidate lookup is - // never consulted and dispatch falls through to the next tier. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(hostSaturated), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getActiveAudit).toHaveBeenCalledWith('/repo'); - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('WL-IMP'); - }); - - it('host-wide saturation with an empty remaining backlog reports audit-host-saturated (never no-candidate)', async () => { - // A host-wide skip is NOT a genuine empty backlog: it must never enter - // the no-candidate cooldown — polling continues and the next idle tick - // re-checks while the remote audit is still running. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(hostSaturated), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('audit-host-saturated'); - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - }); - - it('distinguishes host-wide from per-worklog saturation: a per-worklog active audit still reports audit-in-flight', async () => { - // The reason string must not regress the existing per-worklog semantics: - // only `source: 'host-wide'` produces 'audit-host-saturated'. - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(perWorklogActive), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('audit-in-flight'); - }); - - it('logs audit-host-saturated with the concurrent-audit count and slot state (AC3)', async () => { - // Contention must be visible: the skip line names the condition and - // carries the concurrent-audit count (1 — host-wide single-flight) plus - // the live free-slot / contention queue state so an operator can tell - // infra saturation from a content problem. - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - try { - const deps = makeDeps({ - getActiveAudit: vi.fn().mockResolvedValue(hostSaturated), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - }); - - const outcome = await dispatchDowntimeWork(deps, { - model: 'plan', - cwd: '/repo', - freeSlots: 2, - contentionQueueDepth: 0, - }); - - expect(outcome.kind).toBe('implement'); - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('Downtime audit tier skipped: audit-host-saturated'), - ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('concurrentAudits=1'), - ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('contentionQueueDepth=0'), - ); - expect(stderrSpy).toHaveBeenCalledWith( - expect.stringContaining('freeSlots=2'), - ); - } finally { - stderrSpy.mockRestore(); - } - }); -}); - -describe('dispatch tier free-slot minimums (parent WL-0MT32F90V008UAD2 AC3)', () => { - const auditCandidate: DowntimeCandidate = { - id: 'WL-AUD', - title: 'Audit me', - stage: 'audit', - }; - const planCandidate: DowntimeCandidate = { - id: 'WL-PLAN', - title: 'Prep task', - stage: 'intake_complete', - status: 'open', - }; - - it('audit tier is skipped below 2 free slots: with 1 free slot an audit candidate is NOT dispatched (AC3)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: planCandidate, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); - - // 1 free slot: audit needs ≥2 → skipped WITHOUT consulting the lookup - // (ineligible, not a strike); plan needs ≥1 → dispatches. - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('WL-PLAN'); - }); - - it('audit tier dispatches with ≥2 free slots (AC3)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 2 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); - }); - - it('with exactly 1 free slot the audit skip is ineligible — a genuinely empty plan tier is no-candidate, not a strike', async () => { - // Mirror of the code-freeze skip: an unmet tier minimum never produces - // a wl-error strike and never short-circuits the fallback tiers. - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); - - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); // plan tier answered empty - }); - - it('pane tiers (plan/intake) still dispatch with exactly 1 free slot (AC2)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - }); - - it('free-slots gate is independent of the idle gate: no freeSlots arg means no gating (backward compatible)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Legacy direct callers (undefined freeSlots) are not gated — existing - // dispatch behaviour unchanged. - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - }); - - it('scheduled-prompts tier requires the pane minimum ≥1 free slot: 0 free skips it and falls through', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue({ - id: 'SP-1', - prompt: 'Run /skill:refactor', - frequency: '3d', - }), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); - - // 0 free slots: the scheduled prompt (a pane) is ineligible; the plan - // tier also needs ≥1 — the outcome is a neutral no-candidate (defensive - // path — via the worker the idle gate already guarantees ≥1 free). - expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - }); - - it('scheduled-prompts tier dispatches with ≥1 free slot (AC3)', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue({ - id: 'SP-1', - prompt: 'Run /skill:refactor', - frequency: '3d', - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('scheduled'); - expect(deps.recordScheduledPromptTrigger).toHaveBeenCalled(); - }); - - it('implement tier is NOT dispatched with 0 free slots: pane minimum ≥1 honored at selection time (F3-fix AC1/AC4)', async () => { - // Regression for the audit finding (F3 AC2 partial): the implement tier - // had a 'Pane minimum' comment but no panesEligible guard — a direct - // dispatchDowntimeWork({freeSlots:0}) dispatched implement. It must be - // skipped exactly like the other pane tiers (ineligible, never a - // strike), falling through to the plan tier's defensive no-candidate. - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue({ - id: 'WL-IMP', - title: 'Implement me', - stage: 'implement', - }), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); - - // The implement lookup is never consulted at 0 free slots — ineligible, - // not a strike, and falls through to the plan tier's defensive - // no-candidate (via the worker the idle gate already guarantees ≥1). - expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('implement tier still dispatches with ≥1 free slot (F3-fix AC2)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue({ - id: 'WL-IMP', - title: 'Implement me', - stage: 'implement', - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('WL-IMP'); - }); - - it('implement tier is not gated when freeSlots is undefined: direct legacy callers unaffected (F3-fix AC3)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue({ - id: 'WL-IMP', - title: 'Implement me', - stage: 'implement', - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('WL-IMP'); - }); -}); - -// ── Implement dispatch tier (WL-0MSMAYIKX005LLO4) ──────────────────── - -describe('dispatch implement tier', () => { - const implementCandidate: DowntimeCandidate = { - id: 'WL-IMP', - title: 'Implement me', - stage: 'implement', - }; - - it('dispatches /skill:implement on the implement candidate after the audit tier', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('WL-IMP'); - expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); - expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); - expect(deps.getNextImplementCandidate).toHaveBeenCalledWith('/repo'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-IMP', { status: 'open', stage: 'plan_complete' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:implement WL-IMP'), - { model: 'plan', cwd: '/repo', itemId: 'WL-IMP', itemTitle: 'Implement me' }, - ); - }); - - it('records the implement dispatch with kind implement', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; - expect(event.itemId).toBe('WL-IMP'); - expect(event.kind).toBe('implement'); - expect(event.cwd).toBe('/repo'); - }); - - it('dispatch priority is audit → implement → plan → intake (audit stays first)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' } }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Audit wins — the implement tier is never consulted when an audit - // candidate exists (audit is the release gate). - expect(outcome.kind).toBe('audit'); - expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); - expect(deps.getNextItem).not.toHaveBeenCalled(); - }); - - it('falls back to the implement tier when no audit candidate exists, and audit runs first', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('implement'); - expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); - expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); - expect(deps.getNextItem).not.toHaveBeenCalled(); - }); - - it('falls back to plan (intake_complete) when no implement candidate exists', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('plan'); - expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); - expect(deps.getNextItem).toHaveBeenCalledTimes(1); - expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); - }); - - it('falls back to intake (idea) when no implement or plan candidate exists', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('intake'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); - expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); - }); - - it('a wl next CLI error at the implement tier does NOT short-circuit the plan/intake fallback', async () => { - // Mirrors the tier-2 (intake_complete) error handling: getNextImplementCandidate - // is fail-closed at the deps boundary (a wl failure yields null), so the - // plan/intake tiers still run. The implement tier itself never signals a - // hard error — null IS the fail-closed shape (AC6). - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), // wl error → null - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('plan'); - expect(deps.getNextItem).toHaveBeenCalled(); - }); - - it('no-candidate when all tiers (audit, implement, plan, intake) are empty', async () => { - const deps = makeDeps(); // all four tiers empty - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); - expect(deps.getNextItem).toHaveBeenCalledTimes(2); - }); -}); - -// ── Code-freeze gate (WL-0MSQ0RPQP00636JY) ────────────────────────── -// The dispatcher must honour the ship-it code-freeze marker: while frozen -// (or ambiguous — fail-closed), the audit and implement tiers are skipped -// (no new implementation work / audits during a release) and the plan/intake -// tiers still dispatch (low-risk prep). The marker is re-read on EVERY -// dispatch (never cached) so a freeze that starts or ends mid-idle-period is -// honored on the next dispatch attempt. A freeze skip is reason -// 'code-freeze' — never 'no-candidate' — so it cannot trigger the worker's -// no-candidate cooldown: polling continues and resume is immediate. - -describe('dispatch code-freeze gate', () => { - const auditCandidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; - const implementCandidate: DowntimeCandidate = { id: 'WL-IMP', title: 'Implement me', stage: 'implement' }; - const planCandidate: DowntimeCandidate = { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }; - - it('skips the audit and implement tiers while frozen and still dispatches plan', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Frozen → the audit/implement tiers are never consulted. - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); - // Plan/intake tiers still dispatch. - expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-PLAN', { status: 'open', stage: 'intake_complete' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-PLAN'), - expect.anything(), - ); - }); - - it('treats an ambiguous marker as frozen (fail-closed): no audit/implement, plan still dispatches', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - }); - - it('frozen with an empty plan/intake backlog reports code-freeze (never no-candidate)', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - // The freeze itself must never look like a genuine empty backlog: the - // worker must NOT enter the no-candidate cooldown (polling continues). - expect(outcome.reason).toBe('code-freeze'); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('ambiguous with an empty plan/intake backlog also reports code-freeze', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('code-freeze'); - }); - - it('frozen still surfaces wl CLI errors as wl-error (a strike, not hidden by the freeze)', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - getNextItem: vi.fn().mockResolvedValue({ ok: false }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('wl-error'); - }); - - it('re-reads the marker on every dispatch: implement/audit resume immediately when the freeze lifts', async () => { - // First dispatch: frozen → no audit/implement, empty plan/intake → code-freeze. - const freezeStatus = vi.fn().mockReturnValue('frozen'); - const deps = makeDeps({ - readCodeFreezeStatus: freezeStatus, - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const frozenOutcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(frozenOutcome.reason).toBe('code-freeze'); - expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); - - // The freeze lifts (marker now reads not-frozen): the SAME deps object is - // re-read on the next dispatch — no caching — and the implement tier - // dispatches again. - freezeStatus.mockReturnValue('not-frozen'); - deps.getNextImplementCandidate = vi.fn().mockResolvedValue(implementCandidate); - - const resumed = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(resumed.dispatched).toBe(true); - expect(resumed.kind).toBe('implement'); - expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:implement WL-IMP'), - expect.anything(), - ); - }); - - it('not-frozen behaves as before: the audit tier still runs', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - expect(deps.getNextAuditCandidate).toHaveBeenCalledWith('/repo'); - }); -}); - -// ── Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX) ───────────────────── - -describe('dispatch scheduled-prompts tier', () => { - const duePrompt: ScheduledPrompt = { - id: '/skill:refactor', - prompt: '/skill:refactor', - intervalDays: 3, - lastTriggeredAt: null, - }; - - it('dispatches a due scheduled prompt FIRST (before audit/implement/plan/intake)', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), - // The backlog tiers must never be consulted — the prompt dispatches - // instead of reaching them (AC6). - getNextAuditCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, - }), - getNextImplementCandidate: vi.fn().mockResolvedValue({ - id: 'WL-IMP', - title: 'Implement me', - status: 'open', - risk: 'low', - effort: 'small', - } as ImplementCandidate), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('scheduled'); - // The prompt text is the dispatch payload — not a skill+work-item wrap. - expect(deps.spawnAgentPane).toHaveBeenCalledWith('/skill:refactor', { - model: 'plan', - cwd: '/repo', - paneName: 'Downtime /skill:refactor', - }); - // No pre-dispatch claim — there is no work item (AC4). - expect(deps.claimItem).not.toHaveBeenCalled(); - // The backlog tiers are never reached (AC6). - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); - expect(deps.getNextItem).not.toHaveBeenCalled(); - }); - - it('persists lastTriggeredAt and writes the scheduled log marker before the spawn (AC4)', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // The trigger timestamp is persisted (atomic config update) and the - // rolling log marker is written with kind scheduled + noItemComment. - expect(deps.recordScheduledPromptTrigger).toHaveBeenCalledWith( - '/repo', - '/skill:refactor', - expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), - ); - expect(deps.recordDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - itemId: '/skill:refactor', - kind: 'scheduled', - cwd: '/repo', - noItemComment: true, - }), - ); - // Marker + persist before spawn: the dispatch is recorded before the - // pane opens (fail-closed: an unrecorded dispatch never runs). - const persistOrder = - (deps.recordScheduledPromptTrigger as ReturnType).mock.invocationCallOrder[0]; - const markerOrder = - (deps.recordDispatch as ReturnType).mock.invocationCallOrder[0]; - const spawnOrder = - (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; - expect(persistOrder).toBeLessThan(markerOrder); - expect(markerOrder).toBeLessThan(spawnOrder); - }); - - it('falls through to the existing tiers when no prompt is due', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue(null), - getNextAuditCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - }); - - it('skips the scheduled tier while frozen (frozen OR ambiguous are fail-closed, AC5)', async () => { - for (const freezeStatus of ['frozen', 'ambiguous'] as const) { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue(freezeStatus), - getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Scheduled prompts are held by the same freeze gate as audit/implement - // (no new code changes during a release) — the tier is never consulted. - expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-PLAN'), - expect.anything(), - ); - } - }); - - it('a frozen+empty plan/intake backlog reports code-freeze even when a prompt is due (AC5)', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('code-freeze'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('aborts before spawn when the lastTriggeredAt persist fails (fail-closed, AC4)', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), - recordScheduledPromptTrigger: vi.fn().mockResolvedValue(false), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('marker-write-failed'); - // No log record, no pane: an unrecorded dispatch never runs; the entry - // stays due for the next idle slot. - expect(deps.recordDispatch).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('aborts before spawn when the log marker cannot be written (fail-closed, AC4)', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), - recordDispatch: vi.fn().mockResolvedValue(false), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('marker-write-failed'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('records a spawn-failure trace and resolves spawn-failed when the pane fails (AC4)', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('spawn-failed'); - expect(outcome.error).toBe('ENOENT'); - expect(deps.recordDispatchFailure).toHaveBeenCalledWith( - expect.objectContaining({ - itemId: '/skill:refactor', - kind: 'scheduled', - error: 'ENOENT', - noItemComment: true, - }), - ); - }); - - it('reports no-candidate (the cooldown trigger) when no prompt is due and the backlog is empty (AC6)', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue(null), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - }); - - it('buildDowntimePaneArgs honours an explicit paneName (Downtime for scheduled prompts)', () => { - const args = buildDowntimePaneArgs('plan', '/skill:refactor', { - model: 'plan', - cwd: '/repo', - paneName: 'Downtime /skill:refactor', - }); - expect(args).toEqual([ - '--pane-name', - 'Downtime /skill:refactor', - '--no-focus', - '--cwd', - '/repo', - '--model', - 'plan', - '/skill:refactor', - ]); - }); -}); - -// ── Implement candidate selection (WL-0MSMAYIKX005LLO4) ────────────── - -describe('implement selection (selectImplementCandidate)', () => { - const open: ImplementCandidate = { - id: 'WL-OPEN', - title: 'open item', - status: 'open', - risk: 'low', - effort: 'small', - sortIndex: 100, - }; - const completed: ImplementCandidate = { - id: 'WL-DONE', - title: 'completed item', - status: 'completed', - risk: 'low', - effort: 'small', - sortIndex: 200, - }; - - it('selects the first open candidate in wl next priority order (ascending sortIndex)', () => { - expect(selectImplementCandidate([open, completed])?.id).toBe('WL-OPEN'); - }); - - it('excludes completed candidates (status=open client-side filter, AC2)', () => { - expect(selectImplementCandidate([completed, { ...open, id: 'WL-OPEN2', sortIndex: 300 }])?.id).toBe('WL-OPEN2'); - }); - - it('returns null when no open candidate exists', () => { - expect(selectImplementCandidate([completed])).toBeNull(); - }); - - it('returns null on an empty list', () => { - expect(selectImplementCandidate([])).toBeNull(); - }); - - it('sorts ascending by sortIndex (wl next priority order preserved)', () => { - const low = { ...open, id: 'WL-LOW', sortIndex: 50 }; - const high = { ...open, id: 'WL-HIGH', sortIndex: 900 }; - expect(selectImplementCandidate([high, low])?.id).toBe('WL-LOW'); - }); - - it('excludes candidates present in the dispatched marker set (kind implement)', () => { - const dispatched = new Set(['WL-DUP']); - const dup = { ...open, id: 'WL-DUP', sortIndex: 10 }; - const fresh = { ...open, id: 'WL-FRESH', sortIndex: 20 }; - expect(selectImplementCandidate([dup, fresh], dispatched)?.id).toBe('WL-FRESH'); - }); - - it('an empty dispatched set is a no-op', () => { - expect(selectImplementCandidate([open], new Set())?.id).toBe('WL-OPEN'); - }); - - // ── Risk/effort threshold boundaries (AC1) ────────────────────────── - // Belt-and-suspenders client-side guard: even though `wl next --risk low - // --effort small` filters server-side, the herdr tier verifies the - // thresholds again so a malformed/absent server filter can never dispatch - // a Medium+/Large+ item (fail-closed). - - it('risk ≤ Medium eligible (High/Critical excluded)', () => { - const low = { ...open, id: 'WL-LOW', risk: 'low' as const }; - const medium = { ...open, id: 'WL-MED', risk: 'medium' as const }; - const high = { ...open, id: 'WL-HIGH-R', risk: 'high' as const }; - const critical = { ...open, id: 'WL-CRIT', risk: 'critical' as const }; - // medium is first eligible in input order (all share sortIndex 100) - expect(selectImplementCandidate([medium, low, high, critical, open])?.id).toBe('WL-MED'); - expect(selectImplementCandidate([medium])?.id).toBe('WL-MED'); - expect(selectImplementCandidate([low])?.id).toBe('WL-LOW'); - expect(selectImplementCandidate([high])).toBeNull(); - expect(selectImplementCandidate([critical])).toBeNull(); - }); - - it('effort ≤ Medium eligible (Large/Extra Large excluded)', () => { - const xs = { ...open, id: 'WL-XS', effort: 'xs' as const }; - const small = { ...open, id: 'WL-S', effort: 'small' as const }; - const medium = { ...open, id: 'WL-MED-E', effort: 'medium' as const }; - const large = { ...open, id: 'WL-LARGE', effort: 'large' as const }; - const xl = { ...open, id: 'WL-XL', effort: 'xl' as const }; - // medium is first eligible in input order (all share sortIndex 100) - expect(selectImplementCandidate([medium, large, xl, open])?.id).toBe('WL-MED-E'); - expect(selectImplementCandidate([xs])?.id).toBe('WL-XS'); - expect(selectImplementCandidate([small])?.id).toBe('WL-S'); - expect(selectImplementCandidate([medium])?.id).toBe('WL-MED-E'); - expect(selectImplementCandidate([large])).toBeNull(); - expect(selectImplementCandidate([xl])).toBeNull(); - }); - - it('unset/empty risk or effort items are excluded (fail-closed)', () => { - const noRisk = { ...open, id: 'WL-NORISK', risk: undefined }; - const noEffort = { ...open, id: 'WL-NOEFFORT', effort: undefined }; - expect(selectImplementCandidate([noRisk, open])?.id).toBe('WL-OPEN'); - expect(selectImplementCandidate([noEffort, open])?.id).toBe('WL-OPEN'); - expect(selectImplementCandidate([noRisk])).toBeNull(); - expect(selectImplementCandidate([noEffort])).toBeNull(); - }); - - it('recognizes long-form effort spellings (Small / Medium / Extra Small)', () => { - const small = { ...open, id: 'WL-S', effort: 'Small' as const }; - const medium = { ...open, id: 'WL-M', effort: 'Medium' as const }; - const extraSmall = { ...open, id: 'WL-ES', effort: 'Extra Small' as const }; - expect(selectImplementCandidate([small])?.id).toBe('WL-S'); - expect(selectImplementCandidate([medium])?.id).toBe('WL-M'); - expect(selectImplementCandidate([extraSmall])?.id).toBe('WL-ES'); - }); - - it('handles verbose risk/effort descriptions (agent-produced fields)', () => { - const verboseRisk = { ...open, id: 'WL-VERBOSE-RISK', risk: 'medium — NVIDIA driver changes can affect GPU functionality system-wide. Wrong driver version could worsen the problem. Mitigation: test in non-production context first if possible; have rollback plan.' }; - const verboseEffort = { ...open, id: 'WL-VERBOSE-EFFORT', effort: '1–4 hours (estimate: o=1, m=2, p=4) — Small. Diagnostic investigation + targeted fix on a single system. No code changes expected.' }; - // Verbose descriptions with em-dash should extract the leading keyword - expect(selectImplementCandidate([verboseRisk])?.id).toBe('WL-VERBOSE-RISK'); - expect(selectImplementCandidate([verboseEffort])?.id).toBe('WL-VERBOSE-EFFORT'); - // High risk should still be excluded even with verbose description - const highVerbose = { ...open, id: 'WL-HIGH-V', risk: 'high — something went wrong' }; - expect(selectImplementCandidate([highVerbose])).toBeNull(); - }); - - it('regression: a critical item with verbose risk/effort fields wins over a medium item (WL-0MSN3FWV5008KQE9 dispatch)', () => { - // Real-world regression: the downtime dispatcher skipped the critical NVIDIA - // item because its risk/effort fields carry descriptions, selecting the - // medium mode-switch item instead. - const nvidia = { - ...open, - id: 'WL-0MT1KJNDK004SNPK', - title: 'Fix NVIDIA kernel/userspace driver API mismatch (chrome-headless)', - risk: 'Medium — NVIDIA driver changes can affect GPU functionality system-wide. Wrong driver version could worsen the problem. Mitigation: test in non-production context first if possible; have rollback plan.', - effort: '1–4 hours (estimate: o=1, m=2, p=4) — Small. Diagnostic investigation + targeted fix on a single system. No code changes expected.', - sortIndex: 900, - priority: 'critical', - }; - const modeSwitch = { - ...open, - id: 'WL-0MSN3FWV5008KQE9', - title: 'Herdr plugin: activity-gated proxy mode switching', - risk: 'Medium', - effort: 'Small', - sortIndex: 1600, - priority: 'medium', - }; - // Critical (sortIndex 900) must be selected ahead of medium (sortIndex 1600) - expect(selectImplementCandidate([modeSwitch, nvidia])?.id).toBe('WL-0MT1KJNDK004SNPK'); - }); -}); - -describe('parseImplementCandidatesOutput', () => { - it('parses the wl next -n N { workItems: [{ workItem }] } shape', () => { - const stdout = JSON.stringify({ - success: true, - count: 2, - requested: 10, - results: [ - { workItem: { id: 'WL-A', title: 'A', status: 'open', risk: 'low', effort: 'small' } }, - { workItem: { id: 'WL-B', title: 'B', status: 'open', risk: 'low', effort: 'xs' } }, - ], - workItems: [ - { workItem: { id: 'WL-A', title: 'A', status: 'open', risk: 'low', effort: 'small' } }, - { workItem: { id: 'WL-B', title: 'B', status: 'open', risk: 'low', effort: 'xs' } }, - ], - }); - const parsed = parseImplementCandidatesOutput(stdout); - expect(parsed).not.toBeNull(); - expect(parsed).toHaveLength(2); - expect(parsed?.[0]).toMatchObject({ id: 'WL-A', status: 'open', risk: 'low', effort: 'small' }); - expect(parsed?.[1]).toMatchObject({ id: 'WL-B', status: 'open', effort: 'xs' }); - }); - - it('fails closed (null) on malformed JSON', () => { - expect(parseImplementCandidatesOutput('not json')).toBeNull(); - }); - - it('fails closed (null) on output without a workItems list', () => { - expect(parseImplementCandidatesOutput(JSON.stringify({ success: true }))).toBeNull(); - }); - - it('returns an empty array for an empty workItems list', () => { - expect(parseImplementCandidatesOutput(JSON.stringify({ success: true, count: 0, workItems: [] }))).toEqual([]); - }); - - it('skips entries without an id but keeps valid ones', () => { - const stdout = JSON.stringify({ - success: true, - workItems: [{ workItem: { title: 'no id' } }, { workItem: { id: 'WL-OK', title: 'ok' } }], - }); - const parsed = parseImplementCandidatesOutput(stdout); - expect(parsed?.length).toBe(1); - expect(parsed?.[0].id).toBe('WL-OK'); - }); -}); - -describe('implement prompt & pane helpers', () => { - const candidate: DowntimeCandidate = { id: 'WL-IMP', title: 'Implement me', stage: 'implement' }; - - it('builds an /skill:implement prompt', () => { - const prompt = buildDowntimePrompt('implement', candidate); - expect(prompt).toContain('/skill:implement WL-IMP'); - }); - - it('builds a Downtime implement pane name', () => { - const args = buildDowntimePaneArgs('implement', 'Run /skill:implement WL-IMP — Implement me.', { - model: 'plan', - cwd: '/repo', - }); - expect(args).toContain('Downtime implement'); - expect(args).toContain('--no-focus'); - }); - - it('skillKindFromPrompt detects an implement prompt', () => { - expect(skillKindFromPrompt('Run /skill:implement WL-IMP — x.')).toBe('implement'); - }); - - // risk-effort kind (WL-0MTTSWCJR003OMN7) - it('skillKindFromPrompt detects a risk-effort prompt', () => { - expect(skillKindFromPrompt('Run /skill:effort-and-risk WL-RE — x.')).toBe('risk-effort'); - }); - - it('buildDowntimeDispatchComment renders /skill:implement', () => { - const comment = buildDowntimeDispatchComment('WL-IMP', 'implement', '2026-01-01T00:00:00.000Z'); - expect(comment).toContain('/skill:implement WL-IMP'); - }); -}); - -describe('audit selection (selectAuditCandidate)', () => { - // Fixed clock so the 7-day recency filter is deterministic (all fixture - // dates fall within the window). - const NOW = new Date('2026-01-01T00:05:00.000Z').getTime(); - const fresh: AuditCandidate = { - id: 'FRESH', - title: 'fresh', - auditedAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:30.000Z', - sortIndex: 200, - }; - const stale: AuditCandidate = { - id: 'STALE', - title: 'stale', - auditedAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:02:00.000Z', // >60s later -> stale - sortIndex: 300, - }; - const unaudited: AuditCandidate = { id: 'NOAUDIT', title: 'no audit', sortIndex: 100 }; - - it('selects an item with a missing audit', () => { - expect(selectAuditCandidate([fresh, unaudited], NOW)?.id).toBe('NOAUDIT'); - }); - - it('does not select an item with a fresh audit', () => { - expect(selectAuditCandidate([fresh], NOW)).toBeNull(); - }); - - it('selects a stale item (audit older than the 60s buffer)', () => { - expect(selectAuditCandidate([fresh, stale], NOW)).toEqual(stale); - }); - - it('sorts ascending by sortIndex and returns the first', () => { - expect(selectAuditCandidate([stale, unaudited], NOW)?.id).toBe('NOAUDIT'); - expect(selectAuditCandidate([{ ...unaudited, sortIndex: 500 }, stale], NOW)?.id).toBe('STALE'); - }); - - it('returns null on an empty list', () => { - expect(selectAuditCandidate([], NOW)).toBeNull(); - }); - - it('never selects a covered child — children are never dispatched independently (AC2)', () => { - // A covered child: completed/in_review, no own fresh audit, parent has the - // fresh covering audit. The audit tier is root-only, and this client-side - // guard is belt-and-suspenders for a leaking/faulty response. - const coveredChild: AuditCandidate = { - id: 'WL-COVERED-CHILD', - title: 'Covered child', - parentId: 'WL-PARENT', - updatedAt: '2026-01-01T00:00:00.000Z', - sortIndex: 1, - }; - expect(selectAuditCandidate([coveredChild, unaudited], NOW)?.id).toBe('NOAUDIT'); - expect(selectAuditCandidate([coveredChild], NOW)).toBeNull(); - }); - - it('never selects an uncovered child either (AC2)', () => { - // Uncovered child: parent demoted/stale, so no covering audit — it must - // still never enter the audit dispatch tier (flagged, not dispatched; - // WL-0MUBVH9FV0027COG owns durable uncovered reporting). - const uncoveredChild: AuditCandidate = { - id: 'WL-UNCOVERED-CHILD', - title: 'Uncovered child', - parentId: 'WL-DEMOTED-PARENT', - updatedAt: '2026-01-01T00:00:00.000Z', - sortIndex: 1, - }; - expect(selectAuditCandidate([uncoveredChild], NOW)).toBeNull(); - }); - - it('classifies the 60s freshness boundary correctly', () => { - // auditedAt exactly 60s before updatedAt -> stale (selected) - const boundaryStale: AuditCandidate = { - id: 'B1', - title: 'Boundary stale', - auditedAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:01:00.000Z', - }; - // auditedAt just under 60s before updatedAt -> fresh (not selected) - const boundaryFresh: AuditCandidate = { - id: 'B2', - title: 'Boundary fresh', - auditedAt: '2026-01-01T00:00:30.000Z', - updatedAt: '2026-01-01T00:01:00.000Z', - }; - expect(selectAuditCandidate([boundaryStale], NOW)?.id).toBe('B1'); - expect(selectAuditCandidate([boundaryFresh], NOW)).toBeNull(); - }); - - it('does not select an item whose updatedAt did not move (flag-only flip, WL-0MSN6ZCTN0027U2R)', () => { - // The worklog core guarantees (packages/shared/src/database.ts update()) - // that flipping needsProducerReview does NOT bump updatedAt — so after - // the flip the item still has its pre-audit updatedAt and the audit - // remains fresh. selectAuditCandidate must therefore not re-dispatch - // a redundant /skill:audit for it. - const afterFlagFlip: AuditCandidate = { - id: 'FLAGFLIP', - title: 'flagged for review', - auditedAt: '2026-01-01T00:00:30.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', // unchanged by the flag flip - sortIndex: 10, - }; - expect(selectAuditCandidate([afterFlagFlip], NOW)).toBeNull(); - }); -}); - -describe('audit selection 7-day recency (selectAuditCandidate)', () => { - const DAY_MS = 24 * 60 * 60 * 1000; - const NOW = new Date('2026-01-01T12:00:00.000Z').getTime(); - const recentStale: AuditCandidate = { - id: 'RECENT', - title: 'modified 6 days ago', - // audited >60s before updatedAt -> stale audit (selected by freshness) - auditedAt: new Date(NOW - 6 * DAY_MS - 120_000).toISOString(), - updatedAt: new Date(NOW - 6 * DAY_MS).toISOString(), - sortIndex: 100, - }; - const ancientStale: AuditCandidate = { - id: 'ANCIENT', - title: 'modified 8 days ago', - auditedAt: new Date(NOW - 8 * DAY_MS - 120_000).toISOString(), - updatedAt: new Date(NOW - 8 * DAY_MS).toISOString(), - sortIndex: 100, - }; - - it('selects a stale-audit item modified within the last 7 days', () => { - expect(selectAuditCandidate([ancientStale, recentStale], NOW)?.id).toBe('RECENT'); - }); - - it('excludes stale-audit items not modified within the last 7 days', () => { - expect(selectAuditCandidate([ancientStale], NOW)).toBeNull(); - }); - - it('includes a candidate with a missing updatedAt (recency cannot be verified)', () => { - const missing: AuditCandidate = { id: 'MISSING', title: 'no updatedAt', sortIndex: 100 }; - expect(selectAuditCandidate([missing], NOW)?.id).toBe('MISSING'); - }); - - it('excludes a candidate with an unparseable updatedAt (fail-closed)', () => { - const garbage: AuditCandidate = { - id: 'GARBAGE', - title: 'bad date', - updatedAt: 'not-a-date', - sortIndex: 100, - }; - expect(selectAuditCandidate([garbage], NOW)).toBeNull(); - }); - - it('includes a candidate modified exactly 7 days ago (boundary inclusive)', () => { - const boundary: AuditCandidate = { - id: 'EDGE', - title: 'exactly 7 days', - auditedAt: new Date(NOW - 7 * DAY_MS - 120_000).toISOString(), - updatedAt: new Date(NOW - 7 * DAY_MS).toISOString(), - sortIndex: 100, - }; - expect(selectAuditCandidate([boundary], NOW)?.id).toBe('EDGE'); - }); - - it('excludes a candidate modified 7 days and 1ms ago', () => { - const justOutside: AuditCandidate = { - id: 'OUT', - title: '7 days + 1ms', - auditedAt: new Date(NOW - 7 * DAY_MS - 120_001).toISOString(), - updatedAt: new Date(NOW - 7 * DAY_MS - 1).toISOString(), - sortIndex: 100, - }; - expect(selectAuditCandidate([justOutside], NOW)).toBeNull(); - }); -}); - -describe('audit selection dispatched-marker exclusion (selectAuditCandidate)', () => { - // Regression (WL-0MSGTLSUT002NF29): an item already dispatched for audit - // by the downtime worker must never be re-selected while it still lacks a - // fresh audit — even though it is completed/in_review (the dispatched run - // reverts the status without recording a fresh audit). - const NOW = new Date('2026-01-01T00:05:00.000Z').getTime(); - const dispatchedUnaudited: AuditCandidate = { - id: 'WL-DUP', - title: 'already dispatched, no audit', - sortIndex: 100, - }; - const otherUnaudited: AuditCandidate = { - id: 'WL-OTHER', - title: 'not dispatched', - sortIndex: 200, - }; - - it('excludes a candidate present in the dispatched set with no fresh audit (regression)', () => { - const dispatched = new Set(['WL-DUP']); - expect(selectAuditCandidate([dispatchedUnaudited], NOW, dispatched)).toBeNull(); - }); - - it('selects another candidate when the first is excluded', () => { - const dispatched = new Set(['WL-DUP']); - expect(selectAuditCandidate([dispatchedUnaudited, otherUnaudited], NOW, dispatched)?.id).toBe( - 'WL-OTHER', - ); - }); - - it('composes with audit freshness: a fresh audit still excludes the item (unchanged)', () => { - const freshDispatched: AuditCandidate = { - id: 'WL-FRESH', - title: 'fresh audit since dispatch', - auditedAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:30.000Z', - sortIndex: 100, - }; - const dispatched = new Set(['WL-FRESH']); - // isAuditFresh governs: fresh → not a candidate, even without the marker. - expect(selectAuditCandidate([freshDispatched], NOW)).toBeNull(); - // Marker + fresh audit → still not a candidate (unchanged). - expect(selectAuditCandidate([freshDispatched], NOW, dispatched)).toBeNull(); - }); - - it('a stale/absent audit plus an audit marker is excluded', () => { - const staleDispatched: AuditCandidate = { - id: 'WL-STALE', - title: 'stale audit since dispatch', - auditedAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:02:00.000Z', // >60s later -> stale - sortIndex: 100, - }; - expect(selectAuditCandidate([staleDispatched], NOW, new Set(['WL-STALE']))).toBeNull(); - }); - - it('an item without a marker is selected as before (empty set is a no-op)', () => { - expect(selectAuditCandidate([dispatchedUnaudited], NOW, new Set())?.id).toBe('WL-DUP'); - }); -}); - -describe('parseAuditCandidatesOutput', () => { - it('parses a { workItems: [...] } shape', () => { - const stdout = JSON.stringify({ - success: true, - count: 1, - workItems: [{ id: 'WL-1', title: 'A', sortIndex: 100, auditedAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:30.000Z' }], - }); - const parsed = parseAuditCandidatesOutput(stdout); - expect(parsed).not.toBeNull(); - expect(parsed![0].id).toBe('WL-1'); - expect(parsed![0].sortIndex).toBe(100); - }); - - it('parses a bare array shape', () => { - const stdout = JSON.stringify([{ id: 'WL-2', title: 'B' }]); - expect(parseAuditCandidatesOutput(stdout)?.[0].id).toBe('WL-2'); - }); - - it('fails closed (null) on malformed JSON', () => { - expect(parseAuditCandidatesOutput('not json')).toBeNull(); - }); - - it('fails closed (null) on output without a list', () => { - expect(parseAuditCandidatesOutput(JSON.stringify({ success: false }))).toBeNull(); - }); - - it('skips items without an id but keeps valid ones', () => { - const stdout = JSON.stringify({ workItems: [{ title: 'no id' }, { id: 'WL-3', title: 'C' }] }); - const parsed = parseAuditCandidatesOutput(stdout); - expect(parsed).not.toBeNull(); - expect(parsed!.length).toBe(1); - expect(parsed![0].id).toBe('WL-3'); - }); - - it('returns an empty array for an empty workItems list', () => { - expect(parseAuditCandidatesOutput(JSON.stringify({ workItems: [] }))).toEqual([]); - }); - - it('parses parentId so child candidates can be excluded client-side (AC2)', () => { - const stdout = JSON.stringify({ - workItems: [ - { id: 'WL-ROOT', title: 'Root', sortIndex: 1 }, - { id: 'WL-CHILD', title: 'Child', parentId: 'WL-ROOT', sortIndex: 2 }, - ], - }); - const parsed = parseAuditCandidatesOutput(stdout); - expect(parsed?.[0].parentId).toBeUndefined(); - expect(parsed?.[1].parentId).toBe('WL-ROOT'); - }); -}); - -describe('parseInProgressOutput (active-audit single-flight)', () => { - it('parses bare workItems entries into their ids', () => { - const stdout = JSON.stringify({ success: true, count: 2, workItems: [{ id: 'WL-A' }, { id: 'WL-B' }] }); - expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A', 'WL-B']); - }); - - it('also accepts the { workItem } wrapper shape inside workItems', () => { - const stdout = JSON.stringify({ workItems: [{ workItem: { id: 'WL-A' } }, { workItem: { id: 'WL-B' } }] }); - expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A', 'WL-B']); - }); - - it('fails closed (null) on malformed JSON', () => { - expect(parseInProgressOutput('not json')).toBeNull(); - }); - - it('fails closed (null) on output without a list', () => { - expect(parseInProgressOutput(JSON.stringify({ success: false }))).toBeNull(); - }); - - it('skips entries without an id but keeps valid ones', () => { - const stdout = JSON.stringify({ workItems: [{ title: 'no id' }, { id: 'WL-A' }] }); - expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A']); - }); - - it('returns an empty set for an empty workItems list', () => { - expect([...parseInProgressOutput(JSON.stringify({ workItems: [] }))!]).toEqual([]); - }); -}); - -describe('audit prompt & pane helpers', () => { - const candidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; - - it('builds an /skill:audit prompt', () => { - const prompt = buildDowntimePrompt('audit', candidate); - expect(prompt).toContain('/skill:audit WL-AUD'); - }); - - it('builds a Downtime audit pane name', () => { - const args = buildDowntimePaneArgs('audit', 'Run /skill:audit WL-AUD — Audit me.', { - model: 'plan', - cwd: '/repo', - }); - expect(args).toContain('Downtime audit'); - expect(args).toContain('--no-focus'); - }); - - it('skillKindFromPrompt detects an audit prompt', () => { - expect(skillKindFromPrompt('Run /skill:audit WL-AUD — x.')).toBe('audit'); - expect(skillKindFromPrompt('Run /skill:plan WL-1 — x.')).toBe('plan'); - expect(skillKindFromPrompt('Run /skill:intake WL-2 — x.')).toBe('intake'); - }); - - it('buildDowntimeDispatchComment renders /skill:audit', () => { - const comment = buildDowntimeDispatchComment('WL-AUD', 'audit', '2026-01-01T00:00:00.000Z'); - expect(comment).toContain('/skill:audit WL-AUD'); - }); - - it('toDowntimeCandidate produces a stage-audit candidate', () => { - expect(toDowntimeCandidate({ id: 'X', title: 'T' })).toEqual({ - id: 'X', - title: 'T', - stage: 'audit', - }); - }); -}); - -// ── Dispatch audit trail (WL-0MSGPI4AR000YOK8) ──────────────────────── - -describe('dispatch audit trail', () => { - it('records a plan dispatch with item id, kind, timestamp and cwd', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(deps.recordDispatch).toHaveBeenCalledTimes(1); - const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; - expect(event.itemId).toBe('WL-ABC'); - expect(event.kind).toBe('plan'); - expect(event.cwd).toBe('/repo'); - expect(Number.isNaN(Date.parse(event.dispatchedAt))).toBe(false); - }); - - it('records an intake fallback dispatch', async () => { - const deps = makeDeps({ - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: null }) - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-DEF', title: 'An idea', stage: 'idea' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('intake'); - const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; - expect(event.itemId).toBe('WL-DEF'); - expect(event.kind).toBe('intake'); - }); - - it('does not record when nothing is dispatched', async () => { - const deps = makeDeps(); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.recordDispatch).not.toHaveBeenCalled(); - }); - - it('rolls the claim back (claim-rolled-back) when recordDispatch fails — AC1', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - // A rejecting marker write (or a stub that throws) must abort BEFORE - // the pane spawns: an unmarked item is never dispatched (RCA - // WL-0MSRBFFLN005W3VT design point 2 — marker-before-spawn fail-closed). - // WL-0MT32F908002YFFA AC1: the successful CAS claim is then rolled - // back so the next idle period can re-select the item. - recordDispatch: vi.fn().mockRejectedValue(new Error('audit boom')), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('claim-rolled-back'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.rollbackClaim).toHaveBeenCalledWith( - 'WL-ABC', - { status: 'open', stage: 'intake_complete' }, - '/repo', - ); - }); - - it('rolls the claim back (claim-rolled-back) when recordDispatch resolves false — AC1', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - recordDispatch: vi.fn().mockResolvedValue(false), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('claim-rolled-back'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.rollbackClaim).toHaveBeenCalledWith( - 'WL-ABC', - { status: 'open', stage: 'intake_complete' }, - '/repo', - ); - }); - - it('falls back to marker-write-failed when the claim rollback itself fails (fail-closed)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - recordDispatch: vi.fn().mockRejectedValue(new Error('audit boom')), - // Concurrent agent already moved the item — nothing to roll back. - rollbackClaim: vi.fn().mockResolvedValue(false), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('marker-write-failed'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('never crashes when the claim rollback throws (fail-closed)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - recordDispatch: vi.fn().mockResolvedValue(false), - rollbackClaim: vi.fn().mockRejectedValue(new Error('wl update boom')), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('marker-write-failed'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('writes the marker BEFORE the pane spawns (marker-before-spawn ordering)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - const markerOrder = (deps.recordDispatch as ReturnType).mock.invocationCallOrder[0]; - const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; - expect(markerOrder).toBeLessThan(spawnOrder); - }); - - it('records the dispatched-at stage on plan/intake markers (change-guard input)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; - expect(event.stage).toBe('intake_complete'); - }); -}); - -// ── CAS claim race + fail-closed paths (RCA WL-0MSRBFFLN005W3VT) ─────── - -describe('dispatch CAS claim race', () => { - it('a losing pane (stale claim) aborts with no pane, no marker, no success record', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - // Another pane claimed the item first: the CAS guard fails stale. - claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'stale' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('claim-failed'); - // No pane, no marker, no success record — the loser aborts entirely. - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - }); - - it('a claim wl failure is a wl-error (a strike, never silently discarded)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'error' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('wl-error'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.recordDispatch).not.toHaveBeenCalled(); - }); - - it('a stale audit-tier claim also aborts (no fall-through to other tiers)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: { - id: 'WL-AUD', - title: 'Audit me', - stage: 'audit', - }, - }), - claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'stale' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('claim-failed'); - // The loser does NOT fall through to plan/intake — exactly one dispatch - // per idle window across panes. - expect(deps.getNextItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('a spawn error makes the outcome not-success (spawn-failed) with the marker already written', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT: send-to-pi.sh' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('spawn-failed'); - expect(outcome.error).toBe('ENOENT: send-to-pi.sh'); - // The marker stands (fail-closed — no re-dispatch of the same item), but - // the outcome is NOT a success (WL-0MSLWJ3I70031Z8U absorbed). - expect(deps.recordDispatch).toHaveBeenCalledTimes(1); - // A failure trace is appended so the audit log distinguishes - // "attempted" from "opened" (AC2) — it never logs success for a pane - // that never appeared. - expect(deps.recordDispatchFailure).toHaveBeenCalledWith( - expect.objectContaining({ - itemId: 'WL-ABC', - kind: 'plan', - cwd: '/repo', - error: 'ENOENT: send-to-pi.sh', - }), - ); - }); - - it('a non-zero script exit makes the outcome not-success (spawn-failed) with the exit trace recorded', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, exitCode: 1 }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('spawn-failed'); - expect(outcome.exitCode).toBe(1); - // The failure trace carries the exit code for the audit log (AC2). - expect(deps.recordDispatchFailure).toHaveBeenCalledWith( - expect.objectContaining({ itemId: 'WL-ABC', kind: 'plan', exitCode: 1 }), - ); - }); - - it('rolls the plan claim back on spawn-failed so the item is re-selectable — AC2/AC4', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT: send-to-pi.sh' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('spawn-failed'); - // The failure trace is preserved AND the claim is rolled back to the - // tier's pre-claim state — the item returns to the selectable backlog. - expect(deps.recordDispatchFailure).toHaveBeenCalledWith( - expect.objectContaining({ itemId: 'WL-ABC', kind: 'plan', error: 'ENOENT: send-to-pi.sh' }), - ); - expect(deps.rollbackClaim).toHaveBeenCalledWith( - 'WL-ABC', - { status: 'open', stage: 'intake_complete' }, - '/repo', - ); - }); - - it('rolls an audit claim back to completed/in_review on spawn-failed (audit tier)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit', status: 'completed' }, - }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('spawn-failed'); - // The audit tier's pre-claim state is completed/in_review, NOT open — - // rolling back to `open` would silently move the item out of the audit - // queue (WL-0MT32F908002YFFA AC1/AC2). - expect(deps.rollbackClaim).toHaveBeenCalledWith( - 'WL-AUD', - { status: 'completed', stage: 'in_review' }, - '/repo', - ); - }); - - it('marker-write-failed recovery: claim → failure → rollback → re-selection next idle period (AC1/AC4)', async () => { - // A tiny in-memory item store: the claim flips it to in_progress, the - // rollback flips it back to open, and getNextItem only offers the item - // while it is open — so the SECOND idle window selects it again. - let status: 'open' | 'in_progress' = 'open'; - const recordDispatch = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true); - const deps = makeDeps({ - getNextItem: vi.fn().mockImplementation(async () => - status === 'open' - ? { - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - } - : { ok: true, candidate: null }, - ), - claimItem: vi.fn().mockImplementation(async () => { - status = 'in_progress'; - return { ok: true }; - }), - recordDispatch, - rollbackClaim: vi.fn().mockImplementation(async () => { - status = 'open'; - return true; - }), - }); - - const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(first.reason).toBe('claim-rolled-back'); - expect(status).toBe('open'); - - const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(second.dispatched).toBe(true); - }); - - it('spawn-failed recovery: claim → failure → rollback → re-selection next idle period (AC2/AC4)', async () => { - let status: 'open' | 'in_progress' = 'open'; - const spawnAgentPane = vi - .fn() - .mockResolvedValueOnce({ ok: false, error: 'ENOENT: send-to-pi.sh' }) - .mockResolvedValue({ ok: true }); - const deps = makeDeps({ - getNextItem: vi.fn().mockImplementation(async () => - status === 'open' - ? { - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - } - : { ok: true, candidate: null }, - ), - claimItem: vi.fn().mockImplementation(async () => { - status = 'in_progress'; - return { ok: true }; - }), - spawnAgentPane, - rollbackClaim: vi.fn().mockImplementation(async () => { - status = 'open'; - return true; - }), - }); - - const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(first.reason).toBe('spawn-failed'); - expect(status).toBe('open'); - - const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(second.dispatched).toBe(true); - }); - - it('a throwing recordDispatchFailure never crashes the dispatch (fail-closed)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, exitCode: 1 }), - recordDispatchFailure: vi.fn().mockRejectedValue(new Error('log io boom')), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('spawn-failed'); - expect(outcome.exitCode).toBe(1); - }); - - it('claim → marker → spawn ordering is fixed (claim first, marker before spawn)', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - const claimOrder = (deps.claimItem as ReturnType).mock.invocationCallOrder[0]; - const markerOrder = (deps.recordDispatch as ReturnType).mock.invocationCallOrder[0]; - const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; - expect(claimOrder).toBeLessThan(markerOrder); - expect(markerOrder).toBeLessThan(spawnOrder); - }); -}); - -// ── Plan/intake selection (RCA WL-0MSRBFFLN005W3VT RC-2 + amplifier) ── - -describe('plan/intake selection (selectNextCandidate)', () => { - const openCandidate = (id: string, sortIndex = 0, stage: DowntimeStage = 'intake_complete') => ({ - id, - title: id, - stage, - status: 'open', - sortIndex, - }); - - it('selects the first open candidate in wl next priority order (ascending sortIndex)', () => { - const selected = selectNextCandidate([ - openCandidate('WL-2', 20), - openCandidate('WL-1', 10), - ]); - expect(selected?.id).toBe('WL-1'); - }); - - it('excludes a candidate still at its dispatched-at stage (change-guard)', () => { - const selected = selectNextCandidate( - [openCandidate('WL-ONCE', 5), openCandidate('WL-NEXT', 20)], - new Map([['WL-ONCE', 'intake_complete']]), - ); - // WL-ONCE was dispatched for /skill:plan at intake_complete and is still - // at intake_complete → excluded; the next candidate is selected. - expect(selected?.id).toBe('WL-NEXT'); - }); - - it('releases a candidate whose stage advanced past its dispatched-at stage', () => { - // The marker recorded intake_complete but the candidate is now at idea - // (a different stage) → not suppressed. - const selected = selectNextCandidate( - [openCandidate('WL-FREE', 5, 'idea')], - new Map([['WL-FREE', 'intake_complete']]), - ); - expect(selected?.id).toBe('WL-FREE'); - }); - - it('a legacy marker without a recorded stage never suppresses selection', () => { - const selected = selectNextCandidate( - [openCandidate('WL-LEGACY', 5)], - new Map([['WL-LEGACY', '']]), - ); - expect(selected?.id).toBe('WL-LEGACY'); - }); - - it('excludes completed candidates (client-side open-status guard, amplifier fix)', () => { - const selected = selectNextCandidate([ - { ...openCandidate('WL-DONE', 5), status: 'completed' }, - openCandidate('WL-OPEN', 10), - ]); - expect(selected?.id).toBe('WL-OPEN'); - }); - - it('a candidate without a verifiable status is never selected (fail-closed)', () => { - const selected = selectNextCandidate([ - { id: 'WL-NO-STATUS', title: 'x', stage: 'intake_complete' }, - ]); - expect(selected).toBeNull(); - }); - - it('returns null on an empty list', () => { - expect(selectNextCandidate([])).toBeNull(); - }); -}); - -describe('parseNextCandidatesOutput', () => { - it('parses the batch shape (wl next -n N) preserving status and sortIndex', () => { - const candidates = parseNextCandidatesOutput( - JSON.stringify({ - success: true, - workItems: [ - { workItem: { id: 'WL-1', title: 'One', status: 'open', sortIndex: 7 } }, - { workItem: { id: 'WL-2', title: 'Two', status: 'completed', sortIndex: 3 } }, - ], - }), - 'idea', - ); - expect(candidates).toEqual([ - { id: 'WL-1', title: 'One', stage: 'idea', status: 'open', sortIndex: 7 }, - { id: 'WL-2', title: 'Two', stage: 'idea', status: 'completed', sortIndex: 3 }, - ]); - }); - - it('parses the legacy single-item shape', () => { - const candidates = parseNextCandidatesOutput( - JSON.stringify({ success: true, workItem: { id: 'WL-1', title: 'One', status: 'open' } }), - 'intake_complete', - ); - expect(candidates).toEqual([ - { id: 'WL-1', title: 'One', stage: 'intake_complete', status: 'open', sortIndex: undefined }, - ]); - }); - - it('treats workItem:null as an empty backlog (not a parse failure)', () => { - const candidates = parseNextCandidatesOutput( - JSON.stringify({ success: false, workItem: null, reason: 'none' }), - 'idea', - ); - expect(candidates).toEqual([]); - }); - - it('returns null on malformed JSON (fail-closed)', () => { - expect(parseNextCandidatesOutput('not json', 'idea')).toBeNull(); - expect(parseNextCandidatesOutput(JSON.stringify({}), 'idea')).toBeNull(); - }); -}); - -describe('buildDowntimeDispatchComment', () => { - it('states the skill, item id, and ISO timestamp', () => { - const comment = buildDowntimeDispatchComment('WL-ABC', 'plan', '2026-01-01T00:00:00.000Z'); - expect(comment).toContain('/skill:plan WL-ABC'); - expect(comment).toContain('2026-01-01T00:00:00.000Z'); - expect(comment).toContain('herdr downtime worker'); - }); - - it('sanitizes newlines out of the embedded title', () => { - const comment = buildDowntimeDispatchComment('WL-ABC', 'intake', '2026-01-01T00:00:00.000Z', 'multi\nline\rtitle'); - expect(comment).toContain('multi line title'); - expect(comment).not.toMatch(/[\r\n]/); - }); - - // risk-effort comment (WL-0MTTSWCJR003OMN7) - it('renders /skill:effort-and-risk for risk-effort kind', () => { - const comment = buildDowntimeDispatchComment('WL-RE', 'risk-effort', '2026-09-09T12:00:00.000Z'); - expect(comment).toContain('/skill:effort-and-risk WL-RE'); - expect(comment).toContain('herdr downtime worker'); - }); -}); - -// ── Single-flight (AC5) ─────────────────────────────────────────────── - -describe('single-flight dispatch guard', () => { - it('does not dispatch a second time while one dispatch is in flight', async () => { - let release!: () => void; - const gate = new Promise<{ ok: true }>((resolve) => { - release = () => resolve({ ok: true }); - }); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - spawnAgentPane: vi.fn().mockImplementation(() => gate), - }); - - const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - release(); - const [firstOutcome, secondOutcome] = await Promise.all([first, second]); - - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - expect(firstOutcome.dispatched).toBe(true); - expect(secondOutcome.dispatched).toBe(false); - expect(secondOutcome.reason).toBe('dispatch-in-flight'); - }); - - it('allows a new dispatch once the previous one has completed', async () => { - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - }); - - const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(first.dispatched).toBe(true); - expect(second.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); - }); -}); - -// ── Endpoint failures & poller (AC6) — implemented in F2 ────────────── - -describe('endpoint failures and poller', () => { - it('network errors are treated as busy and never throw', async () => { - const fetcher = vi.fn().mockRejectedValue(networkErrorFixture); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - await expect(poller.poll()).resolves.toBeNull(); - }); - - it('timeouts are treated as busy and never throw', async () => { - const fetcher = vi.fn().mockRejectedValue(timeoutErrorFixture); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - await expect(poller.poll()).resolves.toBeNull(); - }); - - it('HTTP error statuses are treated as busy', async () => { - const fetcher = vi.fn().mockResolvedValue(httpErrorResponseFixture); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - await expect(poller.poll()).resolves.toBeNull(); - }); - - it('polls GET {proxyUrl}/llama/local/status', async () => { - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - await poller.poll(); - expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); - }); - - it('normalises a trailing slash on the proxy URL', async () => { - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000/', fetcher); - await poller.poll(); - expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); - }); - - it('returns the parsed status on a successful poll', async () => { - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - await expect(poller.poll()).resolves.toEqual(idleAllSlotsFree); - }); - - it('treats invalid JSON as busy', async () => { - const fetcher = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: async () => { - throw new SyntaxError('Unexpected token'); - }, - }); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - await expect(poller.poll()).resolves.toBeNull(); - }); - - it('treats payloads with missing required fields as busy', async () => { - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(ambiguousMissingFieldsRaw)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - await expect(poller.poll()).resolves.toBeNull(); - }); - - it('derives local_lease_active from the lease fields when the boolean is absent', async () => { - const status = parseLlamaStatus({ - llama_server_running: true, - model_switch_in_progress: false, - available_slots: 4, - total_slots: 4, - local_owner_session_id: 'session-1', - local_owner_lease_remaining_seconds: 120, - }); - expect(status).not.toBeNull(); - expect(status!.local_lease_active).toBe(true); - expect(evaluateIdle(status!, 0)).toBe(false); - }); - - it('coalesces overlapping polls (single-flight, no overlapping fetches)', async () => { - let release!: () => void; - const gate = new Promise((resolve) => { - release = () => resolve(jsonResponseFixture(idleAllSlotsFree)); - }); - const fetcher = vi.fn().mockImplementation(() => gate); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - - const first = poller.poll(); - expect(poller.isPolling()).toBe(true); - const second = poller.poll(); - release(); - - const [firstResult, secondResult] = await Promise.all([first, second]); - expect(fetcher).toHaveBeenCalledTimes(1); - expect(firstResult).toEqual(idleAllSlotsFree); - expect(secondResult).toBe(firstResult); // same coalesced result - expect(poller.isPolling()).toBe(false); - }); - - it('aborts the request after the per-poll timeout (fail closed)', async () => { - vi.useFakeTimers(); - let receivedSignal: unknown; - const fetcher = vi.fn((_url: string, init?: { signal?: unknown }) => { - receivedSignal = init?.signal; - return new Promise((_resolve, reject) => { - const signal = init?.signal as AbortSignal | undefined; - signal?.addEventListener('abort', () => reject(new Error('aborted'))); - }); - }); - - const poller = createDowntimePoller('http://proxy:8000', fetcher, 5_000); - const pollPromise = poller.poll(); - expect(receivedSignal).toBeDefined(); - await vi.advanceTimersByTimeAsync(5_000); - await expect(pollPromise).resolves.toBeNull(); - expect(poller.isPolling()).toBe(false); - vi.useRealTimers(); - }); -}); - -// ── local_active_query parsing (WL-0MSL2ZQIF006QB4Q) ────────────────── - -describe('parseLlamaStatus local_active_query', () => { - const base = { - llama_server_running: true, - model_switch_in_progress: false, - available_slots: 4, - total_slots: 4, - }; - - it('exposes local_active_query=false when the proxy serves it', () => { - const status = parseLlamaStatus({ ...base, local_active_query: false }); - expect(status).not.toBeNull(); - expect(status!.local_active_query).toBe(false); - }); - - it('exposes local_active_query=true when the proxy serves it', () => { - const status = parseLlamaStatus({ ...base, local_active_query: true }); - expect(status).not.toBeNull(); - expect(status!.local_active_query).toBe(true); - }); - - it('leaves local_active_query undefined when absent (pre-fix proxy, backward compatible)', () => { - const status = parseLlamaStatus(base); - expect(status).not.toBeNull(); - expect(status!.local_active_query).toBeUndefined(); - }); - - it('treats a malformed (non-boolean) local_active_query as ambiguous → busy', () => { - expect(parseLlamaStatus({ ...base, local_active_query: 'yes' })).toBeNull(); - }); -}); - -// ── Runtime idle evaluation (F2, AC5) ───────────────────────────────── - -describe('runtime idle evaluation (evaluateIdle)', () => { - const idle: LlamaStatus = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 4, - total_slots: 4, - }; - - function slotsFor(processing: number[], total = 4): LlamaSlot[] { - return Array.from({ length: total }, (_, i) => ({ - slot_id: `s${i + 1}`, - is_processing: processing.includes(i + 1), - })); - } - - it('fails closed when per-slot detail is absent (never admits the count)', () => { - // Fail-closed contract (WL-0MUXVPXAZ005RESW): the untrusted - // `available_slots` count is never a dispatch budget — absent `slots` - // means no trustworthy signal, so even a full count is busy. - expect(evaluateIdle(idle, 0)).toBe(false); - expect(evaluateIdle({ ...idle, available_slots: 3 }, 0)).toBe(false); - expect(evaluateIdle({ ...idle, available_slots: 4 }, 2)).toBe(false); - }); - - it('fails closed when per-slot detail is empty or stale', () => { - expect(evaluateIdle({ ...idle, slots: [] }, 0)).toBe(false); - expect( - evaluateIdle( - { ...idle, slots: slotsFor([]), slots_stale: true }, - 0, - ), - ).toBe(false); - }); - - it('N=0 (default) requires all slots free from the per-slot array', () => { - expect(evaluateIdle({ ...idle, slots: slotsFor([]) }, 0)).toBe(true); - // available_slots still says 4 but the per-slot array shows one busy → busy. - expect( - evaluateIdle({ ...idle, slots: slotsFor([4]) }, 0), - ).toBe(false); - }); - - it('N == total slots behaves like all slots free', () => { - expect(evaluateIdle({ ...idle, slots: slotsFor([]) }, 4)).toBe(true); - expect(evaluateIdle({ ...idle, slots: slotsFor([4]) }, 4)).toBe(false); - }); - - it('N > total slots can never be idle (never dispatches)', () => { - expect(evaluateIdle({ ...idle, slots: slotsFor([]) }, 5)).toBe(false); - }); - - it('ambiguous responses (total_slots 0) are busy', () => { - expect( - evaluateIdle({ ...idle, total_slots: 0, available_slots: 0 }, 0), - ).toBe(false); - }); -}); - -// ── Blocked-questions prompt (AC7) ──────────────────────────────────── - -describe('blocked-questions prompt instruction', () => { - const candidate: DowntimeCandidate = { - id: 'WL-ABC', - title: 'Some task', - stage: 'intake_complete', - }; - - it('the plan prompt runs /skill:plan on the item id', () => { - const prompt = buildDowntimePrompt('plan', candidate); - expect(prompt).toContain('/skill:plan WL-ABC'); - }); - - it('the intake prompt runs /skill:intake on the item id', () => { - const prompt = buildDowntimePrompt('intake', { ...candidate, stage: 'idea' }); - expect(prompt).toContain('/skill:intake WL-ABC'); - }); - - it('instructs recording questions in a comment, flagging needs-producer-review, and stopping', () => { - const prompt = buildDowntimePrompt('plan', candidate); - expect(prompt).toContain('comment'); - expect(prompt).toContain('--needs-producer-review true'); - expect(prompt).toContain('stop'); - expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl comment add'); - expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl update --needs-producer-review true'); - }); - - it('BLOCKED_QUESTIONS_INSTRUCTION includes the final-summary directive', () => { - expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('repeat the questions in your final summary'); - }); - - it('buildDowntimePrompt output includes the final-summary directive', () => { - const prompt = buildDowntimePrompt('implement', candidate); - expect(prompt).toContain('repeat the questions in your final summary'); - }); - - // risk-effort prompt (WL-0MTTSWCJR003OMN7) - it('the risk-effort prompt runs /skill:effort-and-risk on the item id', () => { - const prompt = buildDowntimePrompt('risk-effort', candidate); - expect(prompt).toContain('/skill:effort-and-risk WL-ABC'); - }); -}); - -// ── Settings clamps (AC8) ───────────────────────────────────────────── - -describe('downtime settings clamps', () => { - it('clampDowntimePollInterval enforces the 10s floor and keeps valid values', () => { - expect(clampDowntimePollInterval(5_000)).toBe(DOWNTIME_POLL_INTERVAL_FLOOR_MS); - expect(clampDowntimePollInterval(10_000)).toBe(10_000); - expect(clampDowntimePollInterval(45_000)).toBe(45_000); - }); - - it('clampDowntimePollInterval falls back to the 30s default for non-finite values', () => { - expect(clampDowntimePollInterval(Number.NaN)).toBe(DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - expect(clampDowntimePollInterval(Infinity)).toBe(DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - }); - - it('clampDowntimeIdleThresholdMs rejects negative and non-finite values', () => { - expect(clampDowntimeIdleThresholdMs(-1)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - expect(clampDowntimeIdleThresholdMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - }); - - it('clampDowntimeIdleThresholdMs keeps valid values and floors at 1s', () => { - expect(clampDowntimeIdleThresholdMs(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - expect(clampDowntimeIdleThresholdMs(0)).toBe(1_000); - }); - - it('clampDowntimeRequiredFreeSlots maps negative/non-finite to the default N=2 and rounds', () => { - // Spare-capacity default (parent WL-0MT32F90V008UAD2): negative/non- - // finite input falls back to DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS (2) - // — the out-of-the-box spare-capacity default, still fail-closed safe. - expect(clampDowntimeRequiredFreeSlots(-3)).toBe(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS); - expect(clampDowntimeRequiredFreeSlots(Number.NaN)).toBe(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS); - expect(clampDowntimeRequiredFreeSlots(2.7)).toBe(3); - expect(clampDowntimeRequiredFreeSlots(2)).toBe(2); - }); - - it('clampDowntimeNoCandidateCooldownMs keeps valid values and floors at 60s', () => { - expect(clampDowntimeNoCandidateCooldownMs(3_600_000)).toBe(3_600_000); - expect(clampDowntimeNoCandidateCooldownMs(1_000)).toBe(DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS); - expect(clampDowntimeNoCandidateCooldownMs(60_000)).toBe(60_000); - }); - - it('clampDowntimeNoCandidateCooldownMs rejects negative and non-finite values', () => { - expect(clampDowntimeNoCandidateCooldownMs(-1)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); - expect(clampDowntimeNoCandidateCooldownMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); - expect(clampDowntimeNoCandidateCooldownMs(Infinity)).toBe(DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS); - }); - - it('clampDowntimeNonTerminalCooldownMs keeps valid values and clamps to [60s, 24h]', () => { - expect(clampDowntimeNonTerminalCooldownMs(DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS)).toBe( - DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, - ); - // Below the 60s floor → clamped up (no immediate retry). - expect(clampDowntimeNonTerminalCooldownMs(1_000)).toBe(DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS); - expect(clampDowntimeNonTerminalCooldownMs(60_000)).toBe(60_000); - // Above the 24h ceiling → clamped down (cannot strand indefinitely). - expect(clampDowntimeNonTerminalCooldownMs(48 * 60 * 60 * 1000)).toBe( - DOWNTIME_NON_TERMINAL_COOLDOWN_MAX_MS, - ); - }); - - it('clampDowntimeNonTerminalCooldownMs rejects negative and non-finite values', () => { - expect(clampDowntimeNonTerminalCooldownMs(-1)).toBe(DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS); - expect(clampDowntimeNonTerminalCooldownMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS); - expect(clampDowntimeNonTerminalCooldownMs(Infinity)).toBe(DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS); - }); -}); - -// ── Wiring helpers (F4) ─────────────────────────────────────────────── - -describe('parseNextItemOutput', () => { - it('parses the workItem from `wl next --stage --json`', () => { - const stdout = JSON.stringify({ success: true, workItem: { id: 'WL-ABC', title: 'Some task' } }); - expect(parseNextItemOutput(stdout, 'intake_complete')).toEqual({ - id: 'WL-ABC', - title: 'Some task', - stage: 'intake_complete', - }); - }); - - it('returns null when wl next reports no item (workItem null)', () => { - const stdout = JSON.stringify({ success: false, workItem: null, reason: 'no items' }); - expect(parseNextItemOutput(stdout, 'idea')).toBeNull(); - }); - - it('returns null for malformed JSON', () => { - expect(parseNextItemOutput('not json', 'idea')).toBeNull(); - }); - - it('returns null when the workItem lacks an id', () => { - const stdout = JSON.stringify({ success: true, workItem: { title: 'no id' } }); - expect(parseNextItemOutput(stdout, 'idea')).toBeNull(); - }); -}); - -describe('skillKindFromPrompt', () => { - it('derives plan from a /skill:plan prompt and intake otherwise', () => { - expect(skillKindFromPrompt('Run /skill:plan WL-ABC — Task.')).toBe('plan'); - expect(skillKindFromPrompt('Run /skill:intake WL-DEF — Idea.')).toBe('intake'); - }); -}); - -describe('downtime worker enabled state', () => { - it('exposes the enabled flag from the per-tick config', async () => { - const cfg = { enabled: true }; - const worker = createDowntimeWorker({ - poller: createDowntimePoller('http://proxy:8000'), - deps: makeDeps(), - config: () => ({ - enabled: cfg.enabled, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }), - }); - expect(worker.enabled).toBe(true); - cfg.enabled = false; - expect(worker.enabled).toBe(false); - }); -}); - -// ── Per-instance toggle override (parent WL-0MSZ4NSOE007AQEF) ───────── - -describe('downtime worker per-instance override', () => { - function makeWorker( - override: boolean | null = null, - globalEnabled: boolean = true, - ) { - const cfg = { - enabled: globalEnabled, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }; - const worker = createDowntimeWorker({ - poller: createDowntimePoller('http://proxy:8000'), - deps: makeDeps(), - config: () => ({ ...cfg }), - override, - }); - return { worker, cfg }; - } - - it('defaults to null (follows global settings)', () => { - const { worker } = makeWorker(); - expect(worker.override).toBe(null); - expect(worker.enabled).toBe(true); - }); - - it('toggle() cycles null → false → null (no force-enable)', () => { - const { worker } = makeWorker(); - // null → false (first press disables) - expect(worker.override).toBe(null); - worker.toggle(); - expect(worker.override).toBe(false); - // false → null (second press returns to follow settings) - worker.toggle(); - expect(worker.override).toBe(null); - }); - - it('two consecutive toggles return to follow-settings (null→false→null)', () => { - const { worker, cfg } = makeWorker(); - // First press: disable - worker.toggle(); - expect(worker.override).toBe(false); - expect(worker.enabled).toBe(false); - // Second press: back to follow settings - worker.toggle(); - expect(worker.override).toBe(null); - expect(worker.enabled).toBe(cfg.enabled); // follows global - }); - - it('three presses cycle null → false → null → false', () => { - const { worker } = makeWorker(); - expect(worker.override).toBe(null); - worker.toggle(); // → false - expect(worker.override).toBe(false); - worker.toggle(); // → null - expect(worker.override).toBe(null); - worker.toggle(); // → false again (not true) - expect(worker.override).toBe(false); - }); - - it('tick() short-circuits after disable (override=false)', async () => { - const { worker } = makeWorker(); - worker.toggle(); // → false - const result = await worker.tick(); - expect(result).toEqual({ polled: false, dispatched: false, idle: false }); - }); - - it('tick() follows global setting after second press (null → false → null)', async () => { - vi.useFakeTimers(); - try { - const { worker, cfg } = makeWorker(null, true); - // First press: disable - worker.toggle(); - expect(worker.override).toBe(false); - let result = await worker.tick(); - expect(result).toEqual({ polled: false, dispatched: false, idle: false }); - - // Second press: back to follow settings - worker.toggle(); - expect(worker.override).toBe(null); - result = await worker.tick(); - expect(result.idle).toBe(false); // normal behavior (global busy) - } finally { - vi.useRealTimers(); - } - }); - - it('enabled returns override ?? cfg.enabled (null)', () => { - const { worker, cfg } = makeWorker(); - // null override → follows global - expect(worker.enabled).toBe(true); - cfg.enabled = false; - expect(worker.enabled).toBe(false); - cfg.enabled = true; - // toggle: null → false - worker.toggle(); - expect(worker.override).toBe(false); - expect(worker.enabled).toBe(false); - // toggle: false → null - worker.toggle(); - expect(worker.override).toBe(null); - expect(worker.enabled).toBe(cfg.enabled); // follows global - }); - - it('tick() short-circuits when override is false (even if global is true)', async () => { - const { worker } = makeWorker(false, true); - const result = await worker.tick(); - expect(result).toEqual({ polled: false, dispatched: false, idle: false }); - }); - - it('tick() behaves normally when override is null (follows global)', async () => { - vi.useFakeTimers(); - try { - const { worker } = makeWorker(null, true); - // Busy global → still busy - const result = await worker.tick(); - expect(result.idle).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - it('two workers with independent overrides toggle independently', () => { - const { worker: w1 } = makeWorker(); - const { worker: w2 } = makeWorker(); - w1.toggle(); - expect(w1.override).toBe(false); - expect(w2.override).toBe(null); - expect(w1.enabled).toBe(false); - expect(w2.enabled).toBe(true); - w2.toggle(); // → false - expect(w2.override).toBe(false); - w2.toggle(); // → null - expect(w2.override).toBe(null); - expect(w2.enabled).toBe(true); // follows global - }); - - it('override forces dispatch on when global setting is false', () => { - const { worker } = makeWorker(true, false); - expect(worker.override).toBe(true); - expect(worker.enabled).toBe(true); // override takes precedence - expect(worker.tick).toBeDefined(); - }); - - it('override set via config is respected (pre-toggled worker)', () => { - const { worker } = makeWorker(false, true); - expect(worker.override).toBe(false); - expect(worker.enabled).toBe(false); - }); -}); - -// ── Persisted disable marker (WL-0MT5SFP990001FNW) ─────────────────── - -describe('persisted downtime disable marker (.herdr-downtime-disabled)', () => { - let root: string; - let cwd: string; - - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'herdr-dt-marker-')); - cwd = join(root, 'worklog-root'); - mkdirSync(cwd, { recursive: true }); - }); - - afterEach(() => { - rmSync(root, { recursive: true, force: true }); - }); - - function markerPath(): string { - return join(cwd, DOWNTIME_DISABLE_MARKER_FILE); - } - - function makeWorkerWithCwd( - extra: Partial[0]> = {}, - ) { - const cfg = { - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd, - noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, - }; - const merged = { - poller: createDowntimePoller('http://proxy:8000'), - deps: makeDeps(), - config: () => ({ ...cfg }), - ...extra, - }; - const worker = createDowntimeWorker(merged); - return { worker, cfg }; - } - - it('creates the marker file when toggle() disables (AC1)', () => { - const { worker } = makeWorkerWithCwd(); - expect(existsSync(markerPath())).toBe(false); - worker.toggle(); // disable - expect(worker.override).toBe(false); - expect(existsSync(markerPath())).toBe(true); - }); - - it('does not rewrite an existing marker (no write when already present, AC1)', () => { - writeFileSync(markerPath(), 'existing', 'utf8'); - // Direct helper-level check (a worker constructed with a marker present - // restores override=false, so toggle() would remove it — the no-rewrite - // guarantee of AC1 belongs to the write helper itself). - writeDisableMarker(cwd); - expect(readFileSync(markerPath(), 'utf8')).toBe('existing'); - }); - - it('removes the marker when toggle() returns to follow-settings (AC2)', () => { - const { worker } = makeWorkerWithCwd(); - worker.toggle(); // → false, marker written - expect(existsSync(markerPath())).toBe(true); - worker.toggle(); // → null, marker removed - expect(worker.override).toBe(null); - expect(existsSync(markerPath())).toBe(false); - }); - - it('removing an absent marker is a no-op (AC2)', () => { - const { worker } = makeWorkerWithCwd({ override: false }); - worker.toggle(); // → null → remove absent marker - expect(worker.override).toBe(null); - expect(existsSync(markerPath())).toBe(false); - }); - - it('restores override=false at init when the marker exists (AC3)', () => { - writeFileSync(markerPath(), '', 'utf8'); - const { worker } = makeWorkerWithCwd(); - expect(worker.override).toBe(false); - expect(worker.enabled).toBe(false); - }); - - it('explicit opts.override wins over the marker (AC3)', () => { - writeFileSync(markerPath(), '', 'utf8'); - const { worker } = makeWorkerWithCwd({ override: true }); - expect(worker.override).toBe(true); - expect(worker.enabled).toBe(true); - }); - - it('with no marker, override starts null and follows settings (AC4)', () => { - const { worker } = makeWorkerWithCwd(); - expect(worker.override).toBe(null); - expect(worker.enabled).toBe(true); - }); - - it('the marker is scoped to the worklog root (AC5)', () => { - const otherRoot = join(root, 'other-root'); - mkdirSync(otherRoot, { recursive: true }); - writeFileSync(markerPath(), '', 'utf8'); // marker in cwd only - const { worker: a } = makeWorkerWithCwd(); - const cfgB = { - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: otherRoot, - noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, - }; - const b = createDowntimeWorker({ - poller: createDowntimePoller('http://proxy:8000'), - deps: makeDeps(), - config: () => ({ ...cfgB }), - }); - expect(a.override).toBe(false); - expect(b.override).toBe(null); - expect(b.enabled).toBe(true); - }); - - it('marker-restored disable yields zero dispatch across idle + cooldown windows (AC1/AC6)', async () => { - writeFileSync(markerPath(), '', 'utf8'); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, - }), - }); - const worker = createDowntimeWorker({ - poller: createDowntimePoller('http://proxy:8000'), - deps, - config: () => ({ - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd, - noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, - }), - }); - expect(worker.override).toBe(false); // restored from marker - vi.useFakeTimers(); - try { - // Advance well past the idle threshold and the no-candidate cooldown: - // a disabled worker must short-circuit on EVERY tick (zero polls, - // zero dispatch, zero pane spawns) regardless of elapsed time. - vi.setSystemTime(Date.now() + DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS + 60_000); - const r1 = await worker.tick(); - expect(r1).toEqual({ polled: false, dispatched: false, idle: false }); - const r2 = await worker.tick(); - expect(r2).toEqual({ polled: false, dispatched: false, idle: false }); - expect(deps.getNextItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); -}); - -// ── Pane spawn (AC4) ────────────────────────────────────────────────── - -describe('downtime pane spawn (send-to-pi.sh)', () => { - it('builds --pane-name / --no-focus / --cwd / --model args for a plan dispatch', () => { - const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { - model: 'plan', - cwd: '/repo', - }); - expect(args).toEqual([ - '--pane-name', - 'Downtime plan', - '--no-focus', - '--cwd', - '/repo', - '--model', - 'plan', - 'Run /skill:plan WL-ABC — Some task.', - ]); - }); - - it('uses the intake pane name and forwards the configured model', () => { - const args = buildDowntimePaneArgs('intake', 'Run /skill:intake WL-DEF', { - model: 'code', - cwd: '/repo', - }); - expect(args).toContain('Downtime intake'); - expect(args).toContain('code'); - expect(args).not.toContain('Downtime plan'); - }); - - it('spawns via the injectable spawn and unrefs the child', async () => { - const spawnFn = vi.fn(() => ({ unref: vi.fn(), once: vi.fn() })) as unknown as DowntimeSpawn; - - const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { cwd: '/repo' }, spawnFn); - - expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { - cwd: '/repo', - }); - const handle = (spawnFn as ReturnType).mock.results[0].value; - expect(handle.unref).toHaveBeenCalled(); - expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); - expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); - // No error/exit within the probe window → the pane is assumed opened. - expect(spawned).toEqual({ ok: true }); - }); - - it('resolves {ok:false} with the error trace on a handled spawn error (no unhandled-exception crash)', async () => { - const handle = { - unref: vi.fn(), - once: vi.fn((event: string, listener: (arg: unknown) => void) => { - if (event === 'error') listener(new Error('ENOENT: send-to-pi.sh')); - }), - }; - const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; - - const spawned = await spawnDowntimePane('/path/to/missing.sh', [], { cwd: '/repo' }, spawnFn); - - expect(spawned).toEqual({ ok: false, error: 'ENOENT: send-to-pi.sh' }); - expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); - expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); - }); - - it('resolves {ok:false} with the exit trace on a non-zero script exit within the probe window', async () => { - const handle = { - unref: vi.fn(), - once: vi.fn((event: string, listener: (arg: unknown) => void) => { - if (event === 'exit') listener(1); - }), - }; - const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; - - const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo' }, spawnFn); - - expect(spawned).toEqual({ ok: false, exitCode: 1 }); - }); - - it('treats a signal-killed child (exit code null) as a failure', async () => { - const handle = { - unref: vi.fn(), - once: vi.fn((event: string, listener: (arg: unknown) => void) => { - if (event === 'exit') listener(null); - }), - }; - const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; - - const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo' }, spawnFn); - - expect(spawned).toEqual({ ok: false, exitCode: null }); - }); - - it('resolves {ok:true} when the script exits 0 (pane opened — success path unchanged)', async () => { - const handle = { - unref: vi.fn(), - once: vi.fn((event: string, listener: (arg: unknown) => void) => { - if (event === 'exit') listener(0); - }), - }; - const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; - - const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo' }, spawnFn); - - expect(spawned).toEqual({ ok: true }); - }); - - it('buildDowntimeSpawnOptions uses detached/ignore options with the resolved cwd', () => { - const options = buildDowntimeSpawnOptions('/repo'); - expect(options).toEqual({ - detached: true, - stdio: 'ignore', - cwd: '/repo', - env: expect.objectContaining({ HERDR_RESOLVED_CWD: '/repo' }), - }); - }); - - it('buildDowntimeSpawnOptions bounds the dispatched audit fan-out (AUDIT_PHASE2_PARALLELISM=1)', () => { - const options = buildDowntimeSpawnOptions('/repo'); - // Parent audit + at most one sequential child deep-analysis call fits - // cheap mode's 2 local slots (WL-0MSORQ1RG005DGUS). - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - }); - - it('buildDowntimeSpawnOptions scrubs repository-override git vars (WL-0MV0TZEWZ003ZXEB)', () => { - const saved = new Map(); - for (const key of REPOSITORY_OVERRIDE_ENV_VARS) { - saved.set(key, process.env[key]); - process.env[key] = `/tmp/leaked/${key}`; - } - try { - const options = buildDowntimeSpawnOptions('/repo'); - for (const key of REPOSITORY_OVERRIDE_ENV_VARS) { - expect(options.env[key]).toBeUndefined(); - } - // The scrub must not discard unrelated forwarding. - expect(options.env.HERDR_RESOLVED_CWD).toBe('/repo'); - } finally { - for (const key of REPOSITORY_OVERRIDE_ENV_VARS) { - const previous = saved.get(key); - if (previous === undefined) delete process.env[key]; - else process.env[key] = previous; - } - } - }); - - it('scrubRepositoryOverrides removes only override vars and never mutates its input', () => { - const input = { - PATH: '/usr/bin', - GIT_DIR: '/leak/.git', - GIT_WORK_TREE: '/leak', - HERDR_ENV: '1', - }; - - const cleaned = scrubRepositoryOverrides(input); - - expect(cleaned).toEqual({ PATH: '/usr/bin', HERDR_ENV: '1' }); - // Input mapping is untouched. - expect(input.GIT_DIR).toBe('/leak/.git'); - expect(input.GIT_WORK_TREE).toBe('/leak'); - }); - - // ── Mode-aware PARALLELISM (WL-0MT50S9JW001DHME) ────────────────── - - it('buildDowntimeSpawnOptions defaults to PARALLELISM=1 when config is absent', () => { - // Backward compatibility: no config → '1' - const options = buildDowntimeSpawnOptions('/repo'); - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - - const optionsWithEmptyConfig = buildDowntimeSpawnOptions('/repo', { config: undefined }); - expect(optionsWithEmptyConfig.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - }); - - it('buildDowntimeSpawnOptions returns PARALLELISM=1 in fast mode even with free slots', () => { - const options = buildDowntimeSpawnOptions('/repo', { - config: { mode: 'fast', slotBudget: 3, freeSlots: 3, concurrentDispatchCap: 1 }, - }); - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - }); - - it('buildDowntimeSpawnOptions returns PARALLELISM=2 for cheap mode with a genuinely free second slot (single-flight)', () => { - // Cheap mode, 2 slots, both genuinely free, single dispatch at a time → - // 2 children = 2 streams total, fitting the 2-slot pool. - const options = buildDowntimeSpawnOptions('/repo', { - config: { mode: 'cheap', slotBudget: 2, freeSlots: 2, concurrentDispatchCap: 1 }, - }); - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('2'); - }); - - it('buildDowntimeSpawnOptions returns PARALLELISM=1 when the second slot is NOT genuinely free', () => { - // The operative signal is freeSlots, never the total slotBudget: a 2-slot - // pool with only 1 free slot cannot run 2 children. - const options = buildDowntimeSpawnOptions('/repo', { - config: { mode: 'cheap', slotBudget: 2, freeSlots: 1, concurrentDispatchCap: 1 }, - }); - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - }); - - it('buildDowntimeSpawnOptions returns PARALLELISM=1 when the dispatch budget is unbounded (cap 0)', () => { - // 0 = unbounded → a second audit could run → 2 audits × 2 children would - // exceed the 2-slot cheap pool. Only cap === 1 is single-flight-safe. - const options = buildDowntimeSpawnOptions('/repo', { - config: { mode: 'cheap', slotBudget: 2, freeSlots: 2, concurrentDispatchCap: 0 }, - }); - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - }); - - it('buildDowntimeSpawnOptions returns PARALLELISM=1 when concurrent dispatch budget would exceed slot capacity', () => { - // 2+ concurrent dispatches × 2 children each = 4 streams, exceeds 2-slot budget - const options = buildDowntimeSpawnOptions('/repo', { - config: { mode: 'cheap', slotBudget: 2, freeSlots: 2, concurrentDispatchCap: 2 }, - }); - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - }); - - it('buildDowntimeSpawnOptions returns PARALLELISM=1 when cheap mode slot budget is insufficient', () => { - // Only 1 slot in the pool — cannot run 2 children - const options = buildDowntimeSpawnOptions('/repo', { - config: { mode: 'cheap', slotBudget: 1, freeSlots: 1, concurrentDispatchCap: 1 }, - }); - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - }); - - it('buildDowntimeSpawnOptions returns PARALLELISM=1 for cheap mode with high dispatch cap', () => { - // 3+ concurrent dispatches × 2 children = 6 streams, far exceeds budget - const options = buildDowntimeSpawnOptions('/repo', { - config: { mode: 'cheap', slotBudget: 2, freeSlots: 2, concurrentDispatchCap: 3 }, - }); - expect(options.env.AUDIT_PHASE2_PARALLELISM).toBe('1'); - }); - - it('spawnDowntimePane forwards the spawn config to the spawn boundary (WL-0MT50S9JW001DHME)', async () => { - const handle = { - unref: vi.fn(), - once: vi.fn((event: string, listener: (arg: unknown) => void) => { - if (event === 'exit') listener(0); - }), - }; - const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; - const config = { - mode: 'cheap' as const, - slotBudget: 2, - freeSlots: 2, - concurrentDispatchCap: 1, - }; - - await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo', config }, spawnFn); - - expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', [], { cwd: '/repo', config }); - }); -}); - -// ── Worker orchestrator (AC1/AC5) ───────────────────────────────────── - -describe('downtime worker orchestrator (createDowntimeWorker)', () => { - function makeWorker(overrides: { - enabled?: boolean; - thresholdMs?: number; - cooldownMs?: number; - status?: unknown; - deps?: Partial; - mode?: 'cheap' | 'fast'; - concurrentDispatchCap?: number; - } = {}) { - const cfg = { - enabled: overrides.enabled ?? true, - thresholdMs: overrides.thresholdMs ?? DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: overrides.cooldownMs ?? 3_600_000, - // Mode-aware spawn config inputs (WL-0MT50S9JW001DHME). - ...(overrides.mode !== undefined ? { mode: overrides.mode } : {}), - ...(overrides.concurrentDispatchCap !== undefined - ? { concurrentDispatchCap: overrides.concurrentDispatchCap } - : {}), - }; - const fetcher = vi - .fn() - .mockResolvedValue(jsonResponseFixture(overrides.status ?? idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - ...overrides.deps, - }); - const worker = createDowntimeWorker({ - poller, - deps, - config: () => ({ ...cfg }), - }); - return { worker, deps, cfg, fetcher }; - } - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('does nothing (no poll, no dispatch) when disabled', async () => { - const { worker, deps, fetcher } = makeWorker({ enabled: false }); - const result = await worker.tick(); - expect(result).toEqual({ polled: false, dispatched: false, idle: false }); - expect(fetcher).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('treats a busy status as busy and never dispatches', async () => { - const { worker, deps } = makeWorker({ status: { ...idleAllSlotsFree, local_active_query: true } }); - const result = await worker.tick(); - expect(result.idle).toBe(false); - expect(worker.idleSince).toBeNull(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('treats remote-only traffic as idle and dispatches after the threshold (local_active_query=false)', async () => { - // Integration (AC2): a stub status with local_active_query=false - // (remote streams in flight, local slots free) must be treated as idle - // and dispatch after the idle threshold. - const { worker, deps, cfg } = makeWorker({ - status: { ...idleAllSlotsFree, local_active_query: false }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - expect(worker.idleSince).toBe(start); - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('does not dispatch until the idle duration reaches the threshold (AC1)', async () => { - const { worker, deps, cfg } = makeWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - expect(worker.idleSince).toBe(start); - - vi.setSystemTime(start + cfg.thresholdMs - 1); - const before = await worker.tick(); - expect(before.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - expect(worker.lastDispatchAt).toBe(start + cfg.thresholdMs); - }); - - it('tick forwards a mode-aware spawnConfig with the genuine free-slot snapshot (WL-0MT50S9JW001DHME)', async () => { - const { worker, deps, cfg } = makeWorker({ mode: 'cheap' }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - - vi.setSystemTime(start + cfg.thresholdMs); - await worker.tick(); - - // idleAllSlotsFree: 4 of 4 slots free, total 4 → forwarded verbatim so - // the spawn boundary never has to reach back into the worker. - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - spawnConfig: { mode: 'cheap', slotBudget: 4, freeSlots: 4, concurrentDispatchCap: 1 }, - }), - ); - }); - - it('tick omits spawnConfig when the proxy mode is unknown (conservative serial default)', async () => { - const { worker, deps, cfg } = makeWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - - vi.setSystemTime(start + cfg.thresholdMs); - await worker.tick(); - - const call = (deps.spawnAgentPane as ReturnType).mock.calls[0]; - expect(call[1]).not.toHaveProperty('spawnConfig'); - }); - - it('requires a fresh full idle period after a dispatch (AC5)', async () => { - const { worker, deps, cfg } = makeWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + cfg.thresholdMs); - await worker.tick(); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - - // Still idle at the next tick: the tracker was reset after dispatch, so - // no second dispatch until another full idle period has elapsed. - vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - const next = await worker.tick(); - expect(next.dispatched).toBe(false); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - expect(worker.idleSince).toBe(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - - vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS + cfg.thresholdMs); - const after = await worker.tick(); - expect(after.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); - }); - - it('a busy poll (proxy busy after dispatch) resets the idle run', async () => { - const { worker, deps, cfg, fetcher } = makeWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + cfg.thresholdMs); - await worker.tick(); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - - fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...idleAllSlotsFree, local_active_query: true })); - vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - const busy = await worker.tick(); - expect(busy.idle).toBe(false); - expect(worker.idleSince).toBeNull(); - - // Back to idle: a full new run is required before the next dispatch. - vi.setSystemTime(start + cfg.thresholdMs + 60_000); - const reIdle = await worker.tick(); - expect(reIdle.idle).toBe(true); - expect(reIdle.dispatched).toBe(false); - vi.setSystemTime(start + cfg.thresholdMs + 60_000 + cfg.thresholdMs); - const reDispatch = await worker.tick(); - expect(reDispatch.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); - }); - - it('re-reads settings each tick so changes apply without a restart', async () => { - const { worker, deps, cfg } = makeWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - - cfg.thresholdMs = 60_000; // operator changes the setting live - vi.setSystemTime(start + 60_000); - const result = await worker.tick(); - expect(result.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('does not start a second dispatch while one is in flight (worker single-flight)', async () => { - let release!: () => void; - const gate = new Promise<{ ok: true }>((resolve) => { - release = () => resolve({ ok: true }); - }); - const { worker, deps } = makeWorker({ - deps: { spawnAgentPane: vi.fn().mockImplementation(() => gate) }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - const first = worker.tick(); - const second = await worker.tick(); - expect(second.dispatched).toBe(false); - - release(); - const firstResult = await first; - expect(firstResult.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); -}); - -// ── No-candidate cooldown (WL-0MSI7DQL10016QYX) ────────────────────── - -describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { - /** Empty backlog in BOTH stages → the worker should pause. */ - function makeEmptyBacklogWorker(overrides: { - cooldownMs?: number; - deps?: Partial; - } = {}) { - const cfg = { - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: overrides.cooldownMs ?? 3_600_000, - }; - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - ...overrides.deps, - }); - const worker = createDowntimeWorker({ - poller, - deps, - config: () => ({ ...cfg }), - }); - return { worker, deps, cfg, fetcher }; - } - - // ── Coordination-dir worker fixture (F2 — cooldown lease maintenance) ── - - /** Tmp dirs created by makeCoordinationWorker — cleaned up in afterEach. */ - const coordinationTmpDirs: string[] = []; - - function makeCoordinationWorker(overrides: { - instanceId?: string; - leaseTtlSeconds?: number; - cooldownMs?: number; - deps?: Partial; - /** Pre-seed another instance's valid lease BEFORE the worker is created. */ - seedLease?: { instanceId: string; atMs: number }; - } = {}) { - const coordDir = mkdtempSync(join(tmpdir(), 'cd-wl-0MTF4A4V3-')); - coordinationTmpDirs.push(coordDir); - const instanceId = overrides.instanceId ?? 'inst-a'; - const leaseTtlSeconds = overrides.leaseTtlSeconds ?? 300; - if (overrides.seedLease) { - writeLease(coordDir, overrides.seedLease.instanceId, leaseTtlSeconds, overrides.seedLease.atMs); - } - const cfg = { - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: overrides.cooldownMs ?? 3_600_000, - }; - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - // fetchItem is REQUIRED for coordination dispatch (dispatchFromCoordination). - // Default: returns dispatchable info for any item id. - fetchItem: vi.fn().mockResolvedValue({ - ok: true, - info: { id: 'WL-FAKE', title: 'Fake', status: 'open', stage: 'intake_complete' }, - }), - ...overrides.deps, - }); - const worker = createDowntimeWorker({ - poller, - deps, - coordinationDir: coordDir, - instanceId, - leaseTtlSeconds, - config: () => ({ ...cfg }), - }); - return { worker, deps, cfg, fetcher, coordDir, instanceId, leaseTtlSeconds }; - } - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - for (const dir of coordinationTmpDirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('enters the cooldown only after a genuine no-candidate outcome', async () => { - const { worker, deps } = makeEmptyBacklogWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // idle run starts - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met → dispatch attempt - - const result = await worker.tick(); - - expect(result.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(worker.paused).toBe(true); - }); - - it('dispatches a due scheduled prompt instead of entering the no-candidate cooldown (AC6)', async () => { - // Scheduled tier first (WL-0MSS1Q5ER007QDKX): even with a genuinely - // empty backlog behind it, a DUE prompt dispatches — it never reaches - // the backlog tiers, so it never triggers the no-candidate cooldown. - const { worker, deps } = makeEmptyBacklogWorker({ - deps: { - getDueScheduledPrompt: vi.fn().mockResolvedValue({ - id: '/skill:refactor', - prompt: '/skill:refactor', - intervalDays: 3, - lastTriggeredAt: null, - }), - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // idle run starts - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met → dispatch attempt - - const result = await worker.tick(); - - expect(result.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - '/skill:refactor', - expect.objectContaining({ paneName: 'Downtime /skill:refactor' }), - ); - expect(worker.paused).toBe(false); // a dispatch — not the empty-backlog pause - expect(worker.errorStrikes).toBe(0); - }); - - it('performs no proxy polling, no idle tracking and no dispatch while paused', async () => { - const { worker, deps, fetcher } = makeEmptyBacklogWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); // enters cooldown - expect(worker.paused).toBe(true); - - const pollsBefore = fetcher.mock.calls.length; - const idleBefore = worker.idleSince; - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 60_000); // still within the pause - const result = await worker.tick(); - - expect(result).toEqual({ polled: false, dispatched: false, idle: false }); - expect(fetcher.mock.calls.length).toBe(pollsBefore); // no poll - expect(worker.idleSince).toBe(idleBefore); // no idle tracking - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(worker.paused).toBe(true); - }); - - it('resumes polling once the cooldown expires and requires a fresh full idle period', async () => { - let backlogEmpty = true; - const { worker, deps } = makeEmptyBacklogWorker({ - deps: { - getNextItem: vi.fn().mockImplementation(() => - Promise.resolve( - backlogEmpty - ? { ok: true, candidate: null } - : { ok: true, candidate: { id: 'WL-NEW', title: 'New item', stage: 'intake_complete' } }, - ), - ), - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); // empty backlog → cooldown - expect(worker.paused).toBe(true); - - // The project's backlog fills back up while the worker is paused. - backlogEmpty = false; - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // pause expires - const resumed = await worker.tick(); - expect(resumed.polled).toBe(true); - expect(resumed.dispatched).toBe(false); // fresh idle run — no stale credit - expect(worker.paused).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - // A full new idle period must elapse before the next dispatch. - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - const afterFreshIdle = await worker.tick(); - expect(afterFreshIdle.dispatched).toBe(true); // candidate now available - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('does not enter the cooldown on a transient wl error (fail closed to busy)', async () => { - const { worker, deps } = makeEmptyBacklogWorker({ - deps: { getNextItem: vi.fn().mockResolvedValue({ ok: false }) }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - const result = await worker.tick(); - - expect(result.dispatched).toBe(false); - expect(worker.paused).toBe(false); // a single error is NOT an empty backlog - expect(worker.errorStrikes).toBe(1); // ...but it IS the first strike - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - // WL-0MTJPYM53003ORCV: every strike is logged (per-strike observability). - expect(deps.recordError).toHaveBeenCalledTimes(1); - }); - - it('does not enter the cooldown when the dispatch guard reports in-flight', async () => { - // Hold the module-level dispatch single-flight guard with an outer - // in-flight dispatch; the worker's own dispatch call then returns - // `dispatch-in-flight`, which must NOT trigger the cooldown. - let release!: () => void; - const gate = new Promise<{ ok: true }>((resolve) => { - release = () => resolve({ ok: true }); - }); - const outerDeps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-X', title: 'Outer', stage: 'intake_complete' }, - }), - spawnAgentPane: vi.fn().mockImplementation(() => gate), - }); - const outer = dispatchDowntimeWork(outerDeps, { model: 'plan', cwd: '/repo' }); - - const { worker } = makeEmptyBacklogWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - const result = await worker.tick(); - expect(result.dispatched).toBe(false); - expect(worker.paused).toBe(false); // in-flight guard is not an empty backlog - - release(); - await outer; - }); - - it('does not enter the cooldown on a code-freeze skip (the freeze never pauses the worker)', async () => { - // Frozen + empty plan/intake backlog → reason 'code-freeze' (never - // 'no-candidate'): the worker must keep polling so implement/audit - // dispatch resumes immediately when the freeze lifts (WL-0MSQ0RPQP00636JY). - const { worker, deps } = makeEmptyBacklogWorker({ - deps: { - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - const result = await worker.tick(); - - expect(result.dispatched).toBe(false); - expect(worker.paused).toBe(false); // a freeze skip is NOT an empty backlog - expect(worker.errorStrikes).toBe(0); // ...and not a CLI-error strike either - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('re-reads the cooldown setting each tick so a change applies on the next cooldown entry', async () => { - const { worker, cfg } = makeEmptyBacklogWorker({ cooldownMs: 3_600_000 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); // enters cooldown with 3_600_000 (expires at 4_840_000) - expect(worker.paused).toBe(true); - - // Operator lowers the cooldown live; the in-progress pause keeps its - // original expiry, but the NEXT cooldown entry uses the new value. - cfg.noCandidateCooldownMs = 60_000; - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // original pause expires - await worker.tick(); // resumes, starts a fresh idle run - - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // fresh threshold met - await worker.tick(); // empty backlog again → 60s cooldown (new value) - expect(worker.paused).toBe(true); - - // With the NEW 60s value the pause expires long before the old 60-min - // default would have: at +61s the worker has resumed polling. - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 61_000); - const resumed = await worker.tick(); - expect(resumed.polled).toBe(true); - expect(worker.paused).toBe(false); - }); - - // ── Cooldown lease maintenance (parent F2 — holes A & B regression) ── - - /** Read the current lease file from the coordination directory. */ - function readLease(coordDir: string) { - const raw = readFileSync(join(coordDir, LEASE_FILE), 'utf-8'); - return JSON.parse(raw) as { leaderId: string; acquiredAt: string; ttlSeconds: number }; - } - - /** Write a lease file directly (simulates another instance winning election). */ - function writeLease(coordDir: string, instanceId: string, ttlSeconds: number, nowMs: number) { - const lease = { - leaderId: instanceId, - acquiredAt: new Date(nowMs).toISOString(), - ttlSeconds, - }; - writeFileSync(join(coordDir, LEASE_FILE), JSON.stringify(lease)); - // Also write the lock file so hasLease() returns true. - writeFileSync(join(coordDir, LEADER_LOCK_FILE), instanceId); - } - - it('keeps refreshing the lease during the no-candidate cooldown pause (AC1)', async () => { - // While a leader is paused in the no-candidate cooldown, the lease file - // mtime/content keeps advancing (refresh continues during the pause). - // Sibling fix (e842208580) already runs the leader block BEFORE the - // cooldown gate, so lease refreshes each tick during the pause. - const { worker, coordDir } = makeCoordinationWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - - // Tick 1: election → A wins lease, refreshLease - await worker.tick(); - expect(worker.isLeader).toBe(true); - expect(worker.paused).toBe(false); - - const leaseAfterElection = readLease(coordDir); - expect(leaseAfterElection.leaderId).toBe('inst-a'); - - // Tick 2: threshold met → dispatch attempt → no-candidate → cooldown - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); - expect(worker.paused).toBe(true); - expect(worker.isLeader).toBe(true); - - // Tick 3 during cooldown: lease should refresh (leader block runs first) - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 60_000); - await worker.tick(); - - const leaseDuringPause = readLease(coordDir); - expect(leaseDuringPause.leaderId).toBe('inst-a'); - // Content-based pin (fake timers control Date.now — robust vs fs mtime - // granularity): the lease acquiredAt must have ADVANCED during the pause. - expect(new Date(leaseDuringPause.acquiredAt).getTime()).toBeGreaterThan( - new Date(leaseAfterElection.acquiredAt).getTime(), - ); - // Belt: the pause itself is still in effect (no premature resume). - expect(worker.paused).toBe(true); - }); - - it('renews the lease on cooldown exit BEFORE dispatch (even if expired mid-pause) (AC2)', async () => { - // On cooldown exit, the lease is renewed BEFORE any dispatch decision - // (belt: even if tick loop stalled mid-pause, the lease expired). - const { worker, coordDir } = makeCoordinationWorker({ - leaseTtlSeconds: 300, // 5 min TTL - }); - const start = 1_000_000; - vi.setSystemTime(start); - - // Tick 1: election → A wins lease - await worker.tick(); - expect(worker.isLeader).toBe(true); - - // Tick 2: threshold met → dispatch attempt → no-candidate → cooldown - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); - expect(worker.paused).toBe(true); - expect(worker.isLeader).toBe(true); - - // Stalled tick loop: advance time PAST lease TTL (300s) but still within - // the cooldown (3.6M ms) — simulate a frozen tick loop mid-pause. - // Lease TTL = 300s; we advance 400s into the cooldown → lease expired. - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 400_000); - // Do NOT tick — simulate the loop being frozen. - - // Now advance to cooldown expiry. - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + 1_000); - - // The resume tick: leader block runs (cached leaderState=true) - // but refreshLease no-ops on our expired lease (Hole A — isLeader returns - // false because lease expired). - // Cooldown gate clears. Then poll/dispatch. - await worker.tick(); - - // AC2 assertion: lease must be VALID after the exit tick (renewed BEFORE - // any dispatch decision). On current code the lease is still expired - // (refreshLease no-oped), so this FAILS — proving Hole A. - const leaseAfterExit = readLease(coordDir); - const leaseValid = isLeaseValid(leaseAfterExit); - expect(leaseValid).toBe(true); - expect(leaseAfterExit.leaderId).toBe('inst-a'); - // Belt: the lease was RENEWED at exit — its acquiredAt must postdate the - // stalled period (current code keeps the stale acquiredAt → FAILS). - expect(new Date(leaseAfterExit.acquiredAt).getTime()).toBeGreaterThan( - start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 400_000, - ); - }); - - it('re-derives leadership every tick: expired lease routes to election, no zombie dispatch (AC3)', async () => { - // leaderState is re-derived every tick: an expired lease while - // leaderState === true routes into the election/takeover path instead of - // zombie dispatch. - const { worker, deps, coordDir } = makeCoordinationWorker({ - leaseTtlSeconds: 300, - }); - const start = 1_000_000; - vi.setSystemTime(start); - - // Tick 1: election → A wins lease - await worker.tick(); - expect(worker.isLeader).toBe(true); - - // Tick 2: threshold met → dispatch attempt → no-candidate → cooldown - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); - expect(worker.paused).toBe(true); - expect(worker.isLeader).toBe(true); - - // Mid-pause, B wins the election with a FRESH lease and a FRESH - // coordination entry (a live instance refreshes its entry on its own - // check-in cadence — write it at resume time so pruneStaleEntries does - // NOT delete it before A's dispatch tick; the entry must be present for - // the zombie dispatch to be observable). - const resumeTime = start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + 1_000; - vi.setSystemTime(resumeTime); - writeLease(coordDir, 'inst-b', 300, resumeTime); - - const entryB = { - instanceId: 'inst-b', - workItemId: 'WL-B1', - directory: '/repo-b', - assignedAt: new Date(resumeTime).toISOString(), - lastUpdated: new Date(resumeTime).toISOString(), - }; - writeCoordinationFile(coordDir, { version: 1, entries: [entryB] }); - - // Resume tick at cooldown expiry: leader block runs (cached - // leaderState=true) → refreshLease no-ops (B holds the lease → isLeader - // false) → leaderState STAYS true (Hole B). Cooldown gate clears → a - // FRESH idle run starts (tracker was reset on cooldown entry), so no - // dispatch happens on THIS tick — the zombie dispatch is decided on the - // NEXT tick after the idle threshold. - await worker.tick(); - - // Second tick: idle threshold met → dispatch decision. On current code - // (leaderState cached true) A reads B's entry and spawns A PANE — the - // zombie. After F5 (per-tick re-derivation) leaderState=false → the - // non-leader short-circuit returns BEFORE poll/dispatch. - vi.setSystemTime(resumeTime + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); - - // AC3 assertion: A must NOT dispatch B's item (B is the live leader). - // On current code (cached leaderState=true): spawnAgentPane IS called → - // FAIL (RED). After F5: leaderState re-derived false → GREEN. - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - // Also assert A is no longer leader (re-derived state). - expect(worker.isLeader).toBe(false); - }); - - it('stays silent when a valid foreign lease is held by another instance (AC4)', async () => { - // Non-leader with a valid lease held by another live instance stays - // silent (existing AC3 behavior preserved — regression guard). B's lease - // is pre-seeded BEFORE worker creation, so leaderState=false at startup - // (fixture passes coordination opts TOP-LEVEL as required). - const start = 1_000_000; - vi.setSystemTime(start); - const { worker, deps, fetcher } = makeCoordinationWorker({ - seedLease: { instanceId: 'inst-b', atMs: start }, - }); - - // Tick: leaderState at creation = isLeader() → false (lease owned by B). - // Leader block: leaderState false → hasLease=true, detectStaleLeader=false - // (B's lease is valid) → no election. Then: if (!leaderState) return. - const result = await worker.tick(); - - expect(result.polled).toBe(false); - expect(result.dispatched).toBe(false); - expect(result.idle).toBe(false); - expect(worker.isLeader).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(fetcher).not.toHaveBeenCalled(); // no poll - }); -}); - -// ── Three-strike rule on CLI errors ─────────────────────────────────── - -describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { - /** Every dispatch attempt ends in a wl CLI error ({ok:false} both tiers). */ - function makeErrorWorker(overrides: { deps?: Partial } = {}) { - const cfg = { - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }; - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ ok: false }), - ...overrides.deps, - }); - const worker = createDowntimeWorker({ - poller, - deps, - config: () => ({ ...cfg }), - }); - return { worker, deps, cfg, fetcher }; - } - - it('pauses and records the persistent error after 3 consecutive CLI errors', async () => { - const { worker, deps } = makeErrorWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // idle run starts - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met - - const s1 = await worker.tick(); // strike 1 - expect(s1.dispatched).toBe(false); - expect(worker.paused).toBe(false); - expect(worker.errorStrikes).toBe(1); - - const s2 = await worker.tick(); // strike 2 - expect(s2.dispatched).toBe(false); - expect(worker.paused).toBe(false); - expect(worker.errorStrikes).toBe(2); - - const s3 = await worker.tick(); // strike 3 → pause + log - expect(s3.dispatched).toBe(false); - expect(worker.paused).toBe(true); - expect(worker.errorStrikes).toBe(0); // counter reset once paused - // WL-0MTJPYM53003ORCV: every strike is logged — 3 per-strike entries - // (attempt 1/2/3), not just the pause marker at strike 3. - expect(deps.recordError).toHaveBeenCalledTimes(3); - const event3 = (deps.recordError as ReturnType).mock.calls[2][0]; - expect(event3.cwd).toBe('/repo'); - expect(event3.message).toContain('3 consecutive'); - expect(event3.attempt).toBe(3); - expect(Number.isNaN(Date.parse(event3.at))).toBe(false); - }); - - it('an audit-tier wl failure counts toward the three-strike rule (WL-0MSLWJ2KP0002SV0)', async () => { - // The wl-error comes from the AUDIT tier ({ok:false}) while the - // plan/intake tiers are GENUINELY empty ({ok:true, candidate:null}): the - // only possible strike source is the audit lookup, proving a broken - // audit query cannot silently pass as "no audit candidates" forever — - // three consecutive failures pause the worker and log recordError. - const { worker, deps } = makeErrorWorker({ - deps: { - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: false }), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - const s1 = await worker.tick(); - expect(s1.dispatched).toBe(false); - expect(worker.errorStrikes).toBe(1); - expect(worker.paused).toBe(false); - expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); - - const s2 = await worker.tick(); - expect(s2.dispatched).toBe(false); - expect(worker.errorStrikes).toBe(2); - // WL-0MTJPYM53003ORCV: strikes 1 and 2 are already logged per-strike. - expect(deps.recordError).toHaveBeenCalledTimes(2); - - const s3 = await worker.tick(); // strike 3 → pause + durable trace - expect(s3.dispatched).toBe(false); - expect(worker.paused).toBe(true); - expect(worker.errorStrikes).toBe(0); - expect(deps.recordError).toHaveBeenCalledTimes(3); - expect(deps.recordError).toHaveBeenCalledWith( - expect.objectContaining({ cwd: '/repo' }), - ); - }); - - it('resumes with a fresh strike counter once the pause expires', async () => { - const { worker, deps } = makeErrorWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); // strike 1 - await worker.tick(); // strike 2 - await worker.tick(); // strike 3 → paused - expect(worker.paused).toBe(true); - - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // pause expires - const resumed = await worker.tick(); - expect(resumed.polled).toBe(true); - expect(worker.paused).toBe(false); - expect(worker.errorStrikes).toBe(0); - - // A fresh error is strike 1 again — not a strike 4. - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); - expect(worker.errorStrikes).toBe(1); - expect(worker.paused).toBe(false); - }); - - it('resets the strike counter on a successful dispatch', async () => { - const { worker, deps } = makeErrorWorker({ - deps: { - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: false }) // tier 2 error - .mockResolvedValueOnce({ ok: false }) // tier 3 error → strike 1 - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' } }) - .mockResolvedValue({ ok: false }), // errors afterwards - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - await worker.tick(); // strike 1 - expect(worker.errorStrikes).toBe(1); - - await worker.tick(); // successful dispatch → strikes reset - expect(worker.errorStrikes).toBe(0); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - - // A fresh full idle period is required after the dispatch (AC5) before - // the next dispatch attempt. - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // fresh idle run starts - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 480_000); // fresh threshold met - await worker.tick(); // strike 1 (fresh — not a strike 2) - expect(worker.errorStrikes).toBe(1); - await worker.tick(); // strike 2 - expect(worker.paused).toBe(false); - expect(worker.errorStrikes).toBe(2); - - await worker.tick(); // strike 3 → paused - expect(worker.paused).toBe(true); - // WL-0MTJPYM53003ORCV: per-strike logging — 1 (pre-dispatch strike) + - // 3 (fresh strikes 1-3) = 4 recordError calls. - expect(deps.recordError).toHaveBeenCalledTimes(4); - }); - - it('does not strike on a no-candidate outcome (CLI answered — healthy)', async () => { - const { worker, deps } = makeErrorWorker({ - deps: { - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: false }) - .mockResolvedValueOnce({ ok: false }) // strike 1 - .mockResolvedValue({ ok: true, candidate: null }), // genuine empty backlog - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - await worker.tick(); // strike 1 → recordError logged - expect(worker.errorStrikes).toBe(1); - expect(deps.recordError).toHaveBeenCalledTimes(1); - - const result = await worker.tick(); // no-candidate → cooldown, strikes reset - expect(result.dispatched).toBe(false); - expect(worker.paused).toBe(true); // genuine empty backlog pauses the worker - expect(worker.errorStrikes).toBe(0); - // recordError was called for strike 1; no-candidate does NOT add a strike - expect(deps.recordError).toHaveBeenCalledTimes(1); - }); - - // ── Per-strike logging (WL-0MTJPYM53003ORCV) ──────────────────────── - - it('records a persistent error on EVERY strike (1, 2, and 3), not just strike 3', async () => { - const { worker, deps } = makeErrorWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - await worker.tick(); // strike 1 - expect(worker.errorStrikes).toBe(1); - expect(deps.recordError).toHaveBeenCalledTimes(1); - - await worker.tick(); // strike 2 - expect(worker.errorStrikes).toBe(2); - expect(deps.recordError).toHaveBeenCalledTimes(2); - - await worker.tick(); // strike 3 → pause + log - expect(worker.errorStrikes).toBe(0); - expect(deps.recordError).toHaveBeenCalledTimes(3); - }); - - it('per-strike log entries carry attempt/probeContext/timeoutMs on every strike (WL-0MTJPYM53003ORCV)', async () => { - const { worker, deps } = makeErrorWorker({ - deps: { - // Carry a stderr-ish message so stderrExcerpt is populated from the - // error string (fetcher.ts builds error from stderr||stdout||message). - getNextItem: vi.fn().mockResolvedValue({ ok: false, error: 'SQLITE_BUSY: database is locked' }), - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - await worker.tick(); // strike 1 - const event1 = (deps.recordError as ReturnType).mock.calls[0][0]; - expect(event1.cwd).toBe('/repo'); - expect(event1.attempt).toBe(1); - expect(event1.probeContext).toBe('dispatch-cli'); - expect(event1.timeoutMs).toBe(10_000); - expect(event1.stderrExcerpt).toBe('SQLITE_BUSY: database is locked'); - expect(Number.isNaN(Date.parse(event1.at))).toBe(false); - - await worker.tick(); // strike 2 - const event2 = (deps.recordError as ReturnType).mock.calls[1][0]; - expect(event2.attempt).toBe(2); - expect(event2.probeContext).toBe('dispatch-cli'); - expect(event2.stderrExcerpt).toBe('SQLITE_BUSY: database is locked'); - - await worker.tick(); // strike 3 - const event3 = (deps.recordError as ReturnType).mock.calls[2][0]; - expect(event3.attempt).toBe(3); - expect(event3.probeContext).toBe('dispatch-cli'); - }); - - it('stderr excerpt is truncated to 200 chars with [truncated] marker (WL-0MTJPYM53003ORCV)', async () => { - const longStderr = 'database is locked '.repeat(50); // > 200 chars - const { worker, deps } = makeErrorWorker({ - deps: { - getNextItem: vi.fn().mockResolvedValue({ ok: false, error: longStderr }), - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - await worker.tick(); // strike 1 - const event = (deps.recordError as ReturnType).mock.calls[0][0]; - expect(event.stderrExcerpt.length).toBeLessThanOrEqual(200 + '[truncated]'.length); - expect(event.stderrExcerpt.endsWith('[truncated]')).toBe(true); - expect(event.stderrExcerpt.length).toBe(200 + '[truncated]'.length); - }); - - it('probeContext is dispatch-cli for the legacy dispatch path (WL-0MTJPYM53003ORCV)', async () => { - // The makeErrorWorker fixture runs in legacy (non-coordination) mode, - // so all errors flow through the dispatch-cli path. - const { worker, deps } = makeErrorWorker(); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - - await worker.tick(); // strike 1 - const event1 = (deps.recordError as ReturnType).mock.calls[0][0]; - expect(event1.probeContext).toBe('dispatch-cli'); - - await worker.tick(); // strike 2 - const event2 = (deps.recordError as ReturnType).mock.calls[1][0]; - expect(event2.probeContext).toBe('dispatch-cli'); - }); -}); - -// ── Per-slot idle tracking (WL-0MSP28F5Z008DAUA, parent WL-0MSG7P9N8009PCKG) ── -// Test-first suite for same-slot idle tracking, deferred follow-up Q7 of -// WL-0MSF49FMW009M06K. The proxy feature LP-0MSG5TA7Y002GN39 serves per-slot -// details (`slots: [{slot_id, is_processing}]`) in GET /llama/local/status; -// when `downtimeRequiredFreeSlots` N < total slots, the worker must track idle -// duration PER SLOT IDENTITY so the SAME N slots are free for the full -// threshold — never any-N transient availability. -// -// The proxy (observability.py) serves `slot_id` as an INTEGER -// (`slot.get("id", i)`); `parseLlamaStatus` coerces numeric ids to strings -// and clamps negatives to 0 (WL-0MSVRMAWM007QNR5 — the Aug 15-16 -// zero-dispatch regression fix). Regression tests mirror the live payload -// at parse level and end-to-end through the worker tick. - -// ── parseLlamaStatus: optional per-slot fields (AC1/AC2) ─────────────── - -describe('parseLlamaStatus per-slot slots array', () => { - const base = { - llama_server_running: true, - model_switch_in_progress: false, - available_slots: 4, - total_slots: 4, - }; - - it('exposes the slots array when the payload serves it', () => { - const status = parseLlamaStatus({ - ...base, - slots: [ - { slot_id: 'slot-1', is_processing: false }, - { slot_id: 'slot-2', is_processing: true }, - ], - }); - expect(status).not.toBeNull(); - expect(status!.slots).toEqual([ - { slot_id: 'slot-1', is_processing: false }, - { slot_id: 'slot-2', is_processing: true }, - ]); - }); - - it('leaves slots undefined when the payload does not serve it (backward compatible)', () => { - const status = parseLlamaStatus(base); - expect(status).not.toBeNull(); - expect(status!.slots).toBeUndefined(); - }); - - it('parses an empty slots array as valid (zero free slots — never dispatches)', () => { - const status = parseLlamaStatus({ ...base, slots: [] }); - expect(status).not.toBeNull(); - expect(status!.slots).toEqual([]); - }); - - it('treats a non-array slots field as ambiguous → null (busy, fail-closed)', () => { - expect(parseLlamaStatus({ ...base, slots: 'not-an-array' })).toBeNull(); - expect(parseLlamaStatus({ ...base, slots: { slot_id: 'x', is_processing: false } })).toBeNull(); - }); - - it('treats an entry missing slot_id as malformed → null', () => { - expect(parseLlamaStatus({ ...base, slots: [{ is_processing: false }] })).toBeNull(); - }); - - it('treats an empty-string slot_id as malformed → null (zero-length guard preserved)', () => { - expect(parseLlamaStatus({ ...base, slots: [{ slot_id: '', is_processing: false }] })).toBeNull(); - }); - - it('coerces numeric slot_ids to strings (proxy contract: int, WL-0MSVRMAWM007QNR5)', () => { - const status = parseLlamaStatus({ - ...base, - slots: [ - { slot_id: 0, is_processing: false }, - { slot_id: 3, is_processing: true }, - ], - }); - expect(status).not.toBeNull(); - expect(status!.slots).toEqual([ - { slot_id: '0', is_processing: false }, - { slot_id: '3', is_processing: true }, - ]); - }); - - it('clamps negative numeric slot_ids to 0 (WL-0MSVRMAWM007QNR5)', () => { - const status = parseLlamaStatus({ - ...base, - slots: [{ slot_id: -2, is_processing: false }], - }); - expect(status).not.toBeNull(); - expect(status!.slots).toEqual([{ slot_id: '0', is_processing: false }]); - }); - - it('treats a non-finite or non-integer numeric slot_id as malformed → null (fail-closed)', () => { - expect(parseLlamaStatus({ ...base, slots: [{ slot_id: Number.NaN, is_processing: false }] })).toBeNull(); - expect(parseLlamaStatus({ ...base, slots: [{ slot_id: Number.POSITIVE_INFINITY, is_processing: false }] })).toBeNull(); - expect(parseLlamaStatus({ ...base, slots: [{ slot_id: 1.5, is_processing: false }] })).toBeNull(); - }); - - it('regression: parses the live proxy payload (integer slot_ids, WL-0MSVRMAWM007QNR5)', () => { - // observability.py serves `"slot_id": slot.get("id", i)` — an integer — - // per slot. Before the fix, parseLlamaStatus rejected the numeric - // slot_id and every 30s poll failed closed to busy — the Aug 15-16 - // zero-dispatch regression. Mirror the live payload exactly. - const livePayload = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - available_slots: 4, - total_slots: 4, - slots: [ - { slot_id: 0, is_processing: false }, - { slot_id: 1, is_processing: false }, - { slot_id: 2, is_processing: false }, - { slot_id: 3, is_processing: false }, - ], - }; - const status = parseLlamaStatus(livePayload); - expect(status).not.toBeNull(); - expect(status!.slots!.map((s) => s.slot_id)).toEqual(['0', '1', '2', '3']); - // The coerced payload is a valid idle status for the default N=0 AND - // for per-slot mode (0 < N < total) — dispatch can resume. - expect(evaluateIdle(status!, 0)).toBe(true); - expect(evaluateIdle(status!, 2)).toBe(true); - }); - - it('treats a non-boolean or missing is_processing as malformed → null', () => { - expect(parseLlamaStatus({ ...base, slots: [{ slot_id: 'slot-1', is_processing: 'yes' }] })).toBeNull(); - expect(parseLlamaStatus({ ...base, slots: [{ slot_id: 'slot-1' }] })).toBeNull(); - }); - - it('treats duplicate slot_ids as malformed → null', () => { - expect( - parseLlamaStatus({ - ...base, - slots: [ - { slot_id: 'slot-1', is_processing: false }, - { slot_id: 'slot-1', is_processing: true }, - ], - }), - ).toBeNull(); - }); -}); - -// ── Per-slot idle tracker (AC3/AC4) ─────────────────────────────────── - -describe('per-slot idle tracker (createPerSlotIdleTracker)', () => { - const a: LlamaSlot = { slot_id: 'a', is_processing: false }; - const b: LlamaSlot = { slot_id: 'b', is_processing: false }; - - it('starts a slot timer on its first free poll and continues it across free polls', () => { - const tracker = createPerSlotIdleTracker(); - const start = 1_000_000; - - tracker.record([a, b], start); - expect(tracker.idleSince.get('a')).toBe(start); - expect(tracker.idleSince.get('b')).toBe(start); - - tracker.record([a, b], start + 30_000); - expect(tracker.idleSince.get('a')).toBe(start); // run start stays fixed - expect(tracker.idleSince.get('b')).toBe(start); - }); - - it('a slot starting processing resets ONLY its own timer (other timers continue)', () => { - const tracker = createPerSlotIdleTracker(); - const start = 1_000_000; - - tracker.record([a, b], start); - tracker.record([{ slot_id: 'a', is_processing: true }, b], start + 10_000); - - expect(tracker.idleSince.get('a')).toBeNull(); // own timer reset - expect(tracker.idleSince.get('b')).toBe(start); // other timer continues - }); - - it('a slot absent from the payload is fail-closed to busy (its timer resets)', () => { - const tracker = createPerSlotIdleTracker(); - const start = 1_000_000; - - tracker.record([a, b], start); - tracker.record([a], start + 10_000); // b absent - - expect(tracker.idleSince.get('a')).toBe(start); - expect(tracker.idleSince.get('b')).toBeNull(); - }); - - it('an empty record resets every slot timer (global-busy reset path)', () => { - const tracker = createPerSlotIdleTracker(); - const start = 1_000_000; - - tracker.record([a, b], start); - tracker.record([], start + 10_000); - - expect(tracker.idleSince.get('a')).toBeNull(); - expect(tracker.idleSince.get('b')).toBeNull(); - }); - - it('thresholdMetCount counts only slots continuously free for the full threshold', () => { - const tracker = createPerSlotIdleTracker(); - const start = 1_000_000; - - tracker.record([a, b], start); - expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS - 1)).toBe(0); - expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(2); - expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + 500_000)).toBe(2); - }); - - it('a reset slot needs a fresh full run before it counts toward the threshold again', () => { - const tracker = createPerSlotIdleTracker(); - const start = 1_000_000; - - tracker.record([a, b], start); - // a goes processing at +10s (own timer reset) and free again at +20s. - tracker.record([{ slot_id: 'a', is_processing: true }, b], start + 10_000); - tracker.record([a, b], start + 20_000); - - // Only b has a full continuous run at start+threshold+5s. - expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 5_000)).toBe(1); - // a completes its fresh run at start+20s+threshold → both count. - expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + 20_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(2); - }); -}); - -// ── evaluateIdle: per-slot mode (AC5 + per-slot free count) ─────────── - -describe('evaluateIdle per-slot mode (0 < N < total with slots present)', () => { - const free = (id: string): LlamaSlot => ({ slot_id: id, is_processing: false }); - const busy = (id: string): LlamaSlot => ({ slot_id: id, is_processing: true }); - const perSlot = (slots: LlamaSlot[], available: number, total: number): LlamaStatus => ({ - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: available, - total_slots: total, - slots, - }); - // 4 slots, 2 free (slot-1, slot-2). - const twoFree = perSlot([free('s1'), free('s2'), busy('s3'), busy('s4')], 2, 4); - - it('uses the per-slot free count when per-slot data is present and 0 < N < total', () => { - expect(evaluateIdle(twoFree, 2)).toBe(true); // 2 of 4 free — enough - expect(evaluateIdle(twoFree, 3)).toBe(false); // needs 3 — not enough - }); - - it('per-slot mode still requires the relaxed global checks (server up + no model switch)', () => { - // Spare-capacity relaxation (parent WL-0MT32F90V008UAD2 AC2): in per-slot - // mode ONLY llama_server_running and model_switch_in_progress remain - // global gates — query/lease signals are superseded by per-slot - // is_processing (a busy slot's query/lease is the operator's own session). - expect(evaluateIdle(twoFree, 2)).toBe(true); // relaxed: query does NOT block - expect(evaluateIdle({ ...twoFree, local_active_query: true }, 2)).toBe(true); // relaxed - expect(evaluateIdle({ ...twoFree, local_lease_active: true }, 2)).toBe(true); // relaxed: lease does NOT block - expect(evaluateIdle({ ...twoFree, model_switch_in_progress: true }, 2)).toBe(false); // still global - expect(evaluateIdle({ ...twoFree, llama_server_running: false }, 2)).toBe(false); // still global - }); - - it('N=0 (default) still requires ALL slots free even when per-slot data is present', () => { - expect(evaluateIdle(twoFree, 0)).toBe(false); // 2 < 4 → busy - expect(evaluateIdle(perSlot([free('s1'), free('s2'), free('s3'), free('s4')], 4, 4), 0)).toBe(true); - }); - - it('N == total still requires ALL slots free even when per-slot data is present', () => { - expect(evaluateIdle(perSlot([free('s1'), free('s2'), free('s3'), free('s4')], 4, 4), 4)).toBe(true); - expect(evaluateIdle(perSlot([free('s1'), free('s2'), busy('s3'), free('s4')], 3, 4), 4)).toBe(false); - }); - - it('N > total never idles even with per-slot data', () => { - expect(evaluateIdle(perSlot([free('s1'), free('s2'), free('s3'), free('s4')], 4, 4), 5)).toBe(false); - }); -}); - -// ── Spare-capacity dispatch (parent WL-0MT32F90V008UAD2, F1) ────────── -// Tests for the spare-capacity dispatch feature: relaxed global gate in -// per-slot mode (local_active_query / local_lease_active do NOT block), -// the new DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 2, per-tier free-slot -// minimums at dispatch selection, and unchanged pre-fix proxy degradation. - -/** Per-slot status: 3 of 4 slots free (slot-1 busy), with global query/lease - * signals active — exactly the "operator session" pattern the spare-capacity - * dispatch must handle. */ -function perSlotThreeOfFourFreeActiveQuery(): LlamaStatus { - return { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 3, - total_slots: 4, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - { slot_id: 'slot-4', is_processing: false }, - ], - }; -} - -describe('spare-capacity dispatch: relaxed global idle gate (per-slot mode)', () => { - it('per-slot mode: a mid-query busy slot does NOT block dispatch into free slots (AC1)', () => { - // 3 of 4 slots free with local_active_query=true, local_lease_active=true. - // In the relaxed global gate (AC1), these per-slot query/lease signals - // are superseded by per-slot is_processing. Only llama_server_running and - // model_switch_in_progress stay global. - const status = perSlotThreeOfFourFreeActiveQuery(); - // The new relaxed check: active_query/local_active_query/local_lease - // are NOT blocking in per-slot mode — 3 free >= N=2. - expect(evaluateIdle(status, 2)).toBe(true); // spare capacity: 3 >= 2 free - }); - - it('per-slot mode: local_lease_active on a busy slot does NOT block dispatch (AC1)', () => { - const status: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 3, - total_slots: 4, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - { slot_id: 'slot-4', is_processing: false }, - ], - }; - // local_lease_active is per-slot; in per-slot mode it does NOT block. - expect(evaluateIdle(status, 2)).toBe(true); // 3 free >= 2 - }); - - it('per-slot mode: local_active_query on a busy slot does NOT block dispatch (AC2)', () => { - const status: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_active_query: true, - local_lease_active: false, - available_slots: 2, - total_slots: 3, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - }; - // 2 of 3 free with a mid-query busy slot — per-slot mode should fire. - expect(evaluateIdle(status, 2)).toBe(true); // AC2: mid-query busy slot - }); - - it('per-slot mode: model_switch_in_progress still blocks dispatch (global gate)', () => { - const status = perSlotThreeOfFourFreeActiveQuery(); - // model_switch_in_progress is ALWAYS global — even in per-slot mode. - expect(evaluateIdle({ ...status, model_switch_in_progress: true }, 2)).toBe(false); - }); - - it('per-slot mode: llama_server_running=false still blocks dispatch (global gate)', () => { - const status = perSlotThreeOfFourFreeActiveQuery(); - // server down is ALWAYS global — even in per-slot mode. - expect(evaluateIdle({ ...status, llama_server_running: false }, 2)).toBe(false); - }); -}); - -describe('spare-capacity dispatch: DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 2 (AC1)', () => { - it('DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS should be 2 for out-of-the-box spare-capacity', () => { - // The new default: 2 of 3 slots must be free for dispatch. - expect(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS).toBe(2); - }); -}); - -describe('spare-capacity dispatch: pre-fix proxy (no local_active_query) fails closed', () => { - it('pre-fix proxy (no local_active_query): absent signal → busy regardless of slot state', () => { - // A pre-fix proxy does not serve local_active_query. The worker - // fails closed (busy) on an absent signal — no silent degraded dispatch. - const noLocalQuery: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 2, - total_slots: 4, - }; - // Absent local_active_query → busy (fail-closed), not slot count. - expect(evaluateIdle(noLocalQuery, 2)).toBe(false); // absent → busy - expect(evaluateIdle(noLocalQuery, 4)).toBe(false); // absent → busy - // Even with all slots free, absent local_active_query → busy. - const allFree: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 4, - total_slots: 4, - }; - expect(evaluateIdle(allFree, 2)).toBe(false); // absent → busy (fail-closed) - }); - - it('pre-fix proxy with N=0 (default): absent local_active_query → busy', () => { - const status: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 2, - total_slots: 4, - }; - // Absent local_active_query → busy (fail-closed), regardless of slot count. - expect(evaluateIdle(status, 0)).toBe(false); // absent → busy - }); -}); - -describe('spare-capacity dispatch: ambiguous payloads are busy (AC5)', () => { - it('missing slot_id fields are treated as busy (fail-closed)', () => { - const malformed: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 3, - total_slots: 4, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: false }, - ], - }; - // The slots array has only 2 entries for 4 total — this is ambiguous. - // With per-slot mode active (0 < N < total), the free count is 1 < 2. - expect(evaluateIdle(malformed, 2)).toBe(false); - }); - - it('total_slots 0 is always busy', () => { - const status: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 0, - total_slots: 0, - }; - expect(evaluateIdle(status, 2)).toBe(false); - }); - - it('N > total can never be idle', () => { - const status: LlamaStatus = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 3, - total_slots: 3, - slots: [ - { slot_id: 'slot-1', is_processing: false }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - }; - // 3 free < 4 required — never idle. - expect(evaluateIdle(status, 4)).toBe(false); - }); -}); - -// ── Worker routing: per-slot mode vs fallback (WL-0MSG7P9N8009PCKG AC) ─ - -describe('downtime worker per-slot routing (createDowntimeWorker)', () => { - function makePerSlotWorker(overrides: { - requiredFreeSlots?: number; - thresholdMs?: number; - status?: unknown; - deps?: Partial; - } = {}) { - const cfg = { - enabled: true, - thresholdMs: overrides.thresholdMs ?? DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: overrides.requiredFreeSlots ?? 2, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }; - const fetcher = vi - .fn() - .mockResolvedValue(jsonResponseFixture(overrides.status ?? perSlotTwoFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - ...overrides.deps, - }); - const worker = createDowntimeWorker({ - poller, - deps, - config: () => ({ ...cfg }), - }); - return { worker, deps, cfg, fetcher }; - } - - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('routes to per-slot mode: dispatches with only N of total slots free (same slots, full threshold)', async () => { - // perSlotTwoFree: 2 of 4 slots free. Legacy all-slots-free mode would - // NEVER dispatch (available 2 < 4); per-slot mode dispatches after the - // threshold because the SAME 2 slots have been free throughout. - const { worker, deps, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - // idleSince is null because slots 3,4 are processing — the title bar - // must show "busy", not "idle". Dispatch is unaffected (perSlotTracker - // still counts the 2 free slots for spare-capacity dispatch). - expect(worker.idleSince).toBeNull(); - - vi.setSystemTime(start + cfg.thresholdMs - 1); - const before = await worker.tick(); - expect(before.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('AC1 spare-capacity: 3 slots/1 busy with the operator session active (query+lease) still dispatches into the 2 free slots', async () => { - // parent ACL1: 2 of 3 slots continuously free for the full threshold → - // dispatch fires even though 1 slot is occupied by an active - // (query + lease) session — exactly the operator working in the TUI - // while the downtime worker uses the spare capacity. - const operatorSessionPayload = { - llama_server_running: true, - local_active_query: true, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 2, - total_slots: 3, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - }; - const { worker, deps, cfg, fetcher } = makePerSlotWorker({ - requiredFreeSlots: 2, - status: operatorSessionPayload, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // 2 free → per-slot idle run starts - // idleSince is null because slot-1 is processing — title bar shows - // "busy" even though dispatch fires into free slots. - expect(worker.idleSince).toBeNull(); - - vi.setSystemTime(start + cfg.thresholdMs - 1); - const before = await worker.tick(); - expect(before.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); // dispatch into the 2 free slots - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - expect(fetcher).toHaveBeenCalled(); - }); - - it('AC2 spare-capacity: a mid-query (processing) busy slot does NOT reset the free slots\' timers', async () => { - // parent AC2: with N free of total in per-slot mode, idle is evaluated - // from the free slots only. A processing slot 1 does not reset slot-2 / - // slot-3 timers — dispatch still fires once the free slots have been - // continuously free for the full threshold (the busy slot is the - // operator's own mid-query session). - const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // perSlotTwoFree (slot-1, slot-2 free) → timers start - - // slot-1 goes mid-query while slot-2 stays free; the global query signal - // also fires. Spare-capacity relaxation: only slot-1's timer resets. - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotTwoFree)); - vi.setSystemTime(start + cfg.thresholdMs - 10_000); - const mid = await worker.tick(); - expect(mid.idle).toBe(true); // free count still ≥ N=2 (slot-1 busy) - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); // slot-2 ran the full threshold - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('AC3 tier minimums flow from the poll: 1 free slot skips the audit tier, plan dispatches (worker end-to-end)', async () => { - // N=1 with a single free slot: the idle gate is satisfied (1 ≥ N), but - // the AUDIT tier needs ≥2 free slots (parent WL-0MT32F90V008UAD2 AC3) — - // the worker must pass the polled free count into the tier selection so - // the audit candidate is skipped and the plan pane dispatches instead. - const oneFreePayload = { - llama_server_running: true, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 1, - total_slots: 3, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: true }, - { slot_id: 'slot-3', is_processing: false }, - ], - }; - const { worker, deps, cfg } = makePerSlotWorker({ - requiredFreeSlots: 1, - status: oneFreePayload, - deps: { - getNextAuditCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, - }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete', status: 'open' }, - }), - }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // 1 free slot ≥ N=1 → per-slot idle run starts - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - - // The worker saw freeSlots=1: the audit candidate was never consulted - // and the plan pane dispatched. - expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); - expect(at.dispatched).toBe(true); - expect((deps.spawnAgentPane as ReturnType).mock.calls[0][0]).toContain('/skill:plan WL-PLAN'); - }); - - it('regression: a live proxy payload (integer slot_ids) idles and dispatches (WL-0MSVRMAWM007QNR5)', async () => { - // observability.py serves `"slot_id": slot.get("id", i)` — an integer — - // for each slot. Before the fix, parseLlamaStatus rejected the numeric - // slot_id, so every poll failed closed to busy and the worker never - // dispatched (the Aug 15-16 zero-dispatch regression). This mirrors the - // live payload end-to-end: poll → parse → idle run → threshold → dispatch. - const livePayload = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - available_slots: 4, - total_slots: 4, - slots: [ - { slot_id: 0, is_processing: false }, - { slot_id: 1, is_processing: false }, - { slot_id: 2, is_processing: false }, - { slot_id: 3, is_processing: false }, - ], - }; - const { worker, deps, cfg } = makePerSlotWorker({ status: livePayload }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - expect(worker.idleSince ?? null).not.toBeNull(); // idle run starts - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('a slot starting processing resets only its own timer: other slots keep their run and can dispatch', async () => { - // N=1. slot-1 goes processing at +10s while slot-2 stays free. The poll - // stays per-slot idle (free count 3 ≥ 1), slot-2's timer continues, and - // dispatch fires at the original threshold — not delayed by slot-1. - const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 1 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // all 4 free → timers start - - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); - vi.setSystemTime(start + 10_000); - const mid = await worker.tick(); - expect(mid.idle).toBe(true); // free count 3 ≥ N=1 → still idle - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('a processing slot that must count toward N delays dispatch by its own fresh run', async () => { - // Per-slot mode with 4 slots, N=2: slot-1 is processing from +10s to - // +20s while slot-2 stays free. slot-1's OWN timer resets, so dispatch - // cannot fire at the original threshold (only slot-2 has a full - // continuous run); it fires only after slot-1's fresh run completes. - const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // slot-1, slot-2 free → their timers start - - const fourSlots = (s1: boolean): LlamaStatus => ({ - ...perSlotAllFree, - available_slots: (s1 ? 0 : 1) + 1, // slot-2 free + slot-1 free - total_slots: 4, - slots: [ - { slot_id: 'slot-1', is_processing: s1 }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: true }, - { slot_id: 'slot-4', is_processing: true }, - ], - }); - fetcher.mockResolvedValueOnce(jsonResponseFixture(fourSlots(true))); - vi.setSystemTime(start + 10_000); - await worker.tick(); // slot-1 processing → its own timer resets - - fetcher.mockResolvedValueOnce(jsonResponseFixture(fourSlots(false))); - vi.setSystemTime(start + 20_000); - await worker.tick(); // slot-1 free again → restarts its run - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(false); // only slot-2 has a full continuous run - - vi.setSystemTime(start + 20_000 + cfg.thresholdMs); - const after = await worker.tick(); - expect(after.dispatched).toBe(true); // slot-1 completed its fresh run - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('a global busy poll resets ALL per-slot timers (fresh full idle period required)', async () => { - const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // all free → timers start - - // model_switch_in_progress is a GLOBAL gate in per-slot mode (spare- - // capacity relaxation, parent WL-0MT32F90V008UAD2 AC2) — it resets - // every slot timer. A per-slot query/lease signal is NOT global - // anymore and is covered by the dedicated spare-capacity tests. - fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...perSlotAllFree, model_switch_in_progress: true })); - vi.setSystemTime(start + 10_000); - await worker.tick(); // global busy → every slot timer reset - - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); - vi.setSystemTime(start + 20_000); - await worker.tick(); // free again → timers restart at +20s - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(false); // no slot has a full run since +20s - - vi.setSystemTime(start + 20_000 + cfg.thresholdMs); - const after = await worker.tick(); - expect(after.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('an ambiguous poll (null status) resets ALL per-slot timers (fail-closed)', async () => { - const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // all free → timers start - - fetcher.mockResolvedValueOnce(jsonResponseFixture(null)); - vi.setSystemTime(start + 10_000); - await worker.tick(); // ambiguous → busy → every slot timer reset - - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); - vi.setSystemTime(start + 20_000); - await worker.tick(); // free again → timers restart at +20s - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(false); - - vi.setSystemTime(start + 20_000 + cfg.thresholdMs); - const after = await worker.tick(); - expect(after.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('without usable per-slot data, fails closed (never dispatches on any-N availability)', async () => { - // No usable per-slot detail + 0 < N < total: the worker fails closed - // (WL-0MUXVPXAZ005RESW) — never dispatching on the untrusted count — - // and records the distinct `slots-unavailable` reason. - const { worker, deps, cfg, fetcher } = makePerSlotWorker({ - requiredFreeSlots: 2, - status: { ...idleAllSlotsFree, slots: undefined, available_slots: 4 }, - }); - const start = 1_000_000; - vi.setSystemTime(start); - const first = await worker.tick(); - expect(first.idle).toBe(false); - expect(worker.blockReason).toBe('no-slots'); - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(fetcher).toHaveBeenCalled(); // still polling — just never dispatching - }); - - it('with per-slot data but N=0, falls back to all-slots-free (per-slot mode only when 0 < N < total)', async () => { - // Slots are present but N=0 (all slots): per-slot mode is NOT active — - // all-slots-free applies, so 2 of 4 free is busy and never dispatches. - const { worker, deps, cfg } = makePerSlotWorker({ - requiredFreeSlots: 0, - status: perSlotTwoFree, - }); - const start = 1_000_000; - vi.setSystemTime(start); - const first = await worker.tick(); - expect(first.idle).toBe(false); - - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - // ── Title bar idle display (parent WL-0MT65T14L002HTWB) ──────────── - // The global idle tracker (`worker.idleSince`) drives the title bar - // `[⏳ downtime idle Xs]` indicator. In per-slot mode the relaxed - // per-slot global gate deliberately ignores query/busy signals for - // DISPATCH; the DISPLAY must still reflect actual per-slot activity. - // idleSince (the display signal) is null whenever ANY slot is - // processing — the title bar shows `[downtime busy]` — while dispatch - // into free slots is unaffected. - - it('display: title bar shows busy when ANY slot is processing (incl. the operator\'s)', async () => { - // perSlotTwoFree: slots 3,4 processing. The title-bar display must show - // "busy" (idleSince null) even though the per-slot dispatch gate (2 - // free slots) is satisfied. - const { worker, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - expect(worker.idleSince).toBeNull(); // → `[downtime busy]` in the title bar - - // Even after the dispatch threshold elapses, the display stays busy - // while a slot is processing (dispatch itself fires per usual). - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); // dispatch still fires (spare capacity) - expect(worker.idleSince).toBeNull(); // display never flips to idle mid-query - }); - - it('display: title bar shows idle ONLY when every slot is free', async () => { - // perSlotAllFree: all 4 slots free — no active processing anywhere, so - // the display can honestly show the idle counter. - const { worker, fetcher, cfg } = makePerSlotWorker({ - requiredFreeSlots: 2, - status: perSlotAllFree, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - expect(worker.idleSince).toBe(start); // → `[⏳ downtime idle 0:00]` - - // Keep the status for the second tick (default is perSlotTwoFree which - // has processing slots). Check BELOW the dispatch threshold so the tick - // does not dispatch (which would reset idleSince by design). - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); - vi.setSystemTime(start + cfg.thresholdMs - 10_000); - await worker.tick(); - expect(worker.idleSince).toBe(start); // run start stays fixed while free - }); - - it('display: a slot starting processing resets the idle counter; freeing it restarts a fresh run', async () => { - // Demonstrates that the display (idleSince) tracks actual per-slot - // processing state — completely independent of dispatch logic. - const { worker, fetcher, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // perSlotTwoFree: slots 3,4 processing → display busy - expect(worker.idleSince).toBeNull(); - - // All slots free → the idle counter starts. - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); - vi.setSystemTime(start + 10_000); - await worker.tick(); - expect(worker.idleSince).toBe(start + 10_000); // → `[⏳ downtime idle 0:00]` - - // slot-1 goes processing → display flips back to busy. - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); - vi.setSystemTime(start + 20_000); - await worker.tick(); - expect(worker.idleSince).toBeNull(); // → `[downtime busy]` - - // Free slots (3) ≥ N=2 and their timers (since +10s) exceed threshold. - // Dispatch fires into the 3 free slots while display shows busy. - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); - vi.setSystemTime(start + 20_000 + cfg.thresholdMs); - const at = await worker.tick(); - expect(worker.idleSince).toBeNull(); // display stays busy (slot still processing) - expect(at.dispatched).toBe(true); // dispatch fires (3 ≥ 2 free) - }); - - it('display: transition back to all-free after processing restarts the idle counter', async () => { - // After a busy period, the display should start counting again once - // all slots are free. - const { worker, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // perSlotTwoFree: slots 3,4 processing → busy - expect(worker.idleSince).toBeNull(); - - // All slots free → idle counter starts at current time. - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); - vi.setSystemTime(start + 10_000); - await worker.tick(); - expect(worker.idleSince).toBe(start + 10_000); - - // slot-1 goes processing → display resets to busy. - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); - vi.setSystemTime(start + 20_000); - await worker.tick(); - expect(worker.idleSince).toBeNull(); - - // All slots free again → display should now count from +20s. - fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); - vi.setSystemTime(start + 30_000); - await worker.tick(); - expect(worker.idleSince).toBe(start + 30_000); // fresh count from +30s - }); - - it('display: global busy (server down / model switch) also resets idleSince to busy', async () => { - const { worker, fetcher } = makePerSlotWorker({ - requiredFreeSlots: 2, - status: perSlotAllFree, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - expect(worker.idleSince).toBe(start); - - // model_switch_in_progress is a GLOBAL gate — display shows busy. - fetcher.mockResolvedValueOnce( - jsonResponseFixture({ ...perSlotAllFree, model_switch_in_progress: true }), - ); - vi.setSystemTime(start + 10_000); - await worker.tick(); - expect(worker.idleSince).toBeNull(); // → `[downtime busy]` - }); -}); - -// ── Rotation-aware selection (WL-0MSSRED76008LGB6 / WL-0MSW6CKBY007RB7O) ── - -describe('selectWithRotation (round-robin tie-break)', () => { - const mk = (id: string, priority: string, sortIndex: number) => ({ - id, priority, sortIndex, - }); - - it('empty list → null', () => { - expect(selectWithRotation([])).toBeNull(); - }); - - it('single candidate → that candidate (no rotation)', () => { - const c = mk('a', 'high', 1); - expect(selectWithRotation([c])?.id).toBe('a'); - }); - - it('no registry → first by sortIndex (fail-open pre-rotation behaviour)', () => { - const items = [mk('a', 'high', 2), mk('b', 'high', 1)]; - expect(selectWithRotation(items)?.id).toBe('b'); // lowest sortIndex - }); - - it('candidates without priority → first by sortIndex (fail-open)', () => { - const items = [{ id: 'x', sortIndex: 5 }, { id: 'y', sortIndex: 3 }]; - expect(selectWithRotation(items)?.id).toBe('y'); - }); - - it('rotates through tied top-priority group, skipping lower priority', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-rotation', rng: () => 0.5 }); - const items = [ - mk('low1', 'low', 10), - mk('high1', 'high', 1), - mk('high2', 'high', 2), - mk('medium1', 'medium', 5), - ]; - // Cursor starts at 0 → selects high1; advances to 1 → high2; then back to high1 - expect(selectWithRotation(items, registry)?.id).toBe('high1'); - expect(selectWithRotation(items, registry)?.id).toBe('high2'); - expect(selectWithRotation(items, registry)?.id).toBe('high1'); - }); - - it('single-member top group → no rotation (higher priority still wins)', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-single', rng: () => 0.5 }); - const items = [mk('crit1', 'critical', 1), mk('high1', 'high', 2), mk('high2', 'high', 3)]; - // critical group has 1 member → always crit1, regardless of cursor - expect(selectWithRotation(items, registry)?.id).toBe('crit1'); - expect(selectWithRotation(items, registry)?.id).toBe('crit1'); - }); - - it('priority-first preserved: lower-priority candidate never selected while higher tier has members', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-priority', rng: () => 0.5 }); - const items = [ - mk('med1', 'medium', 10), - mk('med2', 'medium', 11), - mk('low1', 'low', 20), - ]; - // Only medium group has members → rotation within medium only - expect(selectWithRotation(items, registry)?.id).toBe('med1'); - expect(selectWithRotation(items, registry)?.id).toBe('med2'); - expect(selectWithRotation(items, registry)?.id).toBe('med1'); - }); -}); - -describe('rotation-aware selection wired into tiers', () => { - it('selectImplementCandidate rotates within tied priority when registry given', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-implement', rng: () => 0.5 }); - const candidates: ImplementCandidate[] = [ - { id: 'A', title: 'A', status: 'open', priority: 'high', sortIndex: 1, risk: 'low', effort: 'small' }, - { id: 'B', title: 'B', status: 'open', priority: 'high', sortIndex: 2, risk: 'low', effort: 'small' }, - ]; - expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('A'); - expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('B'); - expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('A'); - }); - - it('selectNextCandidate rotates within tied priority when registry given', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-next', rng: () => 0.5 }); - const candidates: DowntimeCandidate[] = [ - { id: 'X', title: 'X', stage: 'plan', status: 'open', priority: 'high', sortIndex: 1 }, - { id: 'Y', title: 'Y', stage: 'plan', status: 'open', priority: 'high', sortIndex: 2 }, - ]; - expect(selectNextCandidate(candidates, undefined, registry)?.id).toBe('X'); - expect(selectNextCandidate(candidates, undefined, registry)?.id).toBe('Y'); - }); - - it('selectAuditCandidate rotates within tied priority when registry given', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-audit', rng: () => 0.5 }); - const now = Date.now(); - const candidates: AuditCandidate[] = [ - { id: 'P', title: 'P', priority: 'high', sortIndex: 1, updatedAt: new Date(now).toISOString() }, - { id: 'Q', title: 'Q', priority: 'high', sortIndex: 2, updatedAt: new Date(now).toISOString() }, - ]; - expect(selectAuditCandidate(candidates, now, undefined, registry)?.id).toBe('P'); - expect(selectAuditCandidate(candidates, now, undefined, registry)?.id).toBe('Q'); - }); -}); - -// ── Probe jitter (WL-0MSSRED76008LGB6 / WL-0MSW6DEI9005XH8Q) ────────── - -describe('worker probe jitter (jitterPollIntervalMs)', () => { - it('returns jittered interval within ±50% when a registry is provided', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-deps', rng: () => 0.75 }); - const { worker } = (() => { - const cfg = { - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }; - const w = createDowntimeWorker({ - poller: createDowntimePoller('http://proxy:8000'), - deps: makeDeps(), - registry, - config: () => ({ ...cfg }), - }); - return { worker: w }; - })(); - // rng 0.75 → factor 1.25 → 10_000 * 1.25 = 12_500 - expect(worker.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(12_500); - }); - - it('returns the static interval when no registry is provided (fail-open)', () => { - const { worker } = (() => { - const cfg = { - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }; - const w = createDowntimeWorker({ - poller: createDowntimePoller('http://proxy:8000'), - deps: makeDeps(), - config: () => ({ ...cfg }), - }); - return { worker: w }; - })(); - expect(worker.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(10_000); - }); - - it('two workers with different RNG produce different jitter values', () => { - const r1 = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-a', rng: () => 0.1 }); - const r2 = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-b', rng: () => 0.9 }); - const mk = (registry: typeof r1) => createDowntimeWorker({ - poller: createDowntimePoller('http://proxy:8000'), - deps: makeDeps(), - registry, - config: () => ({ enabled: true, thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, requiredFreeSlots: 0, model: 'plan', cwd: '/repo', noCandidateCooldownMs: 3_600_000 }), - }); - const w1 = mk(r1); - const w2 = mk(r2); - // rng 0.1 → 0.6× = 6_000; rng 0.9 → 1.4× = 14_000 - expect(w1.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(6_000); - expect(w2.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(14_000); - expect(w1.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).not.toBe(w2.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)); - }); -}); - -// ── Critical-first tier (parent WL-0MT3FM8VA005XBHE) ──────────────────── -// Test-first matrix for the critical-first pre-tier: an open critical item -// at ANY stage (idea / intake_complete / plan_complete) is dispatched with -// its stage-appropriate skill ahead of every non-critical candidate. The -// F2/F3/F4 contract being pinned here (parseCriticalCandidatesOutput, -// selectCriticalCandidate, criticalSkillKind, resolveDependencyFrontier, -// the getNextCriticalCandidate dep, and the tier order) is RED until the -// implementation slices land (WL-0MT3I9UVU004722X, WL-0MT3I9YNZ007IC5V, -// WL-0MT3IA1UB005TLVJ) — the tests reference exports that do not exist yet -// and fail as a unit (the rest of this file stays green). - -// ── Candidate shapes reused across the critical matrix ────────────── - -/** Open critical worklog item at a dispatchable stage. */ -function criticalIdea( - id = 'WL-CRIT-I', - overrides: Record = {}, -): Record { - return { - id, - title: `Critical idea ${id}`, - status: 'open', - stage: 'idea', - priority: 'critical', - sortIndex: 100, - risk: 'low', - effort: 'small', - ...overrides, - }; -} - -/** Open critical worklog item at intake_complete (plan-ready). */ -function criticalReady( - id = 'WL-CRIT-R', - overrides: Record = {}, -): Record { - return { - id, - title: `Critical ready ${id}`, - status: 'open', - stage: 'intake_complete', - priority: 'critical', - sortIndex: 100, - risk: 'low', - effort: 'small', - ...overrides, - }; -} - -/** Open critical worklog item at plan_complete (implement-ready). */ -function criticalPlanned( - id = 'WL-CRIT-P', - overrides: Record = {}, -): Record { - return { - id, - title: `Critical planned ${id}`, - status: 'open', - stage: 'plan_complete', - priority: 'critical', - sortIndex: 100, - risk: 'medium', - effort: 'medium', - ...overrides, - }; -} - -// ── Critical-first: selection helpers (F2) ─────────────────────────── - -describe('critical selection (selectCriticalCandidate)', () => { - it('selects a critical idea candidate (stage-appropriate intake)', () => { - const selected = selectCriticalCandidate([criticalIdea()] as never); - expect(selected?.id).toBe('WL-CRIT-I'); - expect(selected?.stage).toBe('idea'); - expect(criticalSkillKind(selected?.stage ?? '')).toBe('intake'); - }); - - it('selects a critical intake_complete candidate (stage-appropriate plan)', () => { - const selected = selectCriticalCandidate([criticalReady()] as never); - expect(selected?.id).toBe('WL-CRIT-R'); - expect(selected?.stage).toBe('intake_complete'); - expect(criticalSkillKind(selected?.stage ?? '')).toBe('plan'); - }); - - it('selects a critical plan_complete candidate within caps (stage-appropriate implement)', () => { - const selected = selectCriticalCandidate([criticalPlanned()] as never); - expect(selected?.id).toBe('WL-CRIT-P'); - expect(selected?.stage).toBe('plan_complete'); - expect(criticalSkillKind(selected?.stage ?? '')).toBe('implement'); - }); - - it('stage→skill mapping is exact: idea→intake, intake_complete→plan, plan_complete→implement', () => { - expect(criticalSkillKind('idea')).toBe('intake'); - expect(criticalSkillKind('intake_complete')).toBe('plan'); - expect(criticalSkillKind('plan_complete')).toBe('implement'); - }); - - it('non-dispatchable stages map to null (in_progress / in_review are not targets)', () => { - expect(criticalSkillKind('in_progress')).toBeNull(); - expect(criticalSkillKind('in_review')).toBeNull(); - expect(criticalSkillKind('completed')).toBeNull(); - expect(criticalSkillKind('')).toBeNull(); - }); - - it('deterministic selection: lowest sortIndex wins across stages (sortIndex priority order)', () => { - const candidates = [ - criticalReady('WL-CRIT-HIGH-IDX', { sortIndex: 900 }), - criticalIdea('WL-CRIT-LOW-IDX', { sortIndex: 10 }), - criticalPlanned('WL-CRIT-MID', { sortIndex: 500 }), - ] as never; - expect(selectCriticalCandidate(candidates)?.id).toBe('WL-CRIT-LOW-IDX'); - }); - - it('plan_complete above the risk cap is NOT implement-selected (caps retained, Q2)', () => { - const highRisk = criticalPlanned('WL-CRIT-HI-RISK', { risk: 'high' }); - expect(selectCriticalCandidate([highRisk] as never)).toBeNull(); - }); - - it('plan_complete above the effort cap is NOT implement-selected (caps retained, Q2)', () => { - const largeEffort = criticalPlanned('WL-CRIT-HI-EFFORT', { effort: 'large' }); - expect(selectCriticalCandidate([largeEffort] as never)).toBeNull(); - }); - - it('a capped-out plan_complete critical does not shadow a within-cap critical sibling', () => { - const candidates = [ - criticalPlanned('WL-CRIT-HI-RISK', { risk: 'high', sortIndex: 10 }), - criticalReady('WL-CRIT-OK', { sortIndex: 900 }), - ] as never; - // The capped critical is excluded, so the next critical wins (even with - // a higher sortIndex) — caps never multi-starve the critical tier. - expect(selectCriticalCandidate(candidates)?.id).toBe('WL-CRIT-OK'); - }); - - it('excludes non-open items (status=open client-side guard)', () => { - const inProgress = criticalIdea('WL-CRIT-INPROG', { status: 'in_progress' }); - expect(selectCriticalCandidate([inProgress] as never)).toBeNull(); - }); - - it('excludes candidates still at their dispatched-at stage (dispatched-marker change-guard)', () => { - const dispatched = new Map([['WL-CRIT-I', 'idea']]); - const other = criticalReady('WL-CRIT-OTHER'); - const selected = selectCriticalCandidate( - [criticalIdea(), other] as never, - dispatched, - ); - // WL-CRIT-I was dispatched for /skill:intake at idea and is still at idea - // → excluded; the next critical candidate is selected. - expect(selected?.id).toBe('WL-CRIT-OTHER'); - }); - - it('releases a candidate whose stage advanced past its dispatched-at stage', () => { - const dispatched = new Map([['WL-CRIT-R', 'intake_complete']]); - // Now at plan_complete (a different stage) → not suppressed. - const advanced = criticalPlanned('WL-CRIT-R'); - expect(selectCriticalCandidate([advanced] as never, dispatched)?.id).toBe('WL-CRIT-R'); - }); - - it('round-robins within the critical group when a registry is given (tie-break)', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-critical', rng: () => 0.5 }); - const candidates = [ - criticalIdea('WL-CRIT-A', { sortIndex: 1 }), - criticalIdea('WL-CRIT-B', { sortIndex: 2 }), - ] as never; - expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-A'); - expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-B'); - expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-A'); - }); - - it('critical rotation does not disturb the non-critical implement tier cursor', () => { - const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-critical-isolated', rng: () => 0.5 }); - const criticals = [ - criticalIdea('WL-CRIT-A', { sortIndex: 1 }), - criticalIdea('WL-CRIT-B', { sortIndex: 2 }), - ] as never; - const implements_: ImplementCandidate[] = [ - { id: 'WL-IMP-A', title: 'A', status: 'open', priority: 'high', sortIndex: 1, risk: 'low', effort: 'small' }, - { id: 'WL-IMP-B', title: 'B', status: 'open', priority: 'high', sortIndex: 2, risk: 'low', effort: 'small' }, - ]; - // Rotate the critical group twice… - selectCriticalCandidate(criticals, undefined, registry); - selectCriticalCandidate(criticals, undefined, registry); - // …the implement tier's own cursor is untouched: still starts at A. - expect(selectImplementCandidate(implements_, undefined, registry)?.id).toBe('WL-IMP-A'); - }); - - it('returns null on an empty list', () => { - expect(selectCriticalCandidate([] as never)).toBeNull(); - }); -}); - -describe('parseCriticalCandidatesOutput', () => { - it('parses the wl list batch shape preserving status/stage/risk/effort/sortIndex/priority', () => { - const candidates = parseCriticalCandidatesOutput( - JSON.stringify({ - success: true, - count: 2, - workItems: [ - criticalIdea('WL-CRIT-I', { sortIndex: 7 }), - criticalReady('WL-CRIT-R', { sortIndex: 3 }), - ], - }), - ); - expect(candidates).toEqual([ - expect.objectContaining({ id: 'WL-CRIT-I', stage: 'idea', sortIndex: 7, priority: 'critical' }), - expect.objectContaining({ id: 'WL-CRIT-R', stage: 'intake_complete', sortIndex: 3, priority: 'critical' }), - ]); - }); - - it('malformed JSON yields null (fail-closed)', () => { - expect(parseCriticalCandidatesOutput('not json')).toBeNull(); - }); - - it('entries without an id are skipped; empty workItems yields []', () => { - const candidates = parseCriticalCandidatesOutput( - JSON.stringify({ - success: true, - count: 0, - workItems: [{ title: 'no id', stage: 'idea' }], - }), - ); - expect(candidates).toEqual([]); - }); -}); - -// ── Critical-first: dependency frontier (F3, decision Q3) ──────────── - -describe('resolveDependencyFrontier', () => { - it('an unblocked critical candidate resolves to itself (no frontier redirect)', async () => { - const candidate = criticalIdea('WL-CRIT-C'); - const result = await resolveDependencyFrontier( - candidate as never, - async () => [], - ); - expect(result?.id).toBe('WL-CRIT-C'); - }); - - it('dispatches the nearest OPEN blocker when the critical candidate is dependency-blocked', async () => { - const blocker = criticalReady('WL-BLOCK'); - const candidate = criticalIdea('WL-CRIT-C'); - const result = await resolveDependencyFrontier( - candidate as never, - async (id) => (id === 'WL-CRIT-C' ? [blocker] : []), - ); - expect(result?.id).toBe('WL-BLOCK'); - expect(result?.stage).toBe('intake_complete'); - }); - - it('recurses through dependency-blocked blockers to the nearest open ancestor', async () => { - const ancestor = criticalPlanned('WL-ANCESTOR'); - const midBlocker = criticalReady('WL-MID', { status: 'in_progress' }); - const candidate = criticalIdea('WL-CRIT-C'); - const result = await resolveDependencyFrontier( - candidate as never, - async (id) => { - if (id === 'WL-CRIT-C') return [midBlocker]; - if (id === 'WL-MID') return [ancestor]; - return []; - }, - ); - expect(result?.id).toBe('WL-ANCESTOR'); - }); - - it('a chain bottoming in a closed blocker resolves to null (fall through to tiers)', async () => { - const closed = criticalIdea('WL-CLOSED', { status: 'completed' }); - const candidate = criticalIdea('WL-CRIT-C'); - const result = await resolveDependencyFrontier( - candidate as never, - async (id) => (id === 'WL-CRIT-C' ? [closed] : []), - ); - expect(result).toBeNull(); - }); - - it('an in_progress/in_review frontier blocker resolves to null (non-dispatchable stage)', async () => { - const busy = criticalReady('WL-BUSY', { status: 'in_progress' }); - const candidate = criticalIdea('WL-CRIT-C'); - const result = await resolveDependencyFrontier( - candidate as never, - async (id) => (id === 'WL-CRIT-C' ? [busy] : []), - ); - expect(result).toBeNull(); - }); - - it('tolerates cycles (bounded recursion, no infinite loop)', async () => { - const a = criticalIdea('WL-A'); - const b = criticalReady('WL-B'); - const candidate = criticalIdea('WL-CRIT-C'); - const result = await resolveDependencyFrontier( - candidate as never, - async (id) => { - if (id === 'WL-CRIT-C') return [a]; - if (id === 'WL-A') return [b]; - if (id === 'WL-B') return [a]; // cycle: A ↔ B - return []; - }, - ); - // Resolves without hanging; a cycle has no nearest open unblocked - // ancestor, so the frontier yields null (fall through). - expect(result).toBeNull(); - }); - - it('a blocker above the implement caps is not a valid frontier (caps apply to blockers, Q2)', async () => { - const capped = criticalPlanned('WL-CAPPED', { risk: 'high' }); - const candidate = criticalIdea('WL-CRIT-C'); - const result = await resolveDependencyFrontier( - candidate as never, - async (id) => (id === 'WL-CRIT-C' ? [capped] : []), - ); - expect(result).toBeNull(); - }); -}); - -describe('parseDepListBlockersOutput', () => { - it('parses outbound depends-on edges as the blockers of the queried item', () => { - const blockers = parseDepListBlockersOutput( - JSON.stringify({ - success: true, - item: 'WL-CRIT-C', - inbound: [{ id: 'WL-DEPENDENT', title: 'Depends on me', status: 'open', direction: 'depended-on-by' }], - outbound: [ - { id: 'WL-BLOCKER', title: 'Blocking item', status: 'open', priority: 'critical', direction: 'depends-on' }, - ], - }), - ); - expect(blockers).toEqual([ - { id: 'WL-BLOCKER', title: 'Blocking item', status: 'open', priority: 'critical' }, - ]); - }); - - it('ignores inbound depended-on-by edges (they are dependents, not blockers)', () => { - const blockers = parseDepListBlockersOutput( - JSON.stringify({ - success: true, - item: 'WL-CRIT-C', - inbound: [{ id: 'WL-DEPENDENT', title: 'Depends on me', status: 'open', direction: 'depended-on-by' }], - outbound: [], - }), - ); - expect(blockers).toEqual([]); - }); - - it('malformed JSON or a missing outbound list yields null (fail-closed)', () => { - expect(parseDepListBlockersOutput('not json')).toBeNull(); - expect(parseDepListBlockersOutput(JSON.stringify({ success: true, item: 'WL-CRIT-C' }))).toBeNull(); - }); -}); - -describe('parseShownWorkItem', () => { - it('parses the wl show single-item shape into a full critical candidate', () => { - const item = parseShownWorkItem( - JSON.stringify({ - success: true, - workItem: { - id: 'WL-BLOCKER', - title: 'Blocking item', - status: 'open', - stage: 'plan_complete', - risk: 'medium', - effort: 'medium', - sortIndex: 3, - priority: 'critical', - }, - }), - ); - expect(item).toEqual({ - id: 'WL-BLOCKER', - title: 'Blocking item', - status: 'open', - stage: 'plan_complete', - risk: 'medium', - effort: 'medium', - sortIndex: 3, - priority: 'critical', - }); - }); - - it('malformed output or a missing workItem yields null (fail-closed)', () => { - expect(parseShownWorkItem('not json')).toBeNull(); - expect(parseShownWorkItem(JSON.stringify({ success: true }))).toBeNull(); - }); -}); - -describe('parseShowItemOutput audit verdict (WL-0MU308WSF0002JWN)', () => { - it('reads the TOP-LEVEL auditResult verdict and timestamp', () => { - const info = parseShowItemOutput( - JSON.stringify({ - success: true, - workItem: { id: 'WL-AUD', status: 'completed', stage: 'in_review' }, - auditResult: { - workItemId: 'WL-AUD', - readyToClose: false, - auditedAt: '2026-01-02T00:00:00.000Z', - }, - }), - ); - expect(info).toMatchObject({ - id: 'WL-AUD', - status: 'completed', - stage: 'in_review', - auditedAt: '2026-01-02T00:00:00.000Z', - auditResult: false, - }); - }); - - it('leaves the verdict absent when auditResult is null (no audit recorded)', () => { - const info = parseShowItemOutput( - JSON.stringify({ success: true, workItem: { id: 'WL-AUD2', stage: 'in_review' }, auditResult: null }), - ); - expect(info!.auditResult).toBeUndefined(); - }); -}); - -// ── Critical-first: tier wiring (F4, decisions Q1/Q2/Q3) ───────────── - -describe('dispatch critical-first tier', () => { - it('dispatches a critical idea candidate with /skill:intake ahead of ANY non-critical candidate', async () => { - const deps = makeDeps({ - // Non-critical implement candidate exists and is the old winner — - // the critical tier must win regardless. - getNextImplementCandidate: vi.fn().mockResolvedValue({ - id: 'WL-IMP', - title: 'Non-critical implement', - stage: 'implement', - } as DowntimeCandidate), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalIdea('WL-CRIT-I'), - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('intake'); - expect(outcome.candidate?.id).toBe('WL-CRIT-I'); - expect(deps.getNextCriticalCandidate).toHaveBeenCalledWith('/repo'); - expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); - expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-I', { status: 'open', stage: 'idea' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:intake WL-CRIT-I'), - { model: 'plan', cwd: '/repo', itemId: 'WL-CRIT-I', itemTitle: 'Critical idea WL-CRIT-I' }, - ); - }); - - it('dispatches a critical intake_complete candidate with /skill:plan ahead of the implement tier', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue({ - id: 'WL-IMP', - title: 'Non-critical implement', - stage: 'implement', - } as DowntimeCandidate), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalReady('WL-CRIT-R'), - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('WL-CRIT-R'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-R', { status: 'open', stage: 'intake_complete' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-CRIT-R'), - expect.anything(), - ); - }); - - it('dispatches a critical plan_complete candidate within caps with /skill:implement first', async () => { - const deps = makeDeps({ - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalPlanned('WL-CRIT-P'), - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('WL-CRIT-P'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-P', { status: 'open', stage: 'plan_complete' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:implement WL-CRIT-P'), - expect.anything(), - ); - }); - - it('critical-first tier runs AFTER the audit tier (audit keeps its slot)', async () => { - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, - }), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalIdea('WL-CRIT-I'), - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('audit'); - expect(deps.getNextCriticalCandidate).not.toHaveBeenCalled(); - }); - - it('scheduled-prompts tier still runs FIRST (ahead of the critical tier)', async () => { - const deps = makeDeps({ - getDueScheduledPrompt: vi.fn().mockResolvedValue({ - id: '/skill:refactor', - prompt: '/skill:refactor', - intervalDays: 3, - lastTriggeredAt: null, - } as ScheduledPrompt), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalIdea('WL-CRIT-I'), - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('scheduled'); - expect(deps.getNextCriticalCandidate).not.toHaveBeenCalled(); - }); - - it('empty critical tier falls through to the non-critical tiers unchanged (regression)', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue({ - id: 'WL-IMP', - title: 'Implement me', - stage: 'implement', - } as DowntimeCandidate), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.kind).toBe('implement'); - expect(deps.getNextCriticalCandidate).toHaveBeenCalledTimes(1); - expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); - }); - - it('a critical-tier wl failure fails closed to wl-error (never a silent fall-through)', async () => { - const deps = makeDeps({ - getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: false }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('wl-error'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('during a code freeze critical implement dispatch PAUSES (fail-closed) while plan/intake tiers still run', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalPlanned('WL-CRIT-P'), // implement-ready, but frozen - }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Frozen → the critical tier is still consulted (a critical plan/intake - // candidate could still dispatch, Q1), but the implement-ready critical - // is NOT dispatched (no new code lands mid-release); the non-critical - // plan/intake tiers still dispatch. - expect(deps.getNextCriticalCandidate).toHaveBeenCalledTimes(1); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('WL-PLAN'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-PLAN'), - expect.anything(), - ); - expect(deps.claimItem).not.toHaveBeenCalledWith('WL-CRIT-P', expect.anything()); - }); - - it('freeze split-by-skill: critical plan/intake dispatch CONTINUES while frozen (Q1)', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalReady('WL-CRIT-R'), // plan-ready - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-R', { status: 'open', stage: 'intake_complete' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-CRIT-R'), - expect.anything(), - ); - }); - - it('ambiguous marker is treated as frozen (fail-closed): critical implement pauses, plan/intake continue', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalPlanned('WL-CRIT-P'), - }), - getNextItem: vi - .fn() - .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty - .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDEA', title: 'An idea', stage: 'idea' } }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Frozen: the critical implement candidate is skipped (fail-closed on - // the ambiguous marker); the intake tier still dispatches. - expect(outcome.kind).toBe('intake'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:intake WL-IDEA'), - expect.anything(), - ); - expect(deps.claimItem).not.toHaveBeenCalledWith('WL-CRIT-P', expect.anything()); - }); - - it('no double-dispatch: claim CAS applies to the critical tier (stale claim aborts)', async () => { - const deps = makeDeps({ - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalIdea('WL-CRIT-I'), - }), - claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'stale' }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('claim-failed'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('dependency-blocked critical candidate dispatches its frontier blocker with the blocker stage-appropriate skill (Q3)', async () => { - // The dep returns the frontier-resolved blocker (like getNextItem does): - // the critical item is dependency-blocked, so the selected candidate IS - // the nearest open blocker, carrying ITS stage. - const deps = makeDeps({ - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalReady('WL-BLOCKER', { title: 'Blocking work item' }), - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('WL-BLOCKER'); - expect(deps.claimItem).toHaveBeenCalledWith('WL-BLOCKER', { status: 'open', stage: 'intake_complete' }, '/repo'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan WL-BLOCKER'), - expect.anything(), - ); - }); - - it('records the critical dispatch with the skill-mapped kind (rolling log + change-guard consistency)', async () => { - const deps = makeDeps({ - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalIdea('WL-CRIT-I'), - }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; - expect(event.itemId).toBe('WL-CRIT-I'); - expect(event.kind).toBe('intake'); - expect(event.stage).toBe('idea'); - expect(event.cwd).toBe('/repo'); - }); - - it('single-flight: a concurrent dispatch refuses the critical tier (dispatch-in-flight)', async () => { - // Gate the pane spawn so the first dispatch is verifiably in flight - // when the second call arrives (same pattern as the existing - // single-flight guard test, using the critical-tier candidate). - let release!: () => void; - const gate = new Promise<{ ok: true }>((resolve) => { - release = () => resolve({ ok: true }); - }); - const deps = makeDeps({ - getNextCriticalCandidate: vi.fn().mockResolvedValue({ - ok: true, - candidate: criticalIdea('WL-CRIT-I'), - }), - spawnAgentPane: vi.fn().mockImplementation(() => gate), - }); - - const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - release(); - const [firstOutcome, secondOutcome] = await Promise.all([first, second]); - - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - expect(firstOutcome.dispatched).toBe(true); - expect(firstOutcome.kind).toBe('intake'); - expect(secondOutcome.dispatched).toBe(false); - expect(secondOutcome.reason).toBe('dispatch-in-flight'); - }); -}); - -// ── needsProducerReview exclusion (WL-0MTIAL65N004T22F) ───────────── -// AC1–AC4: every tier (audit / implement / plan / intake / critical-first -// including frontier blockers and the coordination leader path) excludes -// items with needsProducerReview === true; absent/false/undefined is -// dispatchable; failed/unparseable parsing is fail-safe; clearing the flag -// makes the item dispatchable again. - -describe('needsProducerReview exclusion', () => { - const auditDated = () => new Date(Date.now() - 1000).toISOString(); - const auditCandidate = (overrides: Partial = {}): AuditCandidate => ({ - id: `AUD-${Math.random().toString(36).slice(2, 8)}`, - title: 'Audit candidate', - sortIndex: 1, - updatedAt: auditDated(), - ...overrides, - }); - const implementCandidate = (overrides: Partial = {}): ImplementCandidate => ({ - id: `IMP-${Math.random().toString(36).slice(2, 8)}`, - title: 'Implement candidate', - status: 'open', - risk: 'low', - effort: 'small', - sortIndex: 1, - ...overrides, - }); - const nextCandidate = (overrides: Partial = {}): DowntimeCandidate => ({ - id: `NEXT-${Math.random().toString(36).slice(2, 8)}`, - title: 'Next candidate', - stage: 'intake_complete', - status: 'open', - sortIndex: 1, - ...overrides, - }); - const critCandidate = (overrides: Partial = {}): CriticalCandidate => ({ - id: `CR-${Math.random().toString(36).slice(2, 8)}`, - title: 'Critical candidate', - status: 'open', - stage: 'idea', - sortIndex: 1, - ...overrides, - }); - - describe('selectAuditCandidate', () => { - it('excludes needsProducerReview === true candidates', () => { - const blockedAudit = auditCandidate({ id: 'A1', needsProducerReview: true, sortIndex: 1 }); - const allowedAudit = auditCandidate({ id: 'A2', sortIndex: 10 }); - const sel = selectAuditCandidate([blockedAudit, allowedAudit]); - expect(sel?.id).toBe('A2'); - }); - it('absent/false/undefined needsProducerReview remain dispatchable', () => { - const falsy = auditCandidate({ id: 'A3', needsProducerReview: false, sortIndex: 1 }); - const absent = auditCandidate({ id: 'A4', sortIndex: 2 }); - expect(selectAuditCandidate([falsy])?.id).toBe('A3'); - expect(selectAuditCandidate([absent])?.id).toBe('A4'); - }); - it('returns null when the tier contains only review-gated candidates', () => { - const b = auditCandidate({ id: 'A5', needsProducerReview: true, sortIndex: 1 }); - expect(selectAuditCandidate([b])).toBeNull(); - }); - it('clearing needsProducerReview makes the item dispatchable again', () => { - const c = auditCandidate({ id: 'A6', needsProducerReview: true, sortIndex: 1 }); - expect(selectAuditCandidate([c])).toBeNull(); - // Clear the flag — same candidate object, flag removed → dispatchable. - c.needsProducerReview = false; - expect(selectAuditCandidate([c])?.id).toBe('A6'); - }); - }); - - describe('selectImplementCandidate', () => { - it('excludes needsProducerReview === true candidates', () => { - const blocked = implementCandidate({ id: 'I1', needsProducerReview: true, sortIndex: 1 }); - const allowed = implementCandidate({ id: 'I2', sortIndex: 10 }); - const sel = selectImplementCandidate([blocked, allowed] as ImplementCandidate[]); - expect(sel?.id).toBe('I2'); - }); - it('absent/false remain dispatchable', () => { - expect(selectImplementCandidate([implementCandidate({ id: 'I3', needsProducerReview: false, sortIndex: 1 })] as ImplementCandidate[])?.id).toBe('I3'); - expect(selectImplementCandidate([implementCandidate({ id: 'I4', sortIndex: 1 })] as ImplementCandidate[])?.id).toBe('I4'); - }); - it('returns null when only review-gated candidates remain', () => { - expect(selectImplementCandidate([implementCandidate({ id: 'I5', needsProducerReview: true, sortIndex: 1 })] as ImplementCandidate[])).toBeNull(); - }); - it('clearing the flag makes the item dispatchable again', () => { - const c = implementCandidate({ id: 'I6', needsProducerReview: true, sortIndex: 1 }); - expect(selectImplementCandidate([c] as ImplementCandidate[])).toBeNull(); - c.needsProducerReview = false; - expect(selectImplementCandidate([c] as ImplementCandidate[])?.id).toBe('I6'); - }); - }); - - describe('selectNextCandidate', () => { - it('excludes needsProducerReview === true candidates', () => { - const blocked = nextCandidate({ id: 'N1', needsProducerReview: true, sortIndex: 1, stage: 'intake_complete' }); - const allowed = nextCandidate({ id: 'N2', sortIndex: 10, stage: 'intake_complete' }); - expect(selectNextCandidate([blocked, allowed])?.id).toBe('N2'); - }); - it('absent/false remain dispatchable', () => { - expect(selectNextCandidate([nextCandidate({ id: 'N3', needsProducerReview: false, sortIndex: 1 })])?.id).toBe('N3'); - expect(selectNextCandidate([nextCandidate({ id: 'N4', sortIndex: 1 })])?.id).toBe('N4'); - }); - it('returns null when only review-gated candidates remain', () => { - expect(selectNextCandidate([nextCandidate({ id: 'N5', needsProducerReview: true, sortIndex: 1 })])).toBeNull(); - }); - it('clearing the flag makes the item dispatchable again', () => { - const c = nextCandidate({ id: 'N6', needsProducerReview: true, sortIndex: 1 }); - expect(selectNextCandidate([c])).toBeNull(); - c.needsProducerReview = false; - expect(selectNextCandidate([c])?.id).toBe('N6'); - }); - }); - - describe('selectCriticalCandidate', () => { - it('excludes needsProducerReview === true candidates', () => { - const blocked = critCandidate({ id: 'C1', needsProducerReview: true, sortIndex: 1 }); - const allowed = critCandidate({ id: 'C2', sortIndex: 10 }); - expect(selectCriticalCandidate([blocked, allowed] as never)?.id).toBe('C2'); - }); - it('absent/false remain dispatchable', () => { - expect(selectCriticalCandidate([critCandidate({ id: 'C3', needsProducerReview: false, sortIndex: 1 })] as never)?.id).toBe('C3'); - expect(selectCriticalCandidate([critCandidate({ id: 'C4', sortIndex: 1 })] as never)?.id).toBe('C4'); - }); - it('returns null when only review-gated criticals remain', () => { - expect(selectCriticalCandidate([critCandidate({ id: 'C5', needsProducerReview: true })] as never)).toBeNull(); - }); - }); - - describe('resolveDependencyFrontier (AC2)', () => { - it('resolves to null when the only blocker is review-gated', async () => { - const crit: CriticalCandidate = critCandidate({ id: 'C6', stage: 'plan_complete', risk: 'low', effort: 'medium' }); - const fetchBlockers = async (_id: string): Promise => [ - { id: 'B1', title: 'Review-gated blocker', status: 'open', stage: 'idea', needsProducerReview: true }, - ]; - expect(await resolveDependencyFrontier(crit, fetchBlockers)).toBeNull(); - }); - it('skips a review-gated direct blocker and still reaches an open ancestor beneath a non-dispatchable wrapper', async () => { - const crit: CriticalCandidate = critCandidate({ id: 'C7', stage: 'plan_complete', risk: 'low', effort: 'medium' }); - const fetchBlockers = async (id: string): Promise => { - if (id === 'C7') return [{ id: 'MID', title: 'Non-dispatchable wrapper', status: 'open', stage: 'in_review', needsProducerReview: true }]; - if (id === 'MID') return [{ id: 'DEEP', title: 'Open deep blocker', status: 'open', stage: 'idea' }]; - return []; - }; - // Walk recurses through MID (non-dispatchable but has open descendant) → DEEP. - expect((await resolveDependencyFrontier(crit, fetchBlockers))?.id).toBe('DEEP'); - }); - it('review-gated plan_complete blockers are excluded (caps-covered + flag)', async () => { - const crit: CriticalCandidate = critCandidate({ id: 'C8', stage: 'plan_complete', risk: 'low', effort: 'medium' }); - const fetchBlockers = async (_id: string): Promise => [ - { id: 'B2', title: 'Gated plan_complete', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'small', needsProducerReview: true }, - ]; - expect(await resolveDependencyFrontier(crit, fetchBlockers)).toBeNull(); - }); - }); - - describe('classifyItemForDispatch', () => { - it('returns null immediately when needsProducerReview === true (AC1 — every tier)', () => { - for (const info of [ - { id: 'X', status: 'completed', stage: 'in_review', updatedAt: new Date().toISOString(), needsProducerReview: true }, - { id: 'X', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'medium', needsProducerReview: true }, - { id: 'X', status: 'open', stage: 'intake_complete', needsProducerReview: true }, - { id: 'X', status: 'open', stage: 'idea', needsProducerReview: true }, - ] as DowntimeItemInfo[]) { - expect(classifyItemForDispatch(info)).toBeNull(); - } - }); - it('dispatches normally when needsProducerReview is false/undefined', () => { - expect(classifyItemForDispatch({ id: 'I', status: 'open', stage: 'idea', needsProducerReview: false } as DowntimeItemInfo)).toBe('intake'); - expect(classifyItemForDispatch({ id: 'I', status: 'open', stage: 'idea' } as DowntimeItemInfo)).toBe('intake'); - }); - - // ── risk-effort dispatch for missing risk/effort (WL-0MTTSWCJR003OMN7) ── - it('returns risk-effort for plan_complete items with missing risk', () => { - const info = { id: 'RE1', status: 'open', stage: 'plan_complete', risk: '', effort: 'small' } as DowntimeItemInfo; - expect(classifyItemForDispatch(info)).toBe('risk-effort'); - }); - it('returns risk-effort for plan_complete items with missing effort', () => { - const info = { id: 'RE2', status: 'open', stage: 'plan_complete', risk: 'low', effort: '' } as DowntimeItemInfo; - expect(classifyItemForDispatch(info)).toBe('risk-effort'); - }); - it('returns risk-effort for plan_complete items with both risk and effort missing', () => { - const info = { id: 'RE3', status: 'open', stage: 'plan_complete', risk: '', effort: '' } as DowntimeItemInfo; - expect(classifyItemForDispatch(info)).toBe('risk-effort'); - }); - it('returns risk-effort for plan_complete items with undefined risk/effort', () => { - expect(classifyItemForDispatch({ id: 'RE4', status: 'open', stage: 'plan_complete' } as DowntimeItemInfo)).toBe('risk-effort'); - expect(classifyItemForDispatch({ id: 'RE5', status: 'open', stage: 'plan_complete', risk: null, effort: null } as DowntimeItemInfo)).toBe('risk-effort'); - }); - it('does NOT dispatch risk-effort when plan_complete has valid risk/effort within caps', () => { - expect(classifyItemForDispatch({ id: 'RE6', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'small' } as DowntimeItemInfo)).toBe('implement'); - expect(classifyItemForDispatch({ id: 'RE7', status: 'open', stage: 'plan_complete', risk: 'medium', effort: 'medium' } as DowntimeItemInfo)).toBe('implement'); - }); - it('classifies ABOVE-cap risk as implement (risk cap removed — high/severe no longer excluded)', () => { - expect(classifyItemForDispatch({ id: 'RE8', status: 'open', stage: 'plan_complete', risk: 'high', effort: 'small' } as DowntimeItemInfo)).toBe('implement'); - expect(classifyItemForDispatch({ id: 'RE8b', status: 'open', stage: 'plan_complete', risk: 'critical', effort: 'small' } as DowntimeItemInfo)).toBe('implement'); - expect(classifyItemForDispatch({ id: 'RE8c', status: 'open', stage: 'plan_complete', risk: 'severe', effort: 'medium' } as DowntimeItemInfo)).toBe('implement'); - }); - it('returns null when plan_complete has valid effort but ABOVE cap (extra large)', () => { - expect(classifyItemForDispatch({ id: 'RE9', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'extra large' } as DowntimeItemInfo)).toBeNull(); - }); - - // ── blocked status is dispatchable (WL-0MUBUA9C8009N0K5 AC1) ── - it('classifies blocked items to their stage-appropriate kind', () => { - expect(classifyItemForDispatch({ id: 'B1', status: 'blocked', stage: 'idea' } as DowntimeItemInfo)).toBe('intake'); - expect(classifyItemForDispatch({ id: 'B2', status: 'blocked', stage: 'intake_complete' } as DowntimeItemInfo)).toBe('plan'); - expect(classifyItemForDispatch({ id: 'B3', status: 'blocked', stage: 'plan_complete', risk: 'high', effort: 'small' } as DowntimeItemInfo)).toBe('implement'); - }); - it('still excludes blocked items needing producer review', () => { - expect(classifyItemForDispatch({ id: 'B4', status: 'blocked', stage: 'idea', needsProducerReview: true } as DowntimeItemInfo)).toBeNull(); - }); - it('does not classify a blocked item with a non-dispatchable stage', () => { - expect(classifyItemForDispatch({ id: 'B5', status: 'blocked', stage: 'in_review' } as DowntimeItemInfo)).toBeNull(); - expect(classifyItemForDispatch({ id: 'B6', status: 'blocked', stage: 'done' } as DowntimeItemInfo)).toBeNull(); - }); - - // ── retired in_progress stage (WL-0MTTSWCJR003OMN7 — OSL dead zones) ── - it('dispatches risk-effort for open/in_progress items (retired stage)', () => { - expect(classifyItemForDispatch({ id: 'IP1', status: 'open', stage: 'in_progress' } as DowntimeItemInfo)).toBe('risk-effort'); - expect(classifyItemForDispatch({ id: 'IP2', status: 'open', stage: 'in_progress', risk: 'low' } as DowntimeItemInfo)).toBe('risk-effort'); - expect(classifyItemForDispatch({ id: 'IP3', status: 'open', stage: 'in_progress', risk: 'low', effort: 'small' } as DowntimeItemInfo)).toBe('risk-effort'); - }); - it('still blocks npr items on in_progress stage', () => { - expect(classifyItemForDispatch({ id: 'IP4', status: 'open', stage: 'in_progress', needsProducerReview: true } as DowntimeItemInfo)).toBeNull(); - }); - }); - - describe('selectCriticalFirstCandidates — blocked critical items (AC2)', () => { - const crit = (over: Partial): DowntimeHerdrItem => ({ - id: 'C', - title: 'Crit', - status: 'open', - stage: 'idea', - priority: 'critical', - sortIndex: 1, - ...over, - }); - - it('includes a blocked critical item at its stage-appropriate kind', () => { - const out = selectCriticalFirstCandidates([ - crit({ id: 'C-BLOCKED-PLAN', status: 'blocked', stage: 'intake_complete' }), - crit({ id: 'C-BLOCKED-IMPL', status: 'blocked', stage: 'plan_complete', risk: 'High', effort: 'S' }), - ]); - expect(out.map((c) => [c.item.id, c.kind])).toEqual([ - ['C-BLOCKED-PLAN', 'plan'], - ['C-BLOCKED-IMPL', 'implement'], - ]); - }); - - it('includes open and blocked critical items ordered by sortIndex', () => { - const out = selectCriticalFirstCandidates([ - crit({ id: 'C-OPEN', status: 'open', stage: 'idea', sortIndex: 20 }), - crit({ id: 'C-BLOCKED', status: 'blocked', stage: 'idea', sortIndex: 10 }), - ]); - expect(out.map((c) => c.item.id)).toEqual(['C-BLOCKED', 'C-OPEN']); - }); - - it('still excludes blocked critical items needing producer review', () => { - const out = selectCriticalFirstCandidates([ - crit({ id: 'C-NPR', status: 'blocked', stage: 'idea', needsProducerReview: true }), - ]); - expect(out).toEqual([]); - }); - - it('does not select a non-critical blocked item', () => { - const out = selectCriticalFirstCandidates([ - crit({ id: 'C-OTHER', status: 'blocked', stage: 'idea', priority: 'high' }), - ]); - expect(out).toEqual([]); - }); - }); - - describe('blocked candidate claim CAS (AC3)', () => { - it('claims a blocked candidate with its actual blocked status as the CAS guard', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - { id: 'B-PLAN', title: 'Blocked plan', status: 'blocked', stage: 'intake_complete', priority: 'critical', sortIndex: 1 }, - ]}), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('B-PLAN'); - // The CAS guard must match the item's ACTUAL status, not the tier's - // nominal `open` — otherwise the claim aborts as stale. - expect(deps.claimItem).toHaveBeenCalledWith( - 'B-PLAN', - { status: 'blocked', stage: 'intake_complete' }, - '/repo', - ); - }); - }); - - describe('parse* preserves needsProducerReview (AC4 fail-safe)', () => { - it('parseNextCandidatesOutput preserves true/undefined/absent per candidate', () => { - const out = JSON.stringify({ - workItems: [ - { workItem: { id: 'P1', title: 'A', status: 'open', priority: 'medium', needsProducerReview: 1 } }, - { workItem: { id: 'P2', title: 'B', status: 'open', priority: 'medium', needsProducerReview: 0 } }, - { workItem: { id: 'P3', title: 'C', status: 'open', priority: 'medium', needsProducerReview: true } }, - { workItem: { id: 'P4', title: 'D', status: 'open', priority: 'medium' } }, - ], - }); - const c = parseNextCandidatesOutput(out, 'intake_complete')!; - expect(c.find((x) => x.id === 'P1')!.needsProducerReview).toBe(true); - expect(c.find((x) => x.id === 'P2')!.needsProducerReview).toBe(false); - expect(c.find((x) => x.id === 'P3')!.needsProducerReview).toBe(true); - expect(c.find((x) => x.id === 'P4')!.needsProducerReview).toBeUndefined(); - }); - it('parseImplementCandidatesOutput preserves needsProducerReview', () => { - const out = JSON.stringify({ - workItems: [{ workItem: { id: 'IM', title: 'X', status: 'open', risk: 'low', effort: 'small', needsProducerReview: true } }], - }); - expect(parseImplementCandidatesOutput(out)![0]!.needsProducerReview).toBe(true); - }); - it('parseAuditCandidatesOutput preserves needsProducerReview', () => { - const out = JSON.stringify({ - workItems: [{ id: 'AU', title: 'A', auditedAt: null, updatedAt: new Date().toISOString(), needsProducerReview: true }], - }); - expect(parseAuditCandidatesOutput(out)![0]!.needsProducerReview).toBe(true); - }); - it('parseCriticalCandidatesOutput preserves needsProducerReview', () => { - const out = JSON.stringify({ - workItems: [{ id: 'CR', title: 'A', status: 'open', stage: 'idea', needsProducerReview: true }], - }); - expect(parseCriticalCandidatesOutput(out)![0]!.needsProducerReview).toBe(true); - }); - it('parseShowItemOutput preserves needsProducerReview', () => { - const out = JSON.stringify({ success: true, workItem: { id: 'SH', title: 'shown', status: 'open', stage: 'idea', needsProducerReview: true } }); - expect(parseShownWorkItem(out)!.needsProducerReview).toBe(true); - }); - it('dispatch falls through when every candidate in every tier needs review (no-candidate, not wl-error)', async () => { - // Every tier returns a flagged candidate (or empty) → no-candidate, never a strike. - const deps2 = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'A-B', title: 'A-B', stage: 'audit', needsProducerReview: true } }), - getActiveAudit: vi.fn().mockResolvedValue({ ok: true, active: false }), - hasFreshAudit: vi.fn().mockResolvedValue(false), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - // Audit tier candidate is review-gated → skipped → falls through to no-candidate. - const outcome = await dispatchDowntimeWork(deps2, { model: 'plan', cwd: '/repo' }); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - }); - }); - - describe('transient retry (WL-0MTOTCETU004YYIU AC3 — bounded backoff)', () => { - describe('isTransientDowntimeError', () => { - it('classifies SQLITE_BUSY variants as transient', () => { - expect(isTransientDowntimeError('SQLITE_BUSY: database is locked')).toBe(true); - expect(isTransientDowntimeError('SQLITE BUSY: database is locked')).toBe(true); - expect(isTransientDowntimeError('database is locked')).toBe(true); - expect(isTransientDowntimeError('database table is locked')).toBe(true); - expect(isTransientDowntimeError('database busy')).toBe(true); - }); - it('does not classify timeout/hang as transient (retry would triple 10s wall-clock)', () => { - // ETIMEDOUT / "timed out" is a hung child (10s per attempt); retrying - // would be 30s and risks DOWNTIME_RUN_TIMEOUT_MS — strikes correctly. - expect(isTransientDowntimeError('ETIMEDOUT')).toBe(false); - expect(isTransientDowntimeError('timed out after 10000ms')).toBe(false); - expect(isTransientDowntimeError('spawn ETIMEDOUT')).toBe(false); - expect(isTransientDowntimeError('exceeded timeout of 10000ms')).toBe(false); - expect(isTransientDowntimeError('operation timeout')).toBe(false); - expect(isTransientDowntimeError('TIMED OUT')).toBe(false); - }); - it('is case-insensitive', () => { - expect(isTransientDowntimeError('Sqlite_Busy: DATABASE IS LOCKED')).toBe(true); - expect(isTransientDowntimeError('DATABASE BUSY')).toBe(true); - }); - it('does not classify parse/author-gate errors as transient', () => { - expect(isTransientDowntimeError('show parse error')).toBe(false); - expect(isTransientDowntimeError('audit list parse error')).toBe(false); - expect(isTransientDowntimeError('author identity gate')).toBe(false); - expect(isTransientDowntimeError('needsProducerReview gate')).toBe(false); - expect(isTransientDowntimeError('')).toBe(false); - expect(isTransientDowntimeError('some random error')).toBe(false); - }); - }); - - describe('withTransientRetry', () => { - it('succeeds on first try without retry', async () => { - const fn = vi.fn().mockResolvedValue('ok'); - await expect(withTransientRetry(fn, 2, 1)).resolves.toBe('ok'); - expect(fn).toHaveBeenCalledTimes(1); - }); - it('retries once on transient then succeeds (no strike)', async () => { - const fn = vi - .fn() - .mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')) - .mockResolvedValueOnce('ok'); - await expect(withTransientRetry(fn, 2, 1)).resolves.toBe('ok'); - expect(fn).toHaveBeenCalledTimes(2); - }); - it('retries twice on two transients then succeeds', async () => { - const fn = vi - .fn() - .mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')) - .mockRejectedValueOnce(new Error('database is locked')) - .mockResolvedValueOnce('ok'); - await expect(withTransientRetry(fn, 2, 1)).resolves.toBe('ok'); - expect(fn).toHaveBeenCalledTimes(3); - }); - it('retries up to limit then throws (single strike, not three)', async () => { - const fn = vi.fn().mockRejectedValue(new Error('SQLITE_BUSY: database is locked')); - await expect(withTransientRetry(fn, 2, 1)).rejects.toThrow('SQLITE_BUSY'); - expect(fn).toHaveBeenCalledTimes(3); // 1 + 2 retries - }); - it('fails fast on non-transient (no retry)', async () => { - const fn = vi.fn().mockRejectedValue(new Error('show parse error')); - await expect(withTransientRetry(fn, 2, 1)).rejects.toThrow('show parse error'); - expect(fn).toHaveBeenCalledTimes(1); - }); - it('fails fast on second attempt if second error is non-transient', async () => { - const fn = vi - .fn() - .mockRejectedValueOnce(new Error('SQLITE_BUSY: database is locked')) - .mockRejectedValueOnce(new Error('show parse error')); - await expect(withTransientRetry(fn, 2, 1)).rejects.toThrow('show parse error'); - expect(fn).toHaveBeenCalledTimes(2); - }); - it('does not retry timeout variants (fail fast — one strike)', async () => { - const fn = vi.fn().mockRejectedValue(new Error('timed out after 10000ms')); - await expect(withTransientRetry(fn, 2, 1)).rejects.toThrow('timed out'); - expect(fn).toHaveBeenCalledTimes(1); - }); - it('handles non-Error thrown values', async () => { - const fn = vi - .fn() - .mockRejectedValueOnce('SQLITE_BUSY: database is locked') - .mockResolvedValueOnce('ok'); - await expect(withTransientRetry(fn, 2, 1)).resolves.toBe('ok'); - expect(fn).toHaveBeenCalledTimes(2); - }); - }); - }); -}); - -// ── Review-queue depth gate (WL-0MT2UQWOR007CYY9) ────────────────────── -// The implement tier is gated by the depth of the completed/in_review review -// queue: when root items >= browseItemCount, only critical-priority -// implement candidates remain eligible. The gate is re-read live on every -// dispatch (no plugin restart needed), fail-closed on wl error (gate active), -// and uses a neutral reason ('review-queue-hold') when plan/intake are also empty -// — never 'no-candidate', so the worker's cooldown is not triggered. - - -// ── Review-queue depth gate (WL-0MT2UQWOR007CYY9) ────────────────────── -// The implement tier is gated by the depth of the completed/in_review review -// queue: when root items >= browseItemCount, only critical-priority -// implement candidates remain eligible. The gate is re-read live on every -// dispatch (no plugin restart needed), fail-closed on wl error (gate active), -// and uses a neutral reason ('review-queue-hold') when plan/intake are also empty -// — never 'no-candidate', so the worker's cooldown is not triggered. - -describe('review-queue depth gate (WL-0MT2UQWOR007CYY9)', () => { - const implementCandidate = (overrides: Partial = {}): ImplementCandidate => ({ - id: `IMP-${Math.random().toString(36).slice(2, 8)}`, - title: 'Implement candidate', - status: 'open', - risk: 'low', - effort: 'small', - sortIndex: 1, - ...overrides, - }); - - const planCandidate: DowntimeCandidate = { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }; - - // ── AC1: critical bypass ─────────────────────────────────────────── - it('AC1: a critical-priority implement candidate always dispatches regardless of queue depth', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-CRIT', priority: 'critical' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(999), // deeply queued - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(deps.claimItem).toHaveBeenCalledWith('IMP-CRIT', { status: 'open', stage: 'plan_complete' }, '/repo'); - }); - - it('AC1: critical bypass works even when browseItemCount is 1 (queue deep)', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-CRIT2', priority: 'critical' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(1), // exactly at threshold - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 1 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - }); - - // ── AC2: gate inactive — all priorities eligible ─────────────────── - it('AC2: when reviewQueueCount < browseItemCount, all priorities dispatch unchanged', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-MED', priority: 'medium' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(10), // under threshold - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(deps.claimItem).toHaveBeenCalledWith('IMP-MED', { status: 'open', stage: 'plan_complete' }, '/repo'); - }); - - it('AC2: gate inactive — non-critical (high/low) dispatches when under threshold', async () => { - for (const priority of ['high', 'low'] as const) { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: `IMP-${priority}`, priority }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(0), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - } - }); - - // ── AC3: gate active — non-critical excluded ─────────────────────── - it('AC3: when reviewQueueCount == browseItemCount, non-critical candidates are excluded', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-NONCRIT', priority: 'high' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(20), // at threshold - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - // Non-critical candidate gated → falls through to plan/intake - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - }); - - it('AC3: gate active — only non-critical candidates: implement resolves null, falls through to plan', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-LOW', priority: 'low' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(25), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - // Plan tier should be reached - expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); - }); - - it('AC3: gate boundary — count == browseItemCount - 1: gate NOT active, all priorities dispatch', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-UNDER', priority: 'high' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(19), // one below threshold - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - }); - - it('AC3: gate boundary — count == browseItemCount: gate active, non-critical excluded', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-AT', priority: 'high' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(20), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - // Non-critical gated, no plan/intake → review-queue-hold - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('review-queue-hold'); - }); - - // ── AC4: gate applies ONLY to implement tier ─────────────────────── - it('AC4: audit tier is NOT affected by the review-queue gate', async () => { - const auditCandidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; - const deps = makeDeps({ - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), - getReviewQueueCount: vi.fn().mockResolvedValue(999), // deeply queued - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-GATED', priority: 'high' }), - ), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - // Audit tier still dispatches regardless of review-queue depth - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - }); - - it('AC4: plan tier dispatches even when the review-queue gate is deep', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-GATED', priority: 'high' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(999), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - }); - - // ── AC5: bounded timeout and fail-closed ─────────────────────────── - it('AC5: a getReviewQueueCount error (null) activates the gate — only critical dispatches', async () => { - const deps = makeDeps({ - getReviewQueueCount: vi.fn().mockResolvedValue(null), // gate active on error - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-NONCRIT', priority: 'medium' }), - ), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - // Non-critical gated by error → review-queue-hold (no-candidate not triggered) - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('review-queue-hold'); - }); - - it('AC5: fail-closed — queue error + critical candidate: critical still dispatches', async () => { - const deps = makeDeps({ - getReviewQueueCount: vi.fn().mockResolvedValue(null), // gate active on error - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-CRIT', priority: 'critical' }), - ), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - }); - - // ── AC6: neutral reason (never 'no-candidate') ───────────────────── - it('AC6: gate skip with empty plan AND intake reports review-queue-hold, never no-candidate', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-GATED', priority: 'high' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(25), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), // plan empty - // intake (idea tier) is attempted but the review-queue-hold check short-circuits - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - // Gate skip with empty plan/intake → review-queue-hold, NOT no-candidate - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('review-queue-hold'); - // No cooldown triggered — polling continues - }); - - it('AC6: non-gated empty backlog still reports no-candidate (unchanged)', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - getReviewQueueCount: vi.fn().mockResolvedValue(0), // not gated - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - }); - - // ── Default browseItemCount ──────────────────────────────────────── - it('uses default browseItemCount of 20 when not provided', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-DEFAULT', priority: 'high' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(20), - // No browseItemCount passed — defaults to 20 - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Gate active at default threshold → non-critical excluded - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('review-queue-hold'); - }); - - it('default browseItemCount — count 19 still dispatches non-critical', async () => { - const deps = makeDeps({ - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-DEFAULT', priority: 'high' }), - ), - getReviewQueueCount: vi.fn().mockResolvedValue(19), - // No browseItemCount — defaults to 20 - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - }); - - // ── Root-only counting ───────────────────────────────────────────── - it('root-only: the queue count uses --root-only (child items excluded)', async () => { - // This tests the implementation uses the correct wl query. The - // getReviewQueueCount mock returns whatever the caller passes. - // The real implementation uses --root-only so children are excluded. - const deps = makeDeps({ - getReviewQueueCount: vi.fn().mockResolvedValue(5), - getNextImplementCandidate: vi.fn().mockResolvedValue( - implementCandidate({ id: 'IMP-ROOT', priority: 'high' }), - ), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - // Under threshold → gate not active → implement dispatches - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - }); -}); - -// ── Review-queue depth gate on the LIVE Herdr-head path (WL-0MTTSWC1X005P4VD) ── -// Root cause B2: the original gate lived ONLY in the legacy tier chain, which -// is unreachable while the Herdr head is non-empty (the production state), so -// non-critical implements kept dispatching into a deep review queue. These -// tests pin the gate on the LIVE `dispatchFromHerdrList` path (exercised via -// `dispatchDowntimeWork` with a populated `getHerdrListHead`): audits (the -// queue drain), plan, intake and critical implements flow unconditionally; -// only NON-CRITICAL implement candidates are held; a deep queue with nothing -// audit-needed reports 'review-queue-hold' (never 'no-candidate'). - -describe('review-queue depth gate — live Herdr-head path (WL-0MTTSWC1X005P4VD)', () => { - const now = Date.now(); - const fresh = new Date(now - 60_000).toISOString(); - const auditHead = (id: string): DowntimeHerdrItem => ({ - id, - title: `Audit ${id}`, - status: 'completed', - stage: 'in_review', - updatedAt: fresh, - sortIndex: 10, - }); - const implementHead = (id: string, priority = 'high'): DowntimeHerdrItem => ({ - id, - title: `Implement ${id}`, - status: 'open', - stage: 'plan_complete', - risk: 'Low', - effort: 'S', - priority, - sortIndex: 20, - }); - const planHead = (id: string): DowntimeHerdrItem => ({ - id, - title: `Plan ${id}`, - status: 'open', - stage: 'intake_complete', - sortIndex: 30, - }); - const intakeHead = (id: string): DowntimeHerdrItem => ({ - id, - title: `Intake ${id}`, - status: 'open', - stage: 'idea', - sortIndex: 40, - }); - - it('AC1: with a deep queue an AUDIT candidate still dispatches even behind a held non-critical implement', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1'), auditHead('AUD-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(999), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - expect(outcome.candidate?.id).toBe('AUD-1'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:audit AUD-1'), - expect.anything(), - ); - }); - - it('AC1: an audit-first deep queue dispatches the audit (the gate is implement-only)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [auditHead('AUD-1'), implementHead('IMP-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(999), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('audit'); - }); - - it('AC2: a deep queue HOLDS a non-critical implement — nothing audit-needed left → review-queue-hold (never no-candidate)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(25), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('review-queue-hold'); - expect(outcome.reason).not.toBe('no-candidate'); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('AC2: a CRITICAL implement dispatches even when the queue is deep', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-CRIT', 'critical')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(999), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('IMP-CRIT'); - }); - - it('AC2: PLAN and INTAKE candidates flow while the queue is deep', async () => { - // Plan candidate deeper than a held implement → plan dispatches. - const planDeps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1'), planHead('PLN-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(999), - }); - const planOutcome = await dispatchDowntimeWork(planDeps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(planOutcome.dispatched).toBe(true); - expect(planOutcome.kind).toBe('plan'); - expect(planOutcome.candidate?.id).toBe('PLN-1'); - - // Intake candidate behind a held implement → intake dispatches. - const intakeDeps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1'), intakeHead('IDE-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(999), - }); - const intakeOutcome = await dispatchDowntimeWork(intakeDeps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(intakeOutcome.dispatched).toBe(true); - expect(intakeOutcome.kind).toBe('intake'); - expect(intakeOutcome.candidate?.id).toBe('IDE-1'); - }); - - it('shallow queue: a non-critical implement dispatches unchanged (threshold not met)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(10), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - }); - - it('AC5 boundary: count == browseItemCount HOLDS; count == browseItemCount - 1 dispatches', async () => { - const atThreshold = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(20), - }); - const held = await dispatchDowntimeWork(atThreshold, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(held.dispatched).toBe(false); - expect(held.reason).toBe('review-queue-hold'); - - const underThreshold = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(19), - }); - const dispatched = await dispatchDowntimeWork(underThreshold, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(dispatched.dispatched).toBe(true); - expect(dispatched.kind).toBe('implement'); - }); - - it('AC5 fail-closed: a count-query failure (null) activates the gate on the live path', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(null), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('review-queue-hold'); - }); - - it('the review gate is never consulted while frozen (code-freeze already pauses implements)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1'), planHead('PLN-1')] }), - getReviewQueueCount: vi.fn().mockResolvedValue(25), - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - // Freeze skips the implement; plan still dispatches. The gate read is - // skipped entirely under a freeze (single shared read in the caller). - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - }); -}); - -// ── Worker keeps polling while the review queue is deep (WL-0MTTSWC1X005P4VD) ── -// RCA root cause A regression: queue depth used to WRITE the disable marker -// and early-return BEFORE leader-election/check-in (the 21:53Z silence). The -// sprint-complete auto-disable is removed; a deep queue only ever yields the -// neutral 'review-queue-hold' reason, which never triggers the no-candidate -// cooldown — the worker keeps checking in and keeps polling. - -describe('worker keeps polling while the review queue is deep (WL-0MTTSWC1X005P4VD)', () => { - function makeDeepQueueWorker(overrides: Partial = {}) { - const cfg = { - enabled: true, - thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }; - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - const deps = makeDeps({ - // Deep queue + only a non-critical implement candidate remains. - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [] }), - getReviewQueueCount: vi.fn().mockResolvedValue(25), - getNextImplementCandidate: vi.fn().mockResolvedValue({ - id: 'IMP-HELD', - title: 'Held implement', - stage: 'implement', - status: 'open', - priority: 'high', - }), - getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), // plan+intake empty - ...overrides, - }); - const worker = createDowntimeWorker({ - poller, - deps, - config: () => ({ ...cfg }), - }); - return { worker, deps, cfg }; - } - - it('a deep queue never triggers the no-candidate cooldown (review-queue-hold is neutral)', async () => { - vi.useFakeTimers(); - const start = 1_000_000; - vi.setSystemTime(start); - const { worker, deps } = makeDeepQueueWorker(); - await worker.tick(); // idle run starts - expect(worker.idleSince).toBe(start); - - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); - const at = await worker.tick(); - // Held: no dispatch, but NOT no-candidate → the worker must NOT pause. - expect(at.dispatched).toBe(false); - expect(at.polled).toBe(true); - expect(worker.paused).toBe(false); - expect(worker.errorStrikes).toBe(0); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - // Queue thins below the threshold → dispatch resumes on the very next - // idle tick (no stale cooldown blocking it — the neutral hold never - // reset the idle run, so the warm run is immediately dispatchable). - (deps.getReviewQueueCount as ReturnType).mockResolvedValue(5); - vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - const resumed = await worker.tick(); - expect(resumed.dispatched).toBe(true); - expect(worker.paused).toBe(false); - // The implement pane actually spawned (tick results carry no kind field). - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:implement IMP-HELD'), - expect.anything(), - ); - }); - - it('queue depth alone never short-circuits a tick (no disable marker write)', async () => { - vi.useFakeTimers(); - const start = 2_000_000; - vi.setSystemTime(start); - const { worker } = makeDeepQueueWorker({ getReviewQueueCount: vi.fn().mockResolvedValue(999) }); - // Previously the sprint-complete auto-disable returned BEFORE the poll - // ({polled:false}) when the count crossed browseItemCount. Now the worker - // always polls — the deep queue only gates implement dispatch inside the - // dispatch call (which returns the neutral review-queue-hold). - const result = await worker.tick(); - expect(result.polled).toBe(true); - expect(worker.paused).toBe(false); - }); -}); - -// ── Bounded concurrent dispatch (F1, parent WL-0MT50LKAK001EF5Q) ────── - -describe('pipeline single-flight — one in-flight dispatch pipeline at a time', () => { - it('a second concurrent same-process dispatch is refused (dispatch-in-flight)', async () => { - let release!: () => void; - const gate = new Promise<{ ok: true }>((resolve) => { - release = () => resolve({ ok: true }); - }); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - spawnAgentPane: vi.fn().mockImplementation(() => gate), - }); - - const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - release(); - const [firstOutcome, secondOutcome] = await Promise.all([first, second]); - - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - expect(firstOutcome.dispatched).toBe(true); - expect(secondOutcome.dispatched).toBe(false); - expect(secondOutcome.reason).toBe('dispatch-in-flight'); - }); -}); - -describe('idle gate — no dispatch with zero free slots', () => { - it('second dispatch only fires when requiredFreeSlots continuously idle for threshold', async () => { - // This test verifies the per-slot idle tracker contract. - // With cap=2, we need 2 slots continuously idle for the threshold. - // A second dispatch should NOT fire if only 1 slot is idle. - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - }); - - // With 0 free slots (below panes min of 1), dispatch is gated - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); - - // Dispatch should be gated when no slots are free - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - }); -}); - -describe('idle gate — no dispatch with zero free slots (fast-mode operator priority)', () => { - it('with 3 total slots and 1 free, only 1 dispatch fires even if cap=2', async () => { - // Fast-mode / operator priority preserved: freeSlots check is the hard limit - // With 0 free slots, no dispatch can occur regardless of cap - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); - expect(outcome.dispatched).toBe(false); // no slots free → gated - }); -}); - -describe('bounded concurrent dispatch — claim safety regression', () => { - it('dispatched-marker exclusion still prevents re-dispatch of already-dispatched item', async () => { - // Regression guard for WL-0MSLIY8ZR004QUSY: claim safety via CAS - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Claim safety: the item should be claimed once, not re-claimed - if (outcome.dispatched) { - expect(deps.claimItem).toHaveBeenCalledTimes(1); - } - }); -}); - -// ── Owner lease + contention + pane liveness (parent WL-0MTYZXSLN008HZOW, -// pane bound removed by WL-0MU2EP6JL006A1U3) ────────────────────── -// -// Regression suite for single-slot over-dispatch. The dispatcher must honour -// the Local Proxy owner lease, exclude owned slots from the free count, and -// back off on proxy contention. The RUNNING-PANE BOUND that was added -// alongside these is deliberately GONE (WL-0MU2EP6JL006A1U3): dispatched -// panes stay open until an operator closes them, so a live-pane count can -// never be the concurrency limiter — the LLM idle check is. - -describe('parseLlamaStatus: contention + per-slot owner (AC5/AC6)', () => { - const base = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 1, - total_slots: 1, - }; - - it('parses contention_queue_depth (the live gate signal) when served', () => { - const status = parseLlamaStatus({ ...base, contention_queue_depth: 4 }); - expect(status).not.toBeNull(); - expect(status!.contention_queue_depth).toBe(4); - }); - - it('leaves contention_queue_depth undefined when absent (backward compatible)', () => { - const status = parseLlamaStatus(base); - expect(status).not.toBeNull(); - expect(status!.contention_queue_depth).toBeUndefined(); - }); - - it('treats a negative or non-finite contention_queue_depth as ambiguous → busy', () => { - expect(parseLlamaStatus({ ...base, contention_queue_depth: -1 })).toBeNull(); - expect(parseLlamaStatus({ ...base, contention_queue_depth: Number.NaN })).toBeNull(); - expect(parseLlamaStatus({ ...base, contention_queue_depth: 'many' })).toBeNull(); - }); - - it('parses the cumulative contention_queued_count as telemetry (not a gate)', () => { - const status = parseLlamaStatus({ ...base, contention_queued_count: 13 }); - expect(status).not.toBeNull(); - expect(status!.contention_queued_count).toBe(13); - }); - - it('leaves contention_queued_count undefined when absent (backward compatible)', () => { - const status = parseLlamaStatus(base); - expect(status).not.toBeNull(); - expect(status!.contention_queued_count).toBeUndefined(); - }); - - it('treats a negative or non-finite contention_queued_count as ambiguous → busy', () => { - expect(parseLlamaStatus({ ...base, contention_queued_count: -1 })).toBeNull(); - expect(parseLlamaStatus({ ...base, contention_queued_count: Number.NaN })).toBeNull(); - expect(parseLlamaStatus({ ...base, contention_queued_count: 'many' })).toBeNull(); - }); - - it('keeps the live depth distinct from the cumulative count', () => { - const status = parseLlamaStatus({ - ...base, - contention_queue_depth: 0, - contention_queued_count: 13, - }); - expect(status).not.toBeNull(); - expect(status!.contention_queue_depth).toBe(0); - expect(status!.contention_queued_count).toBe(13); - }); - - it('parses a per-slot owner_session_id', () => { - const status = parseLlamaStatus({ - ...base, - slots: [{ slot_id: 'slot-1', is_processing: false, owner_session_id: 'sess-1' }], - }); - expect(status).not.toBeNull(); - expect(status!.slots![0].owner_session_id).toBe('sess-1'); - }); - - it('omits owner_session_id from the slot shape when absent (backward compatible)', () => { - const status = parseLlamaStatus({ - ...base, - slots: [{ slot_id: 'slot-1', is_processing: false }], - }); - expect(status).not.toBeNull(); - expect(status!.slots![0]).toEqual({ slot_id: 'slot-1', is_processing: false }); - }); -}); - -describe('slot ownership helpers (AC5)', () => { - it('isSlotOwned is true only for a non-empty owner_session_id', () => { - expect(isSlotOwned({ slot_id: 's', is_processing: false, owner_session_id: 'abc' })).toBe(true); - expect(isSlotOwned({ slot_id: 's', is_processing: false, owner_session_id: null })).toBe(false); - expect(isSlotOwned({ slot_id: 's', is_processing: false })).toBe(false); - expect(isSlotOwned({ slot_id: 's', is_processing: false, owner_session_id: '' })).toBe(false); - }); - - it('countFreeUnownedSlots excludes processing AND owned slots', () => { - expect( - countFreeUnownedSlots([ - { slot_id: 'a', is_processing: false }, - { slot_id: 'b', is_processing: true }, - { slot_id: 'c', is_processing: false, owner_session_id: 'live' }, - { slot_id: 'd', is_processing: false }, - ]), - ).toBe(2); - }); - - it('countFreeUnownedSlots is fail-closed for a missing is_processing', () => { - expect( - countFreeUnownedSlots([{ slot_id: 'a' } as unknown as LlamaSlot]), - ).toBe(0); - }); - - it('countFreeUnownedSlots is 0 when every slot is owned (AC3 all-owned condition)', () => { - // The literal all-owned case for the per-slot owner-lease gate - // (WL-0MU8807BI008C9ME AC3): every slot carries a live owner lease, so - // the per-slot free-unowned count — the gate signal in per-slot mode — - // is exactly zero. A slot that is idle by `is_processing` but owned is - // still NOT free. - expect( - countFreeUnownedSlots([ - { slot_id: 'a', is_processing: true, owner_session_id: 'owner-a' }, - { slot_id: 'b', is_processing: true, owner_session_id: 'owner-b' }, - { slot_id: 'c', is_processing: false, owner_session_id: 'owner-c' }, - ]), - ).toBe(0); - }); -}); - -describe('evaluateIdle: idle-but-owned slot is not dispatchable (AC3/AC5)', () => { - it('per-slot mode treats an owned free slot as busy', () => { - // 3 slots reported: slot-1 owned (idle), slot-2/slot-3 unowned-free → - // only 2 genuinely free, so N=2 is idle but N=3 would exceed the total. - expect(evaluateIdle(perSlotIdleOwned, 2)).toBe(true); - // Only 1 unowned slot free (slot-1 owned, slot-2 processing) → N=2 busy. - expect(evaluateIdle(perSlotOwnedOneUnowned, 2)).toBe(false); - }); - - it('a single idle-but-owned slot reports busy (count-based path)', () => { - // The global owner lease derives local_lease_active → the count-based - // path fails closed. - expect(evaluateIdle(singleSlotIdleOwned, 0)).toBe(false); - expect(evaluateIdle(singleSlotIdleOwned, 1)).toBe(false); - }); -}); - -describe('createPerSlotIdleTracker: owned slot timer resets (AC5)', () => { - it('an owned slot never accumulates idle time', () => { - const tracker = createPerSlotIdleTracker(); - const start = 1_000_000; - tracker.record( - [ - { slot_id: 'slot-1', is_processing: false, owner_session_id: 'live' }, - { slot_id: 'slot-2', is_processing: false }, - ], - start, - ); - expect(tracker.thresholdMetCount(1_000, start + 5_000)).toBe(1); - }); -}); - -describe('parseHerdrPaneListOutput / countRunningDowntimePanes (AC1)', () => { - it('parses the herdr pane list envelope', () => { - const panes = parseHerdrPaneListOutput(herdrPaneListRaw); - expect(panes).not.toBeNull(); - expect(panes!.length).toBe(5); - expect(panes![0].paneId).toBe('w1:p1'); - expect(panes![0].label).toContain('Downtime triggered'); - }); - - it('counts only live downtime panes (excludes manual panes and done agents)', () => { - const panes = parseHerdrPaneListOutput(herdrPaneListRaw)!; - const running = countRunningDowntimePanes(panes); - expect(running).toEqual(['w1:p1', 'w1:p2']); - expect(DOWNTIME_PANE_LABEL_PREFIX).toBe('Downtime'); - }); - - it('returns null for unparseable output (an unparseable probe is no longer a dispatch gate)', () => { - expect(parseHerdrPaneListOutput('not json at all')).toBeNull(); - }); - - it('tolerates log lines before the JSON envelope', () => { - const panes = parseHerdrPaneListOutput(`[herdr] starting\n${herdrPaneListRaw}`); - expect(panes).not.toBeNull(); - expect(countRunningDowntimePanes(panes!)).toHaveLength(2); - }); - - it('exposes cwd and agent_session on parsed records (WL-0MUYMBMCG000Z4WP AC1)', () => { - const raw = JSON.stringify({ - result: { - panes: [ - { - pane_id: 'w2Y:p87', - label: 'Manually triggered intake - WL-ABC', - agent: 'pi', - agent_status: 'working', - cwd: '/home/u/projects/OtherRoot', - agent_session: { kind: 'path', value: '/tmp/s.jsonl' }, - }, - ], - }, - }); - const panes = parseHerdrPaneListOutput(raw)!; - expect(panes[0].cwd).toBe('/home/u/projects/OtherRoot'); - expect(panes[0].agentSession).toEqual({ kind: 'path', value: '/tmp/s.jsonl' }); - }); - - it('omits cwd / agent_session on missing or malformed fields, without throwing (WL-0MUYMBMCG000Z4WP AC3)', () => { - const raw = JSON.stringify({ - panes: [ - { pane_id: 'p1', cwd: 7, agent_session: ['x'] }, - { pane_id: 'p2' }, - ], - }); - const panes = parseHerdrPaneListOutput(raw)!; - expect(panes).toHaveLength(2); - expect(panes[0].cwd).toBeUndefined(); - expect(panes[0].agentSession).toBeUndefined(); - expect(panes[1].cwd).toBeUndefined(); - expect(panes[1].agentSession).toBeUndefined(); - }); - - it('shares one parse contract: bare arrays and bracketed log prefixes parse (WL-0MUYMBMCG000Z4WP AC3)', () => { - const bare = JSON.stringify([{ pane_id: 'p1', cwd: '/r' }]); - expect(parseHerdrPaneListOutput(bare)![0].cwd).toBe('/r'); - const prefixed = '[herdr] starting\n' + JSON.stringify({ panes: [{ pane_id: 'p1' }] }); - expect(parseHerdrPaneListOutput(prefixed)![0].paneId).toBe('p1'); - }); -}); - -// ── Item-scoped in-flight guard helpers (F3 WL-0MUBVKXQJ000L8EO) ──────── -// The guard must be ITEM-SCOPED (never a global running-pane count, which -// would reintroduce the WL-0MU2EP6JL006A1U3 stall) and must only treat a -// `working` agent as in-flight (terminal/idle panes must not deadlock the -// item's next tier). - -describe('paneLabelItemId / runningDowntimePaneItemIds / evaluateCriticalFirstGuard (WL-0MUBEZ6PE002WLP4)', () => { - const pane = (overrides: Partial & { paneId: string }): HerdrPaneRecord => ({ - label: 'Downtime triggered implement Some item - WL-ABC', - agent: 'pi', - agentStatus: 'working', - ...overrides, - }); - - it('paneLabelItemId extracts the trailing work-item id (buildDowntimePaneTitle suffix contract)', () => { - expect(paneLabelItemId('Downtime triggered implement Some item - WL-ABC')).toBe('WL-ABC'); - // A title containing ` - ` earlier still yields the LAST segment. - expect(paneLabelItemId('Downtime triggered plan A - B - WL-XYZ')).toBe('WL-XYZ'); - // No suffix → null (manual pane). - expect(paneLabelItemId('Downtime intake')).toBeNull(); - expect(paneLabelItemId(undefined)).toBeNull(); - }); - - it('isSafeDowntimePaneToClose only accepts the downtime pane for the SAME item (AC7)', () => { - const live = [ - pane({ paneId: 'w1:p1', label: 'Downtime triggered implement Some item - WL-ABC' }), - pane({ paneId: 'w1:p2', label: 'pi - my operator session' }), - ]; - // Matching downtime pane for the item → safe. - expect(isSafeDowntimePaneToClose(live, 'w1:p1', 'WL-ABC')).toBe(true); - // A different item's downtime pane (id collision) → NOT safe. - expect(isSafeDowntimePaneToClose(live, 'w1:p1', 'WL-OTHER')).toBe(false); - // A non-downtime (operator) pane → NOT safe. - expect(isSafeDowntimePaneToClose(live, 'w1:p2', 'WL-ABC')).toBe(false); - // The pane is already gone → NOT safe (nothing to close). - expect(isSafeDowntimePaneToClose(live, 'w1:p9', 'WL-ABC')).toBe(false); - }); - - it('runningDowntimePaneItemIds returns only WORKING downtime panes, keyed by item id', () => { - const ids = runningDowntimePaneItemIds([ - pane({ paneId: 'w1:p1', label: 'Downtime triggered implement A - WL-A', agentStatus: 'working' }), - // terminal → excluded - pane({ paneId: 'w1:p2', label: 'Downtime triggered implement B - WL-B', agentStatus: 'done' }), - pane({ paneId: 'w1:p3', label: 'Downtime triggered implement C - WL-C', agentStatus: 'exited' }), - // idle/unknown → NOT working → excluded (no deadlock) - pane({ paneId: 'w1:p4', label: 'Downtime triggered implement D - WL-D', agentStatus: 'idle' }), - // manual pane (no downtime prefix) → excluded - pane({ paneId: 'w1:p5', label: 'Work Items', agentStatus: 'working' }), - // downtime pane with no agent → excluded - pane({ paneId: 'w1:p6', label: 'Downtime plan E - WL-E', agent: undefined, agentStatus: 'working' }), - ]); - expect([...ids]).toEqual(['WL-A']); - }); - - it('evaluateCriticalFirstGuard: live working pane → skip (in-flight-pane), regardless of marker freshness', () => { - const inFlight: InFlightPanes = { available: true, itemIds: new Set(['WL-ABC']) }; - const d = evaluateCriticalFirstGuard('WL-ABC', inFlight, new Date().toISOString(), Date.now(), 3_600_000); - expect(d).toEqual({ escalate: false, reason: 'in-flight-pane' }); - }); - - it('evaluateCriticalFirstGuard: query succeeded and found no pane → escalate (no-live-pane), even with a fresh marker', () => { - const inFlight: InFlightPanes = { available: true, itemIds: new Set() }; - const d = evaluateCriticalFirstGuard('WL-ABC', inFlight, new Date().toISOString(), Date.now(), 3_600_000); - expect(d).toEqual({ escalate: true, reason: 'no-live-pane' }); - }); - - it('evaluateCriticalFirstGuard: query unavailable + fresh marker → skip (in-flight-unverified)', () => { - const inFlight: InFlightPanes = { available: false, itemIds: new Set() }; - const d = evaluateCriticalFirstGuard('WL-ABC', inFlight, new Date().toISOString(), Date.now(), 3_600_000); - expect(d).toEqual({ escalate: false, reason: 'in-flight-unverified' }); - }); - - it('evaluateCriticalFirstGuard: query unavailable + stale/absent marker → escalate (bounded, never starved)', () => { - const inFlight: InFlightPanes = { available: false, itemIds: new Set() }; - const now = Date.now(); - expect(evaluateCriticalFirstGuard('WL-ABC', inFlight, new Date(now - 2 * 3_600_000).toISOString(), now, 3_600_000)) - .toEqual({ escalate: true, reason: 'marker-stale-escalation' }); - expect(evaluateCriticalFirstGuard('WL-ABC', inFlight, undefined, now, 3_600_000)) - .toEqual({ escalate: true, reason: 'marker-stale-escalation' }); - }); - - it('resolveInFlightPanes: unavailable dep / thrown resolver / no records all resolve unavailable (never throws)', async () => { - expect(await resolveInFlightPanes({}, '/repo')).toEqual({ available: false, itemIds: new Set() }); - expect(await resolveInFlightPanes( - { getRunningDowntimePanes: vi.fn().mockRejectedValue(new Error('boom')) }, - '/repo', - )).toEqual({ available: false, itemIds: new Set() }); - expect(await resolveInFlightPanes( - { getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: false, error: 'down' }) }, - '/repo', - )).toEqual({ available: false, itemIds: new Set() }); - // Legacy result with only count/paneIds → unavailable (a bare count must - // never be used as an item-scoped signal). - expect(await resolveInFlightPanes( - { getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 2, paneIds: ['a', 'b'] }) }, - '/repo', - )).toEqual({ available: false, itemIds: new Set() }); - }); - - it('resolveInFlightPanes: an available query with records resolves the working item ids', async () => { - const result = await resolveInFlightPanes( - { - getRunningDowntimePanes: vi.fn().mockResolvedValue({ - ok: true, count: 1, paneIds: ['w1:p1'], - records: [pane({ paneId: 'w1:p1', label: 'Downtime triggered implement A - WL-A', agentStatus: 'working' })], - }), - }, - '/repo', - ); - expect(result.available).toBe(true); - expect([...result.itemIds]).toEqual(['WL-A']); - }); -}); - -describe('dispatchDowntimeWork: running-pane / owner / contention gates', () => { - function dispatchableDeps() { - return makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - }); - } - - it('dispatches without consulting any live-pane count (WL-0MU2EP6JL006A1U3)', async () => { - // Regression: panes deliberately stay open until an operator closes them, - // so the number of live panes must never gate dispatch. - const deps = dispatchableDeps(); - const outcome = await dispatchDowntimeWork(deps, { - model: 'plan', - cwd: '/repo', - }); - expect(outcome.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('refuses an owned slot (slot-owned)', async () => { - const deps = dispatchableDeps(); - const outcome = await dispatchDowntimeWork(deps, { - model: 'plan', - cwd: '/repo', - slotOwned: true, - }); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('slot-owned'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('backs off while the proxy reports contention (proxy-contention)', async () => { - const deps = dispatchableDeps(); - const outcome = await dispatchDowntimeWork(deps, { - model: 'plan', - cwd: '/repo', - contentionQueueDepth: 3, - }); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('proxy-contention'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - -}); - -describe('worker tick: the LLM idle check is the sole concurrency limiter (WL-0MU2EP6JL006A1U3)', () => { - function makeSingleSlotWorker(opts: { - status: LlamaStatus; - runningPanes: () => { ok: true; count: number } | { ok: false; error?: string }; - requiredFreeSlots?: number; - }) { - const cfg = { - enabled: true, - thresholdMs: 1_000, - requiredFreeSlots: opts.requiredFreeSlots ?? 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }; - const poller = createDowntimePoller( - 'http://proxy:8000', - vi.fn().mockResolvedValue(jsonResponseFixture(opts.status)), - ); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - getRunningDowntimePanes: vi.fn().mockImplementation(async () => opts.runningPanes()), - }); - const worker = createDowntimeWorker({ poller, deps, config: () => ({ ...cfg }) }); - return { worker, deps }; - } - - it('live dispatch panes do not block dispatch while the LLM is idle (WL-0MU2EP6JL006A1U3)', async () => { - vi.useFakeTimers(); - try { - // Regression for the overnight stall: previously dispatched panes stay - // OPEN until an operator closes them, so a live-pane count must never be - // a dispatch gate. The LLM is idle and several panes are open → the next - // item still dispatches. - const { worker, deps } = makeSingleSlotWorker({ - status: idleAllSlotsFree, - runningPanes: () => ({ ok: true, count: 3 }), - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const outcome = await worker.tick(); - expect(outcome.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('a failed liveness query does not stall dispatch (WL-0MU2EP6JL006A1U3)', async () => { - vi.useFakeTimers(); - try { - // The pane-liveness probe now only feeds the owner-lease qualifier, so a - // `herdr pane list` failure must not silently stop the dispatcher. The - // proxy's own lease signal still gates slot capacity. - const { worker, deps } = makeSingleSlotWorker({ - status: idleAllSlotsFree, - runningPanes: () => ({ ok: false, error: 'herdr down' }), - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const outcome = await worker.tick(); - expect(outcome.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('an idle-but-owned slot prevents dispatch (AC2/AC3)', async () => { - vi.useFakeTimers(); - try { - // Single slot, idle by is_processing, but a live lease is held AND the - // slot is owned by a live pane → the owner gate refuses the dispatch. - const { worker, deps } = makeSingleSlotWorker({ - status: { - ...idleAllSlotsFree, - available_slots: 1, - total_slots: 1, - local_lease_active: true, - local_owner_session_id: 'session-live', - local_owner_lease_remaining_seconds: 120, - slots: [ - { slot_id: 'slot-1', is_processing: false, owner_session_id: 'session-live' }, - ], - }, - runningPanes: () => ({ ok: true, count: 1 }), - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const blocked = await worker.tick(); - expect(blocked.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('backs off while the proxy reports contention (AC6)', async () => { - vi.useFakeTimers(); - try { - const { worker, deps } = makeSingleSlotWorker({ - status: idleWithContention, - runningPanes: () => ({ ok: true, count: 0 }), - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const blocked = await worker.tick(); - expect(blocked.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('dispatches when only the cumulative contention counter is non-zero (depth 0, WL-0MU1DWXO600153OI)', async () => { - vi.useFakeTimers(); - try { - // Regression: `contention_queued_count` is cumulative and stays > 0 - // after any past queue event; it must never gate dispatch. With the - // live depth at 0 the worker dispatches. - const { worker, deps } = makeSingleSlotWorker({ - status: idleWithCumulativeContention, - runningPanes: () => ({ ok: true, count: 0 }), - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const result = await worker.tick(); - expect(result.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('does not block operator spare-capacity dispatch when no dispatch pane is running', async () => { - vi.useFakeTimers(); - try { - // Operator holds a lease on a 3-slot setup (one slot processing) while - // the worker has NO running pane → spare-capacity dispatch still fires - // into the free slots. - const { worker, deps } = makeSingleSlotWorker({ - status: perSlotThreeOfFourFree, - runningPanes: () => ({ ok: true, count: 0 }), - requiredFreeSlots: 2, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const result = await worker.tick(); - expect(result.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - // ── onProxyIdle callback (WL-0MU4MKVR4005WPBJ) ──────────────────────── - - describe('onProxyIdle callback', () => { - it('calls the callback with the proxy status when the proxy is idle', async () => { - vi.useFakeTimers(); - try { - const callback = vi.fn().mockResolvedValue(undefined); - const poller = createDowntimePoller( - 'http://proxy:8000', - async () => ({ - ok: true, - status: 200, - json: async () => idleAllSlotsFree, - }), - ); - const worker = createDowntimeWorker({ - poller, - deps: makeDeps(), - config: () => ({ - enabled: true, - thresholdMs: 0, // instant dispatch - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - browseItemCount: 20, - }), - onProxyIdle: callback, - }); - vi.setSystemTime(1_000_000); - const result = await worker.tick(); - expect(result.idle).toBe(true); - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(idleAllSlotsFree); - } finally { - vi.useRealTimers(); - } - }); - - it('does not call the callback when the proxy is busy', async () => { - vi.useFakeTimers(); - try { - const callback = vi.fn().mockResolvedValue(undefined); - const poller = createDowntimePoller( - 'http://proxy:8000', - async () => ({ - ok: true, - status: 200, - json: async () => busyActiveQuery, - }), - ); - const worker = createDowntimeWorker({ - poller, - deps: makeDeps(), - config: () => ({ - enabled: true, - thresholdMs: 0, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - browseItemCount: 20, - }), - onProxyIdle: callback, - }); - vi.setSystemTime(1_000_000); - const result = await worker.tick(); - expect(result.dispatched).toBe(false); - expect(result.idle).toBe(false); - expect(callback).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('calls the callback when the proxy is idle but threshold not met', async () => { - vi.useFakeTimers(); - try { - const callback = vi.fn().mockResolvedValue(undefined); - const poller = createDowntimePoller( - 'http://proxy:8000', - async () => ({ - ok: true, - status: 200, - json: async () => idleAllSlotsFree, - }), - ); - const worker = createDowntimeWorker({ - poller, - deps: makeDeps(), - config: () => ({ - enabled: true, - thresholdMs: 60_000, // 60 s threshold — not yet met - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - browseItemCount: 20, - }), - onProxyIdle: callback, - }); - vi.setSystemTime(1_000_000); - const result = await worker.tick(); - expect(result.dispatched).toBe(false); - expect(result.idle).toBe(true); // proxy is idle, just threshold not met - expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(idleAllSlotsFree); - } finally { - vi.useRealTimers(); - } - }); - - it('fires the callback BEFORE dispatching the next item (ordering)', async () => { - vi.useFakeTimers(); - try { - // The operator requirement (WL-0MU4MKVR4005WPBJ): the mode-switch - // check must run before a new item is sent, so the item is served by - // the (possibly newly) cheap pool. Record the invocation order. - const order: string[] = []; - const callback = vi.fn().mockImplementation(async () => { - order.push('onProxyIdle'); - }); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - spawnAgentPane: vi.fn().mockImplementation(async () => { - order.push('spawn'); - return { ok: true }; - }), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 0 }), - }); - const poller = createDowntimePoller( - 'http://proxy:8000', - vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)), - ); - const worker = createDowntimeWorker({ - poller, - deps, - config: () => ({ - enabled: true, - thresholdMs: 1_000, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - browseItemCount: 20, - }), - onProxyIdle: callback, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // starts the idle run - vi.setSystemTime(start + 5_000); - const outcome = await worker.tick(); // idle + ready → dispatch - expect(outcome.dispatched).toBe(true); - // The callback fires on EVERY idle poll (the mode-switch worker has - // its own operator-idle window), so both ticks trigger it. - expect(callback).toHaveBeenCalledTimes(2); - // The check fires before the item is dispatched. - expect(order).toEqual(['onProxyIdle', 'onProxyIdle', 'spawn']); - } finally { - vi.useRealTimers(); - } - }); - - it('does not crash when the callback throws', async () => { - vi.useFakeTimers(); - try { - const callback = vi.fn().mockRejectedValue(new Error('callback failed')); - const poller = createDowntimePoller( - 'http://proxy:8000', - async () => ({ - ok: true, - status: 200, - json: async () => idleAllSlotsFree, - }), - ); - const worker = createDowntimeWorker({ - poller, - deps: makeDeps(), - config: () => ({ - enabled: true, - thresholdMs: 0, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - browseItemCount: 20, - }), - onProxyIdle: callback, - }); - vi.setSystemTime(1_000_000); - const result = await worker.tick(); - // Callback rejected but the worker did not crash or block - expect(result.idle).toBe(true); - expect(callback).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - }); -}); - -// ── Critical-first scan on Herdr-head path (WL-0MU6UL3XY001M3VT) ────── -// Regression tests for the critical-first scan that bypasses non-safety -// filters (dispatched-marker, review-queue gate) for critical items on -// the Herdr-head dispatch path. - -describe('critical-first scan on Herdr-head path (WL-0MU6UL3XY001M3VT)', () => { - const now = Date.now(); - const fresh = new Date(now - 60_000).toISOString(); - - const criticalPlanned = (id: string): DowntimeHerdrItem => ({ - id, title: `Critical plan ${id}`, status: 'open', stage: 'plan_complete', - risk: 'Low', effort: 'S', priority: 'critical', - sortIndex: 10, - }); - const criticalIdea = (id: string): DowntimeHerdrItem => ({ - id, title: `Critical idea ${id}`, status: 'open', stage: 'idea', - priority: 'critical', - sortIndex: 20, - }); - const criticalIntake = (id: string): DowntimeHerdrItem => ({ - id, title: `Critical intake ${id}`, status: 'open', stage: 'intake_complete', - priority: 'critical', - sortIndex: 30, - }); - const nonCriticalImplement = (id: string): DowntimeHerdrItem => ({ - id, title: `Implement ${id}`, status: 'open', stage: 'plan_complete', - risk: 'Low', effort: 'S', priority: 'medium', - sortIndex: 100, - }); - - describe('AC4(a) — critical item bypasses non-safety filters', () => { - it('a critical item with a stale dispatched marker dispatches ahead of a non-critical item', async () => { - // End-to-end incident simulation: a critical plan item carries a - // STANDING implement dispatch marker in the rolling log (its - // implementation aborted → status reset to open, marker never - // cleared). The normal loop would skip it forever; the critical-first - // scan must bypass the marker and escalate it ahead of the - // non-critical item. - const root = mkdtempSync(join(tmpdir(), 'herdr-crit-scan-marker-')); - mkdirSync(join(root, '.worklog'), { recursive: true }); - writeFileSync( - join(root, '.worklog', 'downtime-dispatches.log'), - JSON.stringify({ - at: new Date(now - 3_600_000).toISOString(), - cwd: root, - kind: 'implement', - itemId: 'CR-1', - stage: 'plan_complete', - dispatchedAt: new Date(now - 3_600_000).toISOString(), - message: 'Dispatched CR-1', - }) + '\n', - ); - try { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - criticalPlanned('CR-1'), - nonCriticalImplement('IMP-1'), - ]}), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('CR-1'); - expect(deps.claimItem).toHaveBeenCalledWith( - 'CR-1', { status: 'open', stage: 'plan_complete' }, root, - ); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - it('critical item dispatches via risk-effort tier when stage is in_progress', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - { - id: 'CR-2', title: 'Critical retired stage', status: 'open', - stage: 'in_progress', priority: 'critical', - risk: 'Low', effort: 'S', sortIndex: 10, - }, - nonCriticalImplement('IMP-1'), - ]}), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('risk-effort'); - expect(outcome.candidate?.id).toBe('CR-2'); - }); - - it('a critical plan item dispatches via the plan tier (intake_complete stage)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - criticalIntake('CR-PLAN'), - nonCriticalImplement('IMP-1'), - ]}), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('CR-PLAN'); - }); - - it('a critical idea item dispatches via the intake tier', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - criticalIdea('CR-INTAKE'), - nonCriticalImplement('IMP-1'), - ]}), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('intake'); - expect(outcome.candidate?.id).toBe('CR-INTAKE'); - }); - }); - - describe('AC4(b) — safety gates block critical escalation', () => { - it('needsProducerReview === true blocks a critical item from critical-first scan', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - { - ...criticalPlanned('CR-PR'), - needsProducerReview: true, - }, - nonCriticalImplement('IMP-1'), - ]}), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - // Safety gate blocks critical → falls through to non-critical. - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('IMP-1'); - }); - - it('code-freeze blocks critical implement but allows critical plan/intake', async () => { - const freezeDeps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - criticalPlanned('CR-FREEZE-IMP'), - criticalIntake('CR-FREEZE-PLAN'), - nonCriticalImplement('IMP-1'), - ]}), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(freezeDeps, { model: 'plan', cwd: '/repo' }); - - // Critical implement is freeze-blocked; critical plan dispatches. - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('CR-FREEZE-PLAN'); - }); - - it('active-audit single-flight is audit-tier scoped: an in-flight audit does not block open critical escalation', async () => { - // The active-audit single-flight gate filters AUDIT-kind candidates - // (a second audit pane must not start). An OPEN critical item is never - // audit-kind, so — matching the legacy critical-tier ordering — it is - // escalated even while an audit is in flight. - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - criticalPlanned('CR-AUDIT'), - nonCriticalImplement('IMP-1'), - ]}), - getActiveAudit: vi.fn().mockResolvedValue({ ok: true, active: true }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('CR-AUDIT'); - }); - }); - - describe('lowest sortIndex wins when multiple critical items exist', () => { - it('dispatches the lowest-sortIndex critical item first', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - criticalPlanned('CR-2'), // sortIndex: 10 - criticalIdea('CR-1'), // sortIndex: 20 - nonCriticalImplement('IMP-1'), - ]}), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - // CR-2 has lower sortIndex (10) than CR-1 (20). - expect(outcome.candidate?.id).toBe('CR-2'); - }); - }); - - describe('no critical items — normal processing unchanged', () => { - it('non-critical items dispatch as usual when no critical items exist', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [ - nonCriticalImplement('IMP-1'), - ]}), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('IMP-1'); - }); - }); -}); - -// ── Herdr-head dispatch threads needsProducerReview (WL-0MU72WJ8C0005GIE) ── -// -// The live Herdr-head dispatch path (`dispatchFromHerdrList` → normal scan) -// builds its `classifyItemForDispatch` input from the Herdr item fields. Before -// the fix (022f1b24) it OMITTED `needsProducerReview`, so a non-critical item -// flagged for producer review was dispatched anyway — even though the offer -// path (`computeMostImportantItem`, which passes the item object directly) -// already honoured the flag. These tests pin AC1–AC3: the flag blocks a -// non-critical plan_complete / idea candidate, the direct path agrees with the -// offer path, and clearing the flag restores dispatchability. - -describe('Herdr-head dispatch threads needsProducerReview (WL-0MU72WJ8C0005GIE)', () => { - const implementItem = (id: string, overrides: Partial = {}): DowntimeHerdrItem => ({ - id, - title: `Non-critical ${id}`, - status: 'open', - stage: 'plan_complete', - priority: 'medium', - risk: 'Low', - effort: 'S', - sortIndex: 10, - ...overrides, - }); - - const intakeItem = (id: string, overrides: Partial = {}): DowntimeHerdrItem => ({ - id, - title: `Idea ${id}`, - status: 'open', - stage: 'idea', - priority: 'medium', - sortIndex: 10, - ...overrides, - }); - - const dispatchDeps = (items: DowntimeHerdrItem[]): DowntimeWorkerDeps => makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - describe('AC2 — the flag blocks a non-critical candidate on dispatchDowntimeWork', () => { - it('blocks a plan_complete item (implement) and dispatches the next eligible item', async () => { - const deps = dispatchDeps([ - implementItem('NPR-GATED', { needsProducerReview: true, sortIndex: 1 }), - implementItem('NPR-ALLOWED', { needsProducerReview: false, sortIndex: 2 }), - ]); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('NPR-ALLOWED'); - // The gated item was never claimed. - expect(deps.claimItem).not.toHaveBeenCalledWith('NPR-GATED', expect.any(Object), '/repo'); - }); - - it('blocks an idea item (intake) and dispatches the next eligible item', async () => { - const deps = dispatchDeps([ - intakeItem('NPR-IDEA-GATED', { needsProducerReview: true, sortIndex: 1 }), - intakeItem('NPR-IDEA-ALLOWED', { needsProducerReview: false, sortIndex: 2 }), - ]); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('intake'); - expect(outcome.candidate?.id).toBe('NPR-IDEA-ALLOWED'); - expect(deps.claimItem).not.toHaveBeenCalledWith('NPR-IDEA-GATED', expect.any(Object), '/repo'); - }); - - it('dispatches nothing when every candidate is review-gated', async () => { - const deps = dispatchDeps([ - implementItem('NPR-ONLY', { needsProducerReview: true }), - ]); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('clearing the flag restores dispatchability (same item shape)', async () => { - const gated = implementItem('NPR-CLEAR', { needsProducerReview: true }); - const blocked = await dispatchDowntimeWork(dispatchDeps([gated]), { model: 'plan', cwd: '/repo' }); - expect(blocked.dispatched).toBe(false); - - gated.needsProducerReview = false; - const released = await dispatchDowntimeWork(dispatchDeps([gated]), { model: 'plan', cwd: '/repo' }); - expect(released.dispatched).toBe(true); - expect(released.candidate?.id).toBe('NPR-CLEAR'); - }); - }); - - describe('AC3 — direct and offer paths agree on the producer-review gate', () => { - it('both the direct dispatch and the offer skip the gated item and pick the same next item', async () => { - const items = [ - implementItem('NPR-PARITY-GATED', { needsProducerReview: true, sortIndex: 1 }), - implementItem('NPR-PARITY-ALLOWED', { needsProducerReview: false, sortIndex: 2 }), - ]; - - const direct = await dispatchDowntimeWork(dispatchDeps(items), { model: 'plan', cwd: '/repo' }); - const offer = await computeMostImportantItem(dispatchDeps(items), '/repo'); - - expect(direct.dispatched).toBe(true); - expect(direct.candidate?.id).toBe('NPR-PARITY-ALLOWED'); - expect(offer).toMatchObject({ ok: true, candidate: { id: 'NPR-PARITY-ALLOWED' } }); - }); - - it('the offer reports no candidate when every candidate is review-gated', async () => { - const offer = await computeMostImportantItem( - dispatchDeps([implementItem('NPR-OFFER-ONLY', { needsProducerReview: true })]), - '/repo', - ); - - expect(offer).toMatchObject({ ok: true, noCandidate: true }); - }); - }); -}); - -// ── RCA: duplicate re-dispatch of a critical in-flight item ───────────── -// Parent WL-0MUBEZ6PE002WLP4 / F1 WL-0MUBVKPLP006RRDJ (AC1.1). -// -// RED WITNESS (pre-fix). The critical-first scan -// (`dispatchFromHerdrList` → `selectCriticalFirstCandidates`) bypasses the -// dispatched-marker exclusion UNCONDITIONALLY, so the only thing preventing a -// second dispatch is the CAS claim inside `dispatchClaimedTier`. When the -// item's status has reverted to `open` at its marker's stage (H5 — e.g. an -// aborted `implement.py start` reset it, or `wl reviewed true` released -// it) while the first dispatch's pane is still live, the CAS succeeds and a -// SECOND pane is spawned for the same in-flight item. H1 (marker bypass) -// therefore cannot duplicate on its own; H1+H5 is the confirmed pair. -// -// The item is modelled as `open` at `plan_complete` on BOTH cycles (the -// status revert) with a live `working` downtime pane whose label suffix is -// the item id (the in-flight signal F3 consumes) and a fresh marker in the -// rolling log (written by cycle 1's dispatch). The claim is stubbed to -// succeed on both cycles, matching the genuine DB `open` state. -// -// `it.fails` keeps the suite green while the defect is open: the test body -// asserts the FIXED behaviour and therefore fails pre-fix. F3 -// (WL-0MUBVKXQJ000L8EO) flips it to `it` once the item-scoped in-flight -// guard lands; the RED→GREEN transition is the AC5.5 dependency evidence. -describe('RCA: critical in-flight re-dispatch with a live pane (WL-0MUBEZ6PE002WLP4 / AC1.1)', () => { - const criticalInFlight = (id: string): DowntimeHerdrItem => ({ - id, - title: `Critical in-flight ${id}`, - status: 'open', - stage: 'plan_complete', - risk: 'Low', - effort: 'S', - priority: 'critical', - sortIndex: 10, - }); - - it( - 'two idle cycles over a critical item open at its marker stage with a live pane dispatch exactly once (RED pre-fix)', - async () => { - const root = mkdtempSync(join(tmpdir(), 'herdr-crit-inflight-')); - mkdirSync(join(root, '.worklog'), { recursive: true }); - // A fresh implement marker written by cycle 1's dispatch — the item is - // STILL at the marker's dispatched-at stage (`plan_complete`) on cycle 2. - writeFileSync( - join(root, '.worklog', 'downtime-dispatches.log'), - JSON.stringify({ - at: new Date().toISOString(), - cwd: root, - kind: 'implement', - itemId: 'CR-DUP', - stage: 'plan_complete', - dispatchedAt: new Date().toISOString(), - message: 'Dispatched CR-DUP', - }) + '\n', - ); - try { - const deps = makeDeps({ - // The item is re-observed OPEN at its marker's stage on every cycle - // (the status revert, H5). The critical-first scan re-selects it. - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalInFlight('CR-DUP')] }), - // The CAS claim succeeds on both cycles because the DB genuinely - // says `open` at `plan_complete` (H5 is the necessary partner of H1). - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - // Cycle 1 sees NO pane (the dispatch has not happened yet); by cycle - // 2 the spawned pane is live and `working`. Pre-fix - // `dispatchFromHerdrList` never consults this, so cycle 2 duplicates. - getRunningDowntimePanes: vi - .fn() - .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) - .mockResolvedValue({ - ok: true, - count: 1, - paneIds: ['w1:p1'], - records: [ - { - paneId: 'w1:p1', - label: 'Downtime triggered implement Critical in-flight CR-DUP - CR-DUP', - agent: 'pi', - agentStatus: 'working', - }, - ], - }), - }); - - const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - - // Cycle 1 dispatches the critical item. - expect(first.dispatched).toBe(true); - expect(first.candidate?.id).toBe('CR-DUP'); - // Cycle 2 must NOT dispatch: a live working pane for the item exists. - // Pre-fix this observes 2 spawns (the duplicate); post-fix exactly 1. - expect(second.dispatched).toBe(false); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }, - ); -}); - -// ── Regression: one dispatch per in-flight critical item (F2) ────────── -// Parent WL-0MUBEZ6PE002WLP4 / F2 WL-0MUBVKS5I007LSWB (AC5.1, AC5.3, -// AC5.4, AC5.6). Promotes F1's RED witness -// (WL-0MUBVKPLP006RRDJ) into the permanent, path-covering suite. -// -// The critical-first scan bypasses the dispatched-marker exclusion -// UNCONDITIONALLY and the CAS claim succeeds when the item's status has -// reverted to `open` at its marker's stage (H5), so a live pane does not -// currently prevent a second dispatch. These cases are RED for the in-flight -// variants before F3 (WL-0MUBVKXQJ000L8EO) lands and GREEN after. - -describe('one dispatch per in-flight critical item (WL-0MUBVKS5I007LSWB / AC5)', () => { - const criticalPlannedInFlight = (id: string): DowntimeHerdrItem => ({ - id, title: `Critical in-flight ${id}`, status: 'open', stage: 'plan_complete', - risk: 'Low', effort: 'S', priority: 'critical', sortIndex: 10, - }); - const nonCriticalStale = (id: string): DowntimeHerdrItem => ({ - id, title: `Stale ${id}`, status: 'open', stage: 'plan_complete', - risk: 'Low', effort: 'S', priority: 'medium', sortIndex: 100, - }); - - /** A `herdr pane list` record set with one pane for *itemId* at *status*. */ - const paneRecord = (itemId: string, agentStatus: string) => ({ - paneId: 'w1:p1', - label: `Downtime triggered implement Critical in-flight ${itemId} - ${itemId}`, - agent: 'pi', - agentStatus, - }); - - /** - * A live working pane result. `records` is the item-scoped signal F3 - * consumes (additive on RunningPanesResult); `count` stays the owner-lease - * qualifier only — never a dispatch limit (WL-0MU2EP6JL006A1U3). - */ - const liveWorkingPane = (itemId: string) => ({ - ok: true as const, - count: 1, - paneIds: ['w1:p1'], - records: [paneRecord(itemId, 'working')], - }); - - describe('AC5.1 — Herdr-head path: two cycles, exactly one dispatch', () => { - it('two idle cycles over a critical item open at its marker stage with a live working pane dispatch once (RED pre-fix)', async () => { - const root = mkdtempSync(join(tmpdir(), 'herdr-head-inflight-')); - mkdirSync(join(root, '.worklog'), { recursive: true }); - writeFileSync( - join(root, '.worklog', 'downtime-dispatches.log'), - JSON.stringify({ - at: new Date().toISOString(), cwd: root, kind: 'implement', itemId: 'CR-INFLIGHT', - stage: 'plan_complete', dispatchedAt: new Date().toISOString(), message: 'Dispatched CR-INFLIGHT', - }) + '\n', - ); - try { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-INFLIGHT')] }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - // Cycle 1: no pane yet. Cycle 2: the spawned pane is live and working. - getRunningDowntimePanes: vi - .fn() - .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) - .mockResolvedValue(liveWorkingPane('CR-INFLIGHT')), - }); - - const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - - expect(first.dispatched).toBe(true); - expect(second.dispatched).toBe(false); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - }); - - describe('AC5.4 — live-pane variants', () => { - it('a live `working` pane blocks the second cycle (stage advancement irrelevant while open)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-W')] }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - // Cycle 1: no pane yet. Cycle 2: the spawned pane is live and working. - getRunningDowntimePanes: vi - .fn() - .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) - .mockResolvedValue(liveWorkingPane('CR-W')), - }); - - const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(first.dispatched).toBe(true); - expect(second.dispatched).toBe(false); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('a `done` pane does NOT block dispatch (no idle-pane deadlock, AC2.3)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-DONE')] }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ - ok: true, count: 0, paneIds: [], records: [paneRecord('CR-DONE', 'done')], - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.candidate?.id).toBe('CR-DONE'); - }); - - it('an `exited` pane does NOT block dispatch (no idle-pane deadlock, AC2.3)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-EXIT')] }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ - ok: true, count: 0, paneIds: [], records: [paneRecord('CR-EXIT', 'exited')], - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.candidate?.id).toBe('CR-EXIT'); - }); - - it('a failed/unparseable pane query never duplicating: the fresh marker still holds the second cycle (AC2.2/AC3.3)', async () => { - const root = mkdtempSync(join(tmpdir(), 'herdr-head-panefail-')); - mkdirSync(join(root, '.worklog'), { recursive: true }); - writeFileSync( - join(root, '.worklog', 'downtime-dispatches.log'), - JSON.stringify({ - at: new Date().toISOString(), cwd: root, kind: 'implement', itemId: 'CR-PANEFAIL', - stage: 'plan_complete', dispatchedAt: new Date().toISOString(), message: 'Dispatched CR-PANEFAIL', - }) + '\n', - ); - try { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [criticalPlannedInFlight('CR-PANEFAIL')] }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - // Cycle 1: pane query succeeds (none) so the dispatch proceeds. - // Cycle 2: herdr is unavailable — the in-flight state cannot be - // proven, so the fresh marker must hold the second dispatch. - getRunningDowntimePanes: vi - .fn() - .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) - .mockResolvedValue({ ok: false, error: 'herdr down' }), - }); - - const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - - expect(first.dispatched).toBe(true); - expect(second.dispatched).toBe(false); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - }); - - describe('AC5.3 — stale-marker coverage beyond the critical tier', () => { - it('a non-critical item at the same stage with a fresh marker and a live pane is not re-dispatched', async () => { - const root = mkdtempSync(join(tmpdir(), 'herdr-head-noncrit-')); - mkdirSync(join(root, '.worklog'), { recursive: true }); - writeFileSync( - join(root, '.worklog', 'downtime-dispatches.log'), - JSON.stringify({ - at: new Date().toISOString(), cwd: root, kind: 'implement', itemId: 'IMP-STALE', - stage: 'plan_complete', dispatchedAt: new Date().toISOString(), message: 'Dispatched IMP-STALE', - }) + '\n', - ); - try { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [nonCriticalStale('IMP-STALE')] }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ - ok: true, count: 1, paneIds: ['w1:p1'], - records: [{ paneId: 'w1:p1', label: 'Downtime triggered implement Stale IMP-STALE - IMP-STALE', agent: 'pi', agentStatus: 'working' }], - }), - }); - - const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: root }); - - expect(first.dispatched).toBe(false); - expect(second.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - }); - - describe('AC5.6 — no idle-pane deadlock: a completed skill still advances to its next tier', () => { - it('an item whose previous skill completed (pane present, agent not working) still dispatches its next tier', async () => { - // The pane for the item is still OPEN but its agent is `done`: the item - // is at intake_complete with a completed intake pane. It must dispatch - // the plan tier, not deadlock on the stale pane. - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ - ok: true, - items: [{ id: 'WL-NEXT', title: 'Next tier', status: 'open', stage: 'intake_complete', priority: 'high', risk: 'Low', effort: 'S', sortIndex: 10 }], - }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ - ok: true, count: 0, paneIds: [], - records: [{ paneId: 'w1:p1', label: 'Downtime triggered intake Next tier - WL-NEXT', agent: 'pi', agentStatus: 'done' }], - }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('WL-NEXT'); - }); - }); -}); - -// ── Dispatch-window extension — Herdr head-cap starvation (WL-0MU6UL3GQ0015AA5) ── -// The dispatcher iterates the Herdr list head, which is windowed: mandatory -// items (critical + completed/in_review) are always included and consume -// slots, so when they are numerous the first genuinely dispatchable candidate -// can fall beyond the window. These tests reproduce the 2026-09-18 starvation -// (a 30-item head, all blocked; the only dispatchable item was the 22nd -// "other", cut off by the cap) and assert the bounded window extension finds -// it in the SAME ranking order. -// -// Blocking is done by CLASSIFICATION SHAPE (a non-dispatchable stage for -// open items, out-of-recency for completed/in_review) rather than by the -// `needsProducerReview` flag: the window-extension tests deliberately isolate -// the window mechanism from the review gate. (The flag now blocks on this -// path too — the field is threaded into `classifyItemForDispatch` as of -// WL-0MU72WJ8C0005GIE / 022f1b24.) - -describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0MU6UL3GQ0015AA5 AC1/AC4)', () => { - const OLD = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); - - /** - * A non-dispatchable filler item: `open` status but a stage with no dispatch - * kind (`in_review`). Previously these filler items were made - * non-dispatchable by an above-cap risk, but the implement risk cap was - * removed (WL-0MUBUA9C8009N0K5); the non-dispatchable stage is now the - * stable filler shape. - */ - const blockedImplement = (id: string, overrides: Partial = {}): DowntimeHerdrItem => ({ - id, - title: `Blocked ${id}`, - status: 'open', - stage: 'in_review', - priority: 'medium', - risk: 'High', - effort: 'S', - sortIndex: 100, - ...overrides, - }); - - /** A completed/in_review item outside the audit recency window (never dispatchable). */ - const blockedReview = (id: string, overrides: Partial = {}): DowntimeHerdrItem => ({ - id, - title: `Blocked review ${id}`, - status: 'completed', - stage: 'in_review', - priority: 'medium', - updatedAt: OLD, - sortIndex: 2, - ...overrides, - }); - - /** 1 blocked critical + 8 blocked completed/in_review = the 9 mandatory slots. */ - const mandatorySet = (): DowntimeHerdrItem[] => [ - blockedImplement('CG-CRIT', { priority: 'critical', sortIndex: 1 }), - ...Array.from({ length: 8 }, (_, i) => blockedReview(`CG-REV${i + 1}`, { sortIndex: 2 + i })), - ]; - - /** 21 blocked "other" items fill the remaining window slots (total head = 30). */ - const blockedOthers = (): DowntimeHerdrItem[] => - Array.from({ length: 21 }, (_, i) => blockedImplement(`CG-BLK${i + 1}`, { sortIndex: 20 + i })); - - /** The only genuinely dispatchable item (high, idea, no markers, not review-gated). */ - const dispatchableIntake = (id: string): DowntimeHerdrItem => ({ - id, - title: `Dispatchable ${id}`, - status: 'open', - stage: 'idea', - priority: 'high', - sortIndex: 999, - }); - - /** A dispatchable CRITICAL implement item (the AC4 escape-hatch shape). */ - const dispatchableCriticalImplement = (id: string): DowntimeHerdrItem => ({ - id, - title: `Critical ${id}`, - status: 'open', - stage: 'plan_complete', - priority: 'critical', - risk: 'Low', - effort: 'S', - sortIndex: 500, - }); - - const fullHead = (): DowntimeHerdrItem[] => [...mandatorySet(), ...blockedOthers()]; - - describe('AC1 — head limit equals the live sprint-view browseItemCount', () => { - it('direct dispatch passes the effective browseItemCount to getHerdrListHead', async () => { - const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); - const deps = makeDeps({ getHerdrListHead }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 20); - }); - - it('clamps an out-of-range browseItemCount to the supported [1, 50] range', async () => { - const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); - const deps = makeDeps({ getHerdrListHead }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 999 }); - expect(getHerdrListHead).toHaveBeenLastCalledWith('/repo', 50); - - getHerdrListHead.mockClear(); - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 0 }); - expect(getHerdrListHead).toHaveBeenLastCalledWith('/repo', 1); - }); - - it('defaults to the sprint-view default (20) when browseItemCount is undefined', async () => { - const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); - const deps = makeDeps({ getHerdrListHead }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 20); - }); - - it('computeMostImportantItem passes the effective browseItemCount to getHerdrListHead', async () => { - const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); - const deps = makeDeps({ getHerdrListHead }); - - await computeMostImportantItem(deps, '/repo', Date.now(), 33); - - expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 33); - }); - - it('re-reads the live browseItemCount on every dispatch cycle (settings change applies without restart)', async () => { - const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); - const deps = makeDeps({ getHerdrListHead }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 15 }); - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 40 }); - - expect(getHerdrListHead).toHaveBeenNthCalledWith(1, '/repo', 15); - expect(getHerdrListHead).toHaveBeenNthCalledWith(2, '/repo', 40); - }); - }); - - describe('AC3 — regression: the dispatchable item beyond the window cap is found', () => { - it('regression (WL-0MU6UL3GQ0015AA5 updated): a non-critical item beyond the sprint view is NOP, not dispatched', async () => { - const initialHead = fullHead(); - const target = dispatchableIntake('CG-0MTZO0YIM000VAIQ'); - const getHerdrListHead = vi.fn() - .mockResolvedValueOnce({ ok: true, items: initialHead }) - .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); - const deps = makeDeps({ getHerdrListHead }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - // The out-of-window item is NON-critical: the sprint-view-only contract - // reports no-candidate instead of dispatching hidden backlog work - // (supersedes WL-0MU6UL3GQ0015AA5 for non-critical work). - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.claimItem).not.toHaveBeenCalled(); - // The critical-only extension still consulted the SAME ranking path - // (never a second ranking). - expect(getHerdrListHead).toHaveBeenCalledTimes(2); - expect(getHerdrListHead).toHaveBeenNthCalledWith( - 2, - '/repo', - initialHead.length + DOWNTIME_DISPATCH_EXTEND_MAX, - ); - }); - - it('the coordination check-in offer (computeMostImportantItem) also no-ops on a non-critical out-of-window item', async () => { - const initialHead = fullHead(); - const target = dispatchableIntake('CG-OFFER'); - const getHerdrListHead = vi.fn() - .mockResolvedValueOnce({ ok: true, items: initialHead }) - .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); - const deps = makeDeps({ getHerdrListHead }); - - const result = await computeMostImportantItem(deps, '/repo'); - - expect(result).toMatchObject({ ok: true, noCandidate: true }); - expect(getHerdrListHead).toHaveBeenCalledTimes(2); - }); - - it('prefers an in-window candidate over the extended window (no extra lookup when the head suffices)', async () => { - const inWindow = dispatchableIntake('CG-INWINDOW'); - const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [inWindow, ...blockedOthers()] }); - const deps = makeDeps({ getHerdrListHead }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.candidate?.id).toBe('CG-INWINDOW'); - // The head already yielded a candidate — the extension never runs. - expect(getHerdrListHead).toHaveBeenCalledTimes(1); - }); - - it('stays code-freeze under a freeze even when a non-critical item lies beyond the window', async () => { - // Under a freeze, audit/implement candidates are paused; a non-critical - // item beyond the sprint view is not an escape hatch, so the terminal - // reason stays derived from the in-view flags. - const initialHead = fullHead(); - const target = dispatchableIntake('CG-FROZEN-OUT-OF-WINDOW'); - const getHerdrListHead = vi.fn() - .mockResolvedValueOnce({ ok: true, items: initialHead }) - .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - getHerdrListHead, - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('code-freeze'); - }); - }); - - describe('AC2 — bounded extension, no worklist regression, terminal reasons preserved', () => { - it('stays no-candidate when the extended window adds nothing (mock ignores the limit)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: fullHead() }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.claimItem).not.toHaveBeenCalled(); - }); - - it('reports code-freeze when a frozen head plus extension still yields no candidate', async () => { - const deps = makeDeps({ - readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), - // Both the head and the extension return the same blocked items. - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: fullHead() }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('code-freeze'); - }); - - it('the extension never invents a new failure: a failed extended lookup degrades to no-candidate', async () => { - const getHerdrListHead = vi.fn() - .mockResolvedValueOnce({ ok: true, items: fullHead() }) - .mockResolvedValueOnce({ ok: false, error: 'boom' }); - const deps = makeDeps({ getHerdrListHead }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(getHerdrListHead).toHaveBeenCalledTimes(2); - }); - }); - - describe('AC4 — critical items remain dispatchable', () => { - it('a critical item in the head still dispatches while every non-critical item is filtered', async () => { - const getHerdrListHead = vi.fn().mockResolvedValue({ - ok: true, - items: [dispatchableCriticalImplement('CG-CRIT-DISPATCH'), ...blockedOthers()], - }); - const deps = makeDeps({ getHerdrListHead }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('CG-CRIT-DISPATCH'); - }); - - it('the critical-only extension still reaches an out-of-window critical item', async () => { - const initialHead = fullHead(); - const target = dispatchableCriticalImplement('CG-CRIT-OUT-OF-WINDOW'); - const getHerdrListHead = vi.fn() - .mockResolvedValueOnce({ ok: true, items: initialHead }) - .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); - const deps = makeDeps({ getHerdrListHead }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('CG-CRIT-OUT-OF-WINDOW'); - }); - }); - - describe('fetchExtendedHerdrItems — critical-only escape hatch primitive', () => { - const item = (id: string, priority = 'medium'): DowntimeHerdrItem => ({ - id, - title: `Item ${id}`, - status: 'open', - stage: 'idea', - priority, - }); - - it('returns only newly visible CRITICAL items, preserving the canonical order', async () => { - const current = [item('A')]; - const dep = vi.fn().mockResolvedValue({ - ok: true, - items: [item('A'), item('B'), item('C', 'critical'), item('D'), item('E', 'critical')], - }); - const deps = makeDeps({ getHerdrListHead: dep }); - - const extended = await fetchExtendedHerdrItems(deps, '/repo', current); - - expect(extended.map((i) => i.id)).toEqual(['C', 'E']); - expect(extended.every((i) => i.priority === 'critical')).toBe(true); - expect(dep).toHaveBeenCalledWith('/repo', 1 + DOWNTIME_DISPATCH_EXTEND_MAX); - }); - - it('never returns a non-critical item beyond the sprint view (AC4)', async () => { - const current = [item('A')]; - const dep = vi.fn().mockResolvedValue({ - ok: true, - items: [item('A'), item('B', 'high'), item('C'), item('D', 'low')], - }); - const deps = makeDeps({ getHerdrListHead: dep }); - - expect(await fetchExtendedHerdrItems(deps, '/repo', current)).toEqual([]); - }); - - it('returns [] when the extended head adds nothing (limit-ignoring dep)', async () => { - const current = [item('A')]; - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [item('A')] }), - }); - - expect(await fetchExtendedHerdrItems(deps, '/repo', current)).toEqual([]); - }); - - it('fails open to [] on a {ok:false} extended lookup', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: false, error: 'boom' }), - }); - - expect(await fetchExtendedHerdrItems(deps, '/repo', [item('A')])).toEqual([]); - }); - - it('fails open to [] when the extended lookup throws', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockRejectedValue(new Error('boom')), - }); - - expect(await fetchExtendedHerdrItems(deps, '/repo', [item('A')])).toEqual([]); - }); - }); -}); - -// ── Dispatched success-marker staleness — live Herdr-head path -// (WL-0MU6UL0RJ008IHGT AC1/AC2/AC4) ───────────────────────────── -// -// A SUCCESS dispatch marker (no `outcome`) historically excluded its item -// forever: a pane that spawned but whose agent never advanced the item -// (crash, manual close, silent failure) stranded it permanently — the item -// was invisible to the tier that would retry it. These tests pin the live -// `dispatchFromHerdrList`/`computeMostImportantItem` marker filter: a marker -// excludes ONLY while the item is STILL at the marker's dispatched-at stage -// AND the marker is fresh (age <= the configured staleness window). The -// times are relative to REAL `Date.now()` because the dispatch path reads -// the clock itself. Items here are NON-critical so they exercise the normal -// (non-critical-first) filter path. -describe('dispatched success-marker staleness on the live path (WL-0MU6UL0RJ008IHGT)', () => { - const WINDOW_MS = 24 * 60 * 60 * 1000; // default 24h - const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); - const tempCwds: string[] = []; - - afterEach(() => { - for (const dir of tempCwds.splice(0)) rmSync(dir, { recursive: true, force: true }); - }); - - function makeCwd(): string { - const dir = mkdtempSync(join(tmpdir(), 'dt-stale-marker-')); - tempCwds.push(dir); - return dir; - } - - function writeLog(cwd: string, entries: Array>): void { - mkdirSync(join(cwd, '.worklog'), { recursive: true }); - writeFileSync( - join(cwd, '.worklog', DOWNTIME_LOG_FILE), - entries.map((e) => JSON.stringify(e)).join('\n') + '\n', - 'utf8', - ); - } - - const planHead = (id: string): DowntimeHerdrItem => ({ - id, - title: `Plan ${id}`, - status: 'open', - stage: 'intake_complete', - priority: 'medium', - sortIndex: 30, - }); - const implementHead = (id: string): DowntimeHerdrItem => ({ - id, - title: `Implement ${id}`, - status: 'open', - stage: 'plan_complete', - risk: 'Low', - effort: 'S', - priority: 'medium', - sortIndex: 20, - }); - - it('AC1: a stale success marker at the unchanged stage no longer excludes — the item is re-dispatched', async () => { - const cwd = makeCwd(); - // Plan marker written >24h ago for an item STILL at intake_complete — the - // pane spawned but never advanced it. Must be released on the live path. - writeLog(cwd, [ - { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(WINDOW_MS + 60_000) }, - ]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('PLN-1'); - expect(deps.spawnAgentPane).toHaveBeenCalledWith( - expect.stringContaining('/skill:plan PLN-1'), - expect.anything(), - ); - }); - - it('a FRESH success marker at the unchanged stage still excludes (no double dispatch)', async () => { - const cwd = makeCwd(); - // Plan marker 1h ago for an item still at intake_complete → genuinely - // in-flight; the tier must skip it and report the neutral no-candidate. - writeLog(cwd, [ - { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60 * 60 * 1000) }, - ]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('AC2: an item that ADVANCED past the marker stage is released regardless of age', async () => { - const cwd = makeCwd(); - // Marker says intake_complete but the item is now at plan_complete — the - // history released it via the change-guard; it must still release today. - writeLog(cwd, [ - { itemId: 'IMP-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60 * 1000) }, - ]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('IMP-1'); - }); - - it('id-guard tier: a stale IMPLEMENT marker at the unchanged stage is released', async () => { - const cwd = makeCwd(); - writeLog(cwd, [ - { itemId: 'IMP-1', kind: 'implement', stage: 'plan_complete', dispatchedAt: ago(WINDOW_MS + 60_000) }, - ]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('implement'); - expect(outcome.candidate?.id).toBe('IMP-1'); - }); - - it('id-guard tier: a fresh IMPLEMENT marker still excludes (duplicate-dispatch protection)', async () => { - const cwd = makeCwd(); - writeLog(cwd, [ - { itemId: 'IMP-1', kind: 'implement', stage: 'plan_complete', dispatchedAt: ago(60 * 60 * 1000) }, - ]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - }); - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); - expect(outcome.dispatched).toBe(false); - expect(outcome.reason).toBe('no-candidate'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('id-guard tier: a legacy implement marker WITHOUT a stage keeps excluding while fresh, releases when stale', async () => { - const cwd = makeCwd(); - // Fresh legacy marker (no stage field — pre-change-guard entry): the - // id-guard must NOT weaken protection → still excluded. - writeLog(cwd, [{ itemId: 'IMP-1', kind: 'implement', dispatchedAt: ago(60 * 60 * 1000) }]); - const freshDeps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - }); - const freshOutcome = await dispatchDowntimeWork(freshDeps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); - expect(freshOutcome.dispatched).toBe(false); - expect(freshOutcome.reason).toBe('no-candidate'); - - // Same legacy marker but stale → the age TTL releases it. - writeLog(cwd, [{ itemId: 'IMP-1', kind: 'implement', dispatchedAt: ago(WINDOW_MS + 60_000) }]); - const staleDeps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [implementHead('IMP-1')] }), - }); - const staleOutcome = await dispatchDowntimeWork(staleDeps, { model: 'plan', cwd, markerStaleWindowMs: WINDOW_MS }); - expect(staleOutcome.dispatched).toBe(true); - expect(staleOutcome.kind).toBe('implement'); - }); - - it('computeMostImportantItem releases a stale marker at the unchanged stage (offers re-triable item)', async () => { - const cwd = makeCwd(); - writeLog(cwd, [ - { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(WINDOW_MS + 60_000) }, - ]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, WINDOW_MS); - expect(result.ok).toBe(true); - if (result.ok && 'candidate' in result) { - expect(result.candidate.id).toBe('PLN-1'); - expect(result.kind).toBe('plan'); - } else { - throw new Error(`expected a candidate offer, got ${JSON.stringify(result)}`); - } - }); - - it('computeMostImportantItem keeps excluding a FRESH marker at the unchanged stage (no offer)', async () => { - const cwd = makeCwd(); - writeLog(cwd, [ - { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60 * 60 * 1000) }, - ]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, WINDOW_MS); - expect(result.ok).toBe(true); - if (result.ok) expect('noCandidate' in result).toBe(true); - }); -}); - -// ── Non-terminal pane-close cooldown — live paths (WL-0MUKYERLZ006ELL5) ── - -describe('non-terminal pane-close cooldown on the live paths (WL-0MUKYERLZ006ELL5)', () => { - const COOLDOWN_MS = DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS; - const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); - const tempCwds: string[] = []; - - afterEach(() => { - for (const dir of tempCwds.splice(0)) rmSync(dir, { recursive: true, force: true }); - }); - - function makeCwd(): string { - const dir = mkdtempSync(join(tmpdir(), 'dt-nonterminal-cooldown-')); - tempCwds.push(dir); - return dir; - } - - function writeLog(cwd: string, entries: Array>): void { - mkdirSync(join(cwd, '.worklog'), { recursive: true }); - writeFileSync( - join(cwd, '.worklog', DOWNTIME_LOG_FILE), - entries.map((e) => JSON.stringify(e)).join('\n') + '\n', - 'utf8', - ); - } - - const planHead = (id: string): DowntimeHerdrItem => ({ - id, - title: `Plan ${id}`, - status: 'open', - stage: 'intake_complete', - priority: 'medium', - sortIndex: 30, - }); - - /** A non-terminal pane-close entry for the plan candidate. */ - function closeEntry(overrides: Record = {}): Record { - return { - entryType: 'pane-close', - itemId: 'PLN-1', - kind: 'plan', - stage: 'intake_complete', - reasonCode: 'agent-ended-no-terminal', - outcome: 'requires-attention', - timestamp: ago(60_000), - closed: true, - ...overrides, - }; - } - - it('AC1: a recent non-terminal close holds re-dispatch with the neutral cooldown reason', async () => { - const cwd = makeCwd(); - writeLog(cwd, [closeEntry()]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); - - expect(outcome.dispatched).toBe(false); - // Distinguishable from 'no-candidate' so the no-candidate cooldown is - // not entered and no strike is recorded (AC3/AC5). - expect(outcome.reason).toBe('non-terminal-cooldown'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - expect(deps.claimItem).not.toHaveBeenCalled(); - expect(deps.recordError).not.toHaveBeenCalled(); - expect(deps.recordDispatchFailure).not.toHaveBeenCalled(); - }); - - it('AC2: a close older than cooldownMs releases the item (dispatches normally)', async () => { - const cwd = makeCwd(); - writeLog(cwd, [closeEntry({ timestamp: ago(COOLDOWN_MS + 1) })]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - expect(outcome.candidate?.id).toBe('PLN-1'); - }); - - it('AC3: stage advancement mid-cooldown releases the item immediately', async () => { - const cwd = makeCwd(); - // Dispatched at `idea`; the item has since advanced to `intake_complete`. - writeLog(cwd, [closeEntry({ stage: 'idea' })]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.candidate?.id).toBe('PLN-1'); - }); - - it('AC4: a terminal close (reasonCode none) does not apply the cooldown', async () => { - const cwd = makeCwd(); - writeLog(cwd, [closeEntry({ reasonCode: 'none', outcome: 'closed-as-plan-complete' })]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); - - expect(outcome.dispatched).toBe(true); - }); - - it('applies the cooldown per-kind: an intake close does not hold a plan re-dispatch', async () => { - const cwd = makeCwd(); - writeLog(cwd, [closeEntry({ kind: 'intake', itemId: 'PLN-1' })]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd, nonTerminalCooldownMs: COOLDOWN_MS }); - - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('plan'); - }); - - it('AC7: computeMostImportantItem reports a distinct cooldown hold, never noCandidate', async () => { - const cwd = makeCwd(); - writeLog(cwd, [closeEntry()]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - - const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, undefined, COOLDOWN_MS); - - expect(result.ok).toBe(true); - if (result.ok) { - expect('nonTerminalCooldownHold' in result).toBe(true); - expect('noCandidate' in result).toBe(false); - // The offer-computation hold is NOT a candidate offer. - expect('candidate' in result).toBe(false); - } - }); - - it('AC7: computeMostImportantItem offers a cooldown-elapsed item normally', async () => { - const cwd = makeCwd(); - writeLog(cwd, [closeEntry({ timestamp: ago(COOLDOWN_MS + 1) })]); - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [planHead('PLN-1')] }), - }); - - const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, undefined, COOLDOWN_MS); - - expect(result.ok).toBe(true); - if (result.ok && 'candidate' in result) { - expect(result.candidate.id).toBe('PLN-1'); - expect(result.kind).toBe('plan'); - } else { - throw new Error(`expected a candidate offer, got ${JSON.stringify(result)}`); - } - }); -}); - -// ── clampDowntimeMarkerStaleWindowMs (WL-0MU6UL0RJ008IHGT AC3) ──────── - -describe('clampDowntimeMarkerStaleWindowMs', () => { - it('defaults on non-finite/negative input', () => { - expect(clampDowntimeMarkerStaleWindowMs(NaN)).toBe(DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS); - expect(clampDowntimeMarkerStaleWindowMs(-1)).toBe(DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS); - expect(clampDowntimeMarkerStaleWindowMs(0)).toBe(60 * 60 * 1000); // clamped to 1h floor - }); - - it('clamps into the documented [1h, 7d] range', () => { - expect(clampDowntimeMarkerStaleWindowMs(30 * 60 * 1000)).toBe(60 * 60 * 1000); // below floor - expect(clampDowntimeMarkerStaleWindowMs(8 * 24 * 60 * 60 * 1000)).toBe(7 * 24 * 60 * 60 * 1000); // above ceiling - const mid = 6 * 60 * 60 * 1000; - expect(clampDowntimeMarkerStaleWindowMs(mid)).toBe(mid); - }); -}); - -// ── Dispatch-log selection provenance + pane enrichment (F6) ─────────── -// WL-0MUBVL251006JAQ0 (parent WL-0MUBEZ6PE002WLP4, AC6.1–AC6.3). - -describe('dispatch log: selection path/reason + pane id enrichment (WL-0MUBVL251006JAQ0 / F6)', () => { - const critical = (): DowntimeHerdrItem => ({ - id: 'CR-SEL', title: 'Crit sel', status: 'open', stage: 'plan_complete', - risk: 'Low', effort: 'S', priority: 'critical', sortIndex: 10, - }); - const nonCritical = (): DowntimeHerdrItem => ({ - id: 'IMP-SEL', title: 'Impl sel', status: 'open', stage: 'plan_complete', - risk: 'Low', effort: 'S', priority: 'high', sortIndex: 100, - }); - - it('AC6.1: a critical-first dispatch records selectionPath/selectionReason', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.recordDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - itemId: 'CR-SEL', - selectionPath: 'critical-first', - selectionReason: 'no-live-pane', - }), - ); - }); - - it('AC6.1: a normal-scan dispatch records selectionPath/selectionReason', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [nonCritical()] }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(deps.recordDispatch).toHaveBeenCalledWith( - expect.objectContaining({ - itemId: 'IMP-SEL', - selectionPath: 'normal-scan', - selectionReason: 'non-critical', - }), - ); - }); - - it('AC6.2/AC6.3: a successful dispatch appends an enrichment entry with the resolved paneId, preserving marker fields', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), - recordDispatch: vi.fn().mockResolvedValue(true), - recordDispatchEnrichment: vi.fn().mockResolvedValue(undefined), - // 1st call (pre-spawn in-flight guard): no pane. 2nd call (post-spawn - // enrichment resolver): the pane is live. - getRunningDowntimePanes: vi - .fn() - .mockResolvedValueOnce({ ok: true, count: 0, paneIds: [], records: [] }) - .mockResolvedValue({ - ok: true, count: 1, paneIds: ['w1:p1'], - records: [{ paneId: 'w1:p1', label: 'Downtime triggered implement Crit sel - CR-SEL', agent: 'pi', agentStatus: 'working' }], - }), - }); - - await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - const markerCall = (deps.recordDispatch as ReturnType).mock.calls[0][0]; - const enrichCall = (deps.recordDispatchEnrichment as ReturnType).mock.calls[0][0]; - expect(enrichCall).toMatchObject({ - itemId: markerCall.itemId, - kind: markerCall.kind, - stage: markerCall.stage, - dispatchedAt: markerCall.dispatchedAt, - paneId: 'w1:p1', - enrichment: true, - noItemComment: true, - }); - }); - - it('AC6.3: an unresolved pane id is recorded as null, and the dispatch still succeeds', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), - recordDispatch: vi.fn().mockResolvedValue(true), - recordDispatchEnrichment: vi.fn().mockResolvedValue(undefined), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect((deps.recordDispatchEnrichment as ReturnType).mock.calls[0][0].paneId).toBeNull(); - }); - - it('AC6.3: a throwing enrichment dep never affects the dispatch outcome (fail-open)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), - recordDispatch: vi.fn().mockResolvedValue(true), - recordDispatchEnrichment: vi.fn().mockRejectedValue(new Error('boom')), - }); - - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - - expect(outcome.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('AC6.3: an absent enrichment dep is a no-op (legacy callers unchanged)', async () => { - const deps = makeDeps({ - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [critical()] }), - recordDispatch: vi.fn().mockResolvedValue(true), - }); - // makeDeps omits recordDispatchEnrichment by default. - const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(outcome.dispatched).toBe(true); - }); -}); -// ── AC Tests: per-slot owner-lease gate (WL-0MU8807BI008C9ME) ────────── - -// ── parseLlamaStatus: array local_owner_session_id (blocking dep fix) ── - -describe('parseLlamaStatus: array local_owner_session_id (WL-0MU88086A0089US4)', () => { - function makeBase(): Record { - return { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 3, - total_slots: 3, - contention_queue_depth: 0, - contention_queued_count: 0, - }; - } - - it('AC1: legacy string payload preserves the owner (WL-0MU88086A0089US4)', () => { - const raw = { - ...makeBase(), - local_owner_session_id: 'herdr-1789775322-722064-10708', - }; - const result = parseLlamaStatus(raw); - expect(result).not.toBeNull(); - expect(result!.local_owner_session_id).toBe('herdr-1789775322-722064-10708'); - }); - - it('AC2: current 2-element array payload parses; URL element ignored, session recovered', () => { - const raw = { - ...makeBase(), - local_lease_active: undefined, // omit so it derives from array/lease_seconds - local_owner_session_id: ['http://localhost:8080', 'herdr-1789775322-722064-10708'], - local_owner_lease_remaining_seconds: 30, - }; - const result = parseLlamaStatus(raw); - expect(result).not.toBeNull(); - // The session ID (2nd element) is extracted; the URL (1st element) is dropped. - expect(result!.local_owner_session_id).toBe('herdr-1789775322-722064-10708'); - // local_lease_active derives from lease_seconds > 0. - expect(result!.local_lease_active).toBe(true); - }); - - it('AC3: malformed owner (number) → fail-closed (busy / undefined), no throw', () => { - const raw = { - ...makeBase(), - local_owner_session_id: 42, - }; - const result = parseLlamaStatus(raw); - // A non-string, non-array number is ambiguous → undefined for this field. - expect(result).not.toBeNull(); - expect(result!.local_owner_session_id).toBeUndefined(); - }); - - it('AC3b: malformed owner (object) → fail-closed', () => { - const raw = { - ...makeBase(), - local_owner_session_id: { id: 'bad' }, - }; - const result = parseLlamaStatus(raw); - expect(result).not.toBeNull(); - expect(result!.local_owner_session_id).toBeUndefined(); - }); - - it('AC3c: malformed owner (empty array) → fail-closed', () => { - const raw = { - ...makeBase(), - local_owner_session_id: [], - }; - const result = parseLlamaStatus(raw); - expect(result).not.toBeNull(); - expect(result!.local_owner_session_id).toBeUndefined(); - }); - - it('AC4: contract test pins exact array shape [url, session]', () => { - // Reproduces the exact live-proxy shape observed on 2026-09-19. - const raw = { - ...makeBase(), - local_owner_session_id: ['http://localhost:8080', 'herdr-1789776793-744802-10480'], - local_owner_lease_remaining_seconds: 29.99, - }; - const result = parseLlamaStatus(raw); - expect(result).not.toBeNull(); - expect(result!.local_owner_session_id).toBe('herdr-1789776793-744802-10480'); - }); - - it('AC5: fetchLocalStatus never throws on array shape', async () => { - const arrayPayload = { - ...makeBase(), - local_owner_session_id: ['http://localhost:8080', 'herdr-test'], - }; - const poller = createDowntimePoller( - 'http://proxy:8000', - vi.fn().mockResolvedValue(jsonResponseFixture(arrayPayload)), - ); - const worker = createDowntimeWorker({ - poller, - deps: makeDeps(), - config: () => ({ - enabled: true, - thresholdMs: 0, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }), - }); - // Should not throw — poller must handle the array shape. - const result = await worker.tick(); - expect(result).toBeDefined(); - }); -}); - -describe('parseLlamaStatus: local_owner_session_ids normalization (WL-0MU88086A0089US4)', () => { - function makeBase(): Record { - return { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 3, - total_slots: 3, - contention_queue_depth: 0, - contention_queued_count: 0, - }; - } - - it('captures every session in the array and drops the proxy URL element', () => { - const result = parseLlamaStatus({ - ...makeBase(), - local_owner_session_id: ['http://localhost:8080', 'session-a', 'session-b'], - }); - expect(result).not.toBeNull(); - expect(result!.local_owner_session_ids).toEqual(['session-a', 'session-b']); - expect(result!.local_owner_session_id).toBe('session-a'); - }); - - it('normalises a legacy string owner to a single-element array', () => { - const result = parseLlamaStatus({ - ...makeBase(), - local_owner_session_id: 'legacy-session', - }); - expect(result).not.toBeNull(); - expect(result!.local_owner_session_ids).toEqual(['legacy-session']); - expect(result!.local_owner_session_id).toBe('legacy-session'); - }); - - it('yields an empty owner list for malformed input (fail-closed, no throw)', () => { - const malformedInputs: unknown[] = [ - 42, - { id: 'bad' }, - [], - ['http://localhost:8080'], - [42], - ['http://localhost:8080', 42], - ]; - for (const malformed of malformedInputs) { - const result = parseLlamaStatus({ - ...makeBase(), - local_owner_session_id: malformed, - }); - expect(result).not.toBeNull(); - expect(result!.local_owner_session_ids).toEqual([]); - expect(result!.local_owner_session_id).toBeUndefined(); - } - }); - - it('marks local_lease_active from a multi-session array even without lease seconds', () => { - const result = parseLlamaStatus({ - ...makeBase(), - local_lease_active: undefined, - local_owner_session_id: ['http://localhost:8080', 'session-a', 'session-b'], - }); - expect(result).not.toBeNull(); - expect(result!.local_lease_active).toBe(true); - }); -}); - -// ── Per-slot owner-lease gate in tick() (WL-0MU8807BI008C9ME AC1-AC4) ── - -describe('tick(): owner-lease gate must be per-slot (WL-0MU8807BI008C9ME)', () => { - function makePerSlotWorker(opts: { - status: LlamaStatus; - runningPanes: () => { ok: true; count: number } | { ok: false; error?: string }; - requiredFreeSlots?: number; - }) { - const cfg = { - enabled: true, - thresholdMs: 1_000, - requiredFreeSlots: opts.requiredFreeSlots ?? 2, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - browseItemCount: 20, - }; - const poller = createDowntimePoller( - 'http://proxy:8000', - vi.fn().mockResolvedValue(jsonResponseFixture(opts.status)), - ); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - getRunningDowntimePanes: vi.fn().mockImplementation(async () => opts.runningPanes()), - }); - const worker = createDowntimeWorker({ poller, deps, config: () => ({ ...cfg }) }); - return { worker, deps }; - } - - it('AC1: per-slot 3-slots: 1 owned+busy, 2 free unowned, held lease + runningPanes → dispatch proceeds (RCA regression)', async () => { - // Regression for the overnight stall: cheap mode (3 slots), one slot - // owned by a dispatched agent, 2 free unowned → dispatch MUST fire. - vi.useFakeTimers(); - try { - const ownedSlot = 'http://localhost:8080'; - const status: LlamaStatus = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 2, - total_slots: 3, - local_owner_session_id: ownedSlot, - local_owner_lease_remaining_seconds: 120, - slots: [ - { slot_id: 'slot-1', is_processing: true, owner_session_id: ownedSlot }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - contention_queue_depth: 0, - contention_queued_count: 0, - }; - const { worker, deps } = makePerSlotWorker({ - status, - runningPanes: () => ({ ok: true, count: 1 }), - requiredFreeSlots: 2, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // first tick — establish idle baseline - vi.setSystemTime(start + 5_000); - const outcome = await worker.tick(); - // THE FIX: dispatch MUST proceed because 2 free unowned slots exist. - expect(outcome.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('AC2: count-based status (no slots), owner lease held, runningPanes > 0 → dispatch refused (single-slot protection preserved)', async () => { - vi.useFakeTimers(); - try { - const status: LlamaStatus = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 1, - total_slots: 1, - local_owner_session_id: 'session-live', - local_owner_lease_remaining_seconds: 120, - }; - // No `slots` array → count-based mode. - const { worker, deps } = makePerSlotWorker({ - status, - runningPanes: () => ({ ok: true, count: 1 }), - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const blocked = await worker.tick(); - expect(blocked.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('AC3: per-slot status where every slot is owned → dispatch refused', async () => { - vi.useFakeTimers(); - try { - const ownerA = 'http://localhost:8080'; - const ownerB = 'http://localhost:8081'; - const ownerC = 'http://localhost:8082'; - const status: LlamaStatus = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 0, - total_slots: 3, - local_owner_session_id: ownerA, - local_owner_lease_remaining_seconds: 60, - // AC3: EVERY slot carries a live owner lease, including slot-3 which - // is idle by `is_processing` but owned. countFreeUnownedSlots === 0 - // is the per-slot gate signal — the worker must refuse. - slots: [ - { slot_id: 'slot-1', is_processing: true, owner_session_id: ownerA }, - { slot_id: 'slot-2', is_processing: true, owner_session_id: ownerB }, - { slot_id: 'slot-3', is_processing: false, owner_session_id: ownerC }, - ], - contention_queue_depth: 0, - contention_queued_count: 0, - }; - const { worker, deps } = makePerSlotWorker({ - status, - runningPanes: () => ({ ok: true, count: 1 }), - requiredFreeSlots: 2, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const blocked = await worker.tick(); - expect(blocked.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('AC4: audit-tier minimum (DOWNTIME_AUDIT_MIN_FREE_SLOTS = 2) evaluated against per-slot unowned-free count', async () => { - vi.useFakeTimers(); - try { - // Only 1 free unowned slot — below audit-tier minimum of 2. - const status: LlamaStatus = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 1, - total_slots: 3, - local_owner_session_id: 'http://localhost:8080', - local_owner_lease_remaining_seconds: 60, - slots: [ - { slot_id: 'slot-1', is_processing: true, owner_session_id: 'http://localhost:8080' }, - { slot_id: 'slot-2', is_processing: true }, // processing, not free - { slot_id: 'slot-3', is_processing: false }, // free unowned - ], - contention_queue_depth: 0, - contention_queued_count: 0, - }; - const { worker, deps } = makePerSlotWorker({ - status, - runningPanes: () => ({ ok: true, count: 0 }), - requiredFreeSlots: 2, // audit minimum - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const blocked = await worker.tick(); - // 1 free unowned < requiredFreeSlots=2 → blocked. - expect(blocked.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('AC1b: per-slot with 0 runningPanes but lease held → dispatch proceeds (operator lease on spare slots)', async () => { - // When the worker has NO running panes, even a held lease doesn't block. - vi.useFakeTimers(); - try { - const status: LlamaStatus = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 2, - total_slots: 3, - local_owner_session_id: 'http://localhost:8080', - local_owner_lease_remaining_seconds: 60, - slots: [ - { slot_id: 'slot-1', is_processing: true, owner_session_id: 'http://localhost:8080' }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - contention_queue_depth: 0, - contention_queued_count: 0, - }; - const { worker, deps } = makePerSlotWorker({ - status, - runningPanes: () => ({ ok: true, count: 0 }), - requiredFreeSlots: 2, - }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - vi.setSystemTime(start + 5_000); - const result = await worker.tick(); - expect(result.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it('AC5: cheap-mode dispatch is recorded in downtime-dispatches.log while a previously dispatched agent is still working', async () => { - // Integration evidence for the RCA (WL-0MU8807BI008C9ME): with a - // previously dispatched agent STILL alive (`runningPanes > 0`) and - // holding the shared owner lease, the per-slot gate must still dispatch - // into the 2 free unowned slots AND record the dispatch in the rolling - // log at /.worklog/downtime-dispatches.log — the observable - // behaviour the RCA said never happened. - const root = mkdtempSync(join(tmpdir(), 'herdr-perslot-log-')); - vi.useFakeTimers(); - try { - const status: LlamaStatus = { - llama_server_running: true, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: true, - available_slots: 2, - total_slots: 3, - // Array form served by the live proxy: [url, session]. - local_owner_session_id: ['http://localhost:8080', 'dispatched-pane-session'], - local_owner_lease_remaining_seconds: 90, - slots: [ - { slot_id: 'slot-1', is_processing: true, owner_session_id: 'dispatched-pane-session' }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - contention_queue_depth: 0, - contention_queued_count: 0, - }; - const cfg = { - enabled: true, - thresholdMs: 1_000, - requiredFreeSlots: 2, - model: 'plan', - cwd: root, - noCandidateCooldownMs: 3_600_000, - browseItemCount: 20, - }; - const poller = createDowntimePoller( - 'http://proxy:8000', - vi.fn().mockResolvedValue(jsonResponseFixture(status)), - ); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - // A previously dispatched agent is STILL working. - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 1 }), - // Real rolling-log write (the plugin's recordDispatch contract): - // the dispatch marker must land in downtime-dispatches.log. - recordDispatch: vi.fn(async (event) => { - await appendDowntimeLogEntry(event.cwd, JSON.stringify(event)); - return true; - }), - }); - const worker = createDowntimeWorker({ poller, deps, config: () => ({ ...cfg }) }); - - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); // baseline poll — start the per-slot timer - vi.setSystemTime(start + 5_000); - const outcome = await worker.tick(); - - expect(outcome.dispatched).toBe(true); - const entries = await readDowntimeLogEntries(root); - const marker = entries.find((e) => e.kind === 'plan' && e.itemId === 'WL-ABC'); - expect(marker).toBeDefined(); - expect(marker!.dispatchedAt).toBeDefined(); - } finally { - vi.useRealTimers(); - rmSync(root, { recursive: true, force: true }); - } - }); -}); - -// ── Pane-lifecycle monitor (WL-0MU308WSF0002JWN) — DISABLED ───────── -// -// Mechanism B is switched off in the worker (WL-0MUMEKDK0008LKH8): -// `PANE_LIFECYCLE_MONITOR_ENABLED` is false, so the tick never calls -// `monitorDispatchedPanes` (pinned by the tick integration suite below). -// These unit tests are RETAINED to pin the pure classifier contract — what -// the monitor WOULD do if re-enabled: it inspects every dispatched pane -// recorded in the rolling log (marker + post-spawn enrichment), classifies -// its lifecycle outcome, logs a pane-close entry, and closes the pane — -// EXCEPT `implement` panes, which are never auto-closed (AC6). Every boundary -// is fail-closed (AC7). -describe('pane-lifecycle monitor (WL-0MU308WSF0002JWN) [disabled behaviour]', () => { - const roots: string[] = []; - - function makeRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'pane-lifecycle-')); - roots.push(root); - return root; - } - - afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); - }); - - /** Seed a dispatch marker + its post-spawn enrichment (paneId) entry. */ - async function seedDispatch( - root: string, - opts: { itemId: string; kind: string; paneId: string; stage?: string; title?: string }, - ): Promise { - const marker = { - itemId: opts.itemId, - kind: opts.kind, - dispatchedAt: '2026-01-01T00:00:00.000Z', - cwd: root, - title: opts.title ?? opts.itemId, - stage: opts.stage ?? 'idea', - }; - await appendDowntimeLogEntry(root, JSON.stringify(marker)); - await appendDowntimeLogEntry(root, JSON.stringify({ ...marker, paneId: opts.paneId, enrichment: true })); - } - - /** Deps with a real (rolling-log-appending) recordPaneClose so idempotency works. */ - function monitorDeps(overrides: Partial = {}): DowntimeWorkerDeps { - return makeDeps({ - recordPaneClose: vi.fn(async (entry, cwd) => { - await appendPaneCloseLogEntry(cwd, entry); - }), - ...overrides, - }); - } - - async function paneCloseEntries(root: string) { - return (await readDowntimeLogEntries(root)).filter((e) => e.entryType === 'pane-close'); - } - - it('closes an intake pane and logs closed-as-intake-complete when the item reaches intake_complete', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-1', kind: 'intake', paneId: 'w1:p1', stage: 'idea', title: 'Intake me' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-1', title: 'Intake me', stage: 'intake_complete' }), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 1, records: [{ paneId: 'w1:p1' }] }), - closePane, - }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ checked: 1, logged: 1, closed: 1, errors: 0 }); - expect(closePane).toHaveBeenCalledWith('w1:p1', root, { itemId: 'WL-1', kind: 'intake' }); - const pc = await paneCloseEntries(root); - expect(pc).toHaveLength(1); - expect(pc[0]).toMatchObject({ - entryType: 'pane-close', - itemId: 'WL-1', - itemTitle: 'Intake me', - paneId: 'w1:p1', - kind: 'intake', - outcome: 'closed-as-intake-complete', - closed: true, - }); - }); - - it('closes a plan pane and logs closed-as-plan-complete when the item reaches plan_complete', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-2', kind: 'plan', paneId: 'w1:p2', stage: 'intake_complete' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-2', stage: 'plan_complete' }), - closePane, - }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ logged: 1, closed: 1 }); - expect(closePane).toHaveBeenCalledWith('w1:p2', root, { itemId: 'WL-2', kind: 'plan' }); - expect((await paneCloseEntries(root))[0]).toMatchObject({ outcome: 'closed-as-plan-complete', closed: true }); - }); - - it('closes an audit pane and records the pass/fail verdict (AC5)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-3', kind: 'audit', paneId: 'w1:p3', stage: 'in_review' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ - id: 'WL-3', stage: 'in_review', auditedAt: '2026-01-02T00:00:00.000Z', auditResult: false, - }), - closePane, - }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ logged: 1, closed: 1 }); - expect(closePane).toHaveBeenCalledWith('w1:p3', root, { itemId: 'WL-3', kind: 'audit' }); - expect((await paneCloseEntries(root))[0]).toMatchObject({ outcome: 'audit-failed', closed: true }); - }); - - it('NEVER closes an implement pane but still logs its in_review completion (AC4/AC6)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-4', kind: 'implement', paneId: 'w1:p4', stage: 'plan_complete' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-4', stage: 'in_review' }), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 1, records: [{ paneId: 'w1:p4' }] }), - closePane, - }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ logged: 1, closed: 0 }); - expect(closePane).not.toHaveBeenCalled(); - expect((await paneCloseEntries(root))[0]).toMatchObject({ - outcome: 'requires-attention', - reasonCode: 'reached-in-review', - closed: false, - }); - }); - - it('is idempotent: a second pass with no state change writes no duplicate entry', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-5', kind: 'plan', paneId: 'w1:p5', stage: 'intake_complete' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-5', stage: 'plan_complete' }), - closePane, - }); - - const first = await monitorDispatchedPanes(deps, root); - const second = await monitorDispatchedPanes(deps, root); - - expect(first).toMatchObject({ logged: 1, closed: 1 }); - expect(second).toMatchObject({ logged: 0, closed: 0 }); - expect(closePane).toHaveBeenCalledTimes(1); - expect(await paneCloseEntries(root)).toHaveLength(1); - }); - - it('logs a NEW entry when the item changes state again (agent-ended → plan_complete)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-6', kind: 'plan', paneId: 'w1:p6', stage: 'intake_complete' }); - const noLivePanes = vi.fn().mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }); - // First pass: the agent session has ended but the item is NOT terminal. - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-6', stage: 'intake_complete' }), - getRunningDowntimePanes: noLivePanes, - closePane: vi.fn().mockResolvedValue(true), - }); - await monitorDispatchedPanes(deps, root); - - // The item then advances — a second outcome must be recorded. - deps.getItemLifecycleState = vi.fn().mockResolvedValue({ id: 'WL-6', stage: 'plan_complete' }); - await monitorDispatchedPanes(deps, root); - - const outcomes = (await paneCloseEntries(root)).map((e) => e.outcome); - expect(outcomes).toEqual(['requires-attention', 'closed-as-plan-complete']); - }); - - it('does NOT act on unknown liveness when the item has not reached a terminal (fail-open)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-7', kind: 'plan', paneId: 'w1:p7', stage: 'intake_complete' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-7', stage: 'intake_complete' }), - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: false, error: 'herdr down' }), - closePane, - }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ checked: 1, logged: 0, closed: 0, errors: 0 }); - expect(closePane).not.toHaveBeenCalled(); - expect(await paneCloseEntries(root)).toEqual([]); - }); - - it('is fail-closed: a throwing closePane still logs the outcome (closed:false) and never throws (AC7)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-8', kind: 'intake', paneId: 'w1:p8', stage: 'idea' }); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-8', stage: 'intake_complete' }), - closePane: vi.fn().mockRejectedValue(new Error('herdr: pane not found')), - }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ checked: 1, logged: 1, closed: 0, errors: 0 }); - expect((await paneCloseEntries(root))[0]).toMatchObject({ closed: false }); - }); - - it('is fail-closed: a throwing item lookup counts an error and never throws (AC7)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-9', kind: 'plan', paneId: 'w1:p9' }); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockRejectedValue(new Error('wl exploded')), - closePane: vi.fn().mockResolvedValue(true), - }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ checked: 1, logged: 0, errors: 1 }); - }); - - it('continues past a bad pane so later panes still process (AC7)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-BAD', kind: 'plan', paneId: 'w1:pA' }); - await seedDispatch(root, { itemId: 'WL-GOOD', kind: 'intake', paneId: 'w1:pB' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn(async (itemId: string) => { - if (itemId === 'WL-BAD') throw new Error('wl exploded'); - return { id: itemId, stage: 'intake_complete' }; - }), - closePane, - }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ checked: 2, logged: 1, closed: 1, errors: 1 }); - expect(closePane).toHaveBeenCalledTimes(1); - expect(closePane).toHaveBeenCalledWith('w1:pB', root, { itemId: 'WL-GOOD', kind: 'intake' }); - }); - - it('is a no-op when the item-state dep is absent (legacy callers)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-10', kind: 'plan', paneId: 'w1:p10', stage: 'intake_complete' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = makeDeps({ closePane }); - - const res = await monitorDispatchedPanes(deps, root); - - expect(res).toMatchObject({ checked: 0, logged: 0, closed: 0, errors: 0 }); - expect(closePane).not.toHaveBeenCalled(); - }); - - it('logs requires-attention for an item awaiting producer review (AC3)', async () => { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-11', kind: 'plan', paneId: 'w1:p11', stage: 'intake_complete' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ - id: 'WL-11', stage: 'intake_complete', needsProducerReview: true, - }), - closePane, - }); - - await monitorDispatchedPanes(deps, root); - - expect(closePane).toHaveBeenCalledWith('w1:p11', root, { itemId: 'WL-11', kind: 'plan' }); - expect((await paneCloseEntries(root))[0]).toMatchObject({ - outcome: 'requires-attention', - reasonCode: 'producer-review', - closed: true, - }); - }); - - // ── Warn-level observability (WL-0MU4USJ07009JYL8 AC2/AC6) ────────── - // Every fail-closed catch must emit a stderr warn (never silent) so a - // production failure is diagnosable without a debugger. - function stderrMessages(spy: ReturnType): string { - return spy.mock.calls.map((c) => String(c[0])).join('\n'); - } - - it('warns on a failed pane close (AC2)', async () => { - const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - try { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-W1', kind: 'intake', paneId: 'w1:pW1' }); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-W1', stage: 'intake_complete' }), - closePane: vi.fn().mockRejectedValue(new Error('herdr: boom')), - }); - await monitorDispatchedPanes(deps, root); - expect(stderrMessages(spy)).toContain('pane close failed for w1:pW1'); - } finally { - spy.mockRestore(); - } - }); - - it('warns on a failed item lookup (AC2)', async () => { - const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - try { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-W2', kind: 'plan', paneId: 'w1:pW2' }); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockRejectedValue(new Error('wl exploded')), - }); - await monitorDispatchedPanes(deps, root); - expect(stderrMessages(spy)).toContain('item lookup failed for WL-W2'); - } finally { - spy.mockRestore(); - } - }); - - it('warns on a failed pane-close log write (AC2)', async () => { - const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - try { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-W3', kind: 'plan', paneId: 'w1:pW3' }); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-W3', stage: 'plan_complete' }), - closePane: vi.fn().mockResolvedValue(true), - recordPaneClose: vi.fn().mockRejectedValue(new Error('disk full')), - }); - const res = await monitorDispatchedPanes(deps, root); - expect(res).toMatchObject({ logged: 0, errors: 1 }); - expect(stderrMessages(spy)).toContain('pane-close log write failed for w1:pW3'); - } finally { - spy.mockRestore(); - } - }); - - it('warns when the live-pane query fails (AC2) and still fails open', async () => { - const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); - try { - const root = makeRoot(); - await seedDispatch(root, { itemId: 'WL-W4', kind: 'plan', paneId: 'w1:pW4' }); - const closePane = vi.fn().mockResolvedValue(true); - const deps = monitorDeps({ - getItemLifecycleState: vi.fn().mockResolvedValue({ id: 'WL-W4', stage: 'plan_complete' }), - getRunningDowntimePanes: vi.fn().mockRejectedValue(new Error('herdr down')), - closePane, - }); - const res = await monitorDispatchedPanes(deps, root); - // The terminal-stage close still happens (liveness is only needed for - // agent-done detection) — fail-open, not fail-closed, on liveness. - expect(res).toMatchObject({ logged: 1, closed: 1 }); - expect(stderrMessages(spy)).toContain('live-pane query threw'); - } finally { - spy.mockRestore(); - } - }); -}); - -// ── Worker tick ↔ pane-lifecycle integration (WL-0MU4US5MP001JFEN) ──── -// -// Mechanism B is DISABLED (WL-0MUMEKDK0008LKH8): the worker tick must NOT -// invoke the dispatch monitor, so the scheduled reaper (Mechanism A) is the -// only active auto-close path. These tests pin that disabled behaviour and -// that Mechanism A still runs from the tick. -describe('worker tick pane-lifecycle integration (WL-0MU4US5MP001JFEN) [disabled]', () => { - const roots: string[] = []; - - afterEach(() => { - for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); - }); - - function makeRoot(): string { - const root = mkdtempSync(join(tmpdir(), 'pane-monitor-tick-')); - roots.push(root); - return root; - } - - async function seedDispatch(root: string, itemId: string, kind: string, paneId: string): Promise { - const marker = { - itemId, kind, dispatchedAt: new Date().toISOString(), cwd: root, title: itemId, stage: 'intake_complete', - }; - await appendDowntimeLogEntry(root, JSON.stringify(marker)); - await appendDowntimeLogEntry(root, JSON.stringify({ ...marker, paneId, enrichment: true })); - } - - function makeMonitorWorker( - root: string, - deps: Partial, - paneClose?: { enabled: boolean; idleThresholdMinutes: number }, - ) { - const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - const worker = createDowntimeWorker({ - poller, - deps: makeDeps({ getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), ...deps }), - config: () => ({ - enabled: true, - thresholdMs: 1, - requiredFreeSlots: 0, - model: 'plan', - cwd: root, - noCandidateCooldownMs: 3_600_000, - paneLifecycleIntervalMs: 0, - ...(paneClose ? { paneClose } : {}), - }), - }); - return worker; - } - - it('does NOT invoke the dispatch monitor from the worker tick (Mechanism B disabled, WL-0MUMEKDK0008LKH8)', async () => { - const root = makeRoot(); - await seedDispatch(root, 'WL-TICK', 'plan', 'w9:p1'); - const closePane = vi.fn().mockResolvedValue(true); - const getItemLifecycleState = vi.fn().mockResolvedValue({ id: 'WL-TICK', stage: 'plan_complete' }); - const worker = makeMonitorWorker(root, { - getItemLifecycleState, - getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: true, count: 1, records: [{ paneId: 'w9:p1' }] }), - closePane, - }); - - await worker.tick(); - // Give a hypothetically-enabled (fire-and-forget) monitor time to act - // before asserting the negative, so the assertion is meaningful. - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(getItemLifecycleState).not.toHaveBeenCalled(); - expect(closePane).not.toHaveBeenCalled(); - const pc = (await readDowntimeLogEntries(root)).filter((e) => e.entryType === 'pane-close'); - expect(pc).toHaveLength(0); - }); +import { createRoundRobinRegistry } from './downtime-round-robin.js'; +import { + readCoordinationLogEntries, + type CoordinationLogEntry, +} from './downtime-log.js'; +import { + LEASE_FILE, + LEADER_LOCK_FILE, + isLeaseValid, +} from './leader-election.js'; +import { + writeCoordinationFile, + type CoordinationEntry, +} from './coordination.js'; +import { + statusFixtures, + ambiguousMissingFieldsRaw, + idleAllSlotsFree, + perSlotAllFree, + perSlotTwoFree, + perSlotOneProcessing, + jsonResponseFixture, + makeDeps, + createTempDirTracker, + ago, + writeDowntimeLog, + writeRawDowntimeLog, +} from './downtime-worker.fixtures.js'; +import { + resumeStalledPane, + _resetStalledResumeInFlight, + type ResumeStalledPaneDeps, + type StalledPaneCandidate, +} from './stalled-work.js'; - it('still runs the scheduled reaper (Mechanism A) from the worker tick', async () => { - const root = makeRoot(); - const getItemLifecycleState = vi.fn().mockResolvedValue({ id: 'WL-TICK', stage: 'plan_complete' }); - const runPaneCloseReaper = vi.fn().mockResolvedValue(undefined); - const worker = makeMonitorWorker( - root, - { getItemLifecycleState, runPaneCloseReaper }, - { enabled: true, idleThresholdMinutes: 30 }, - ); +// ── Fixture coherence (AC1) ─────────────────────────────────────────── - await worker.tick(); +describe('downtime worker fixtures', () => { + it.each(statusFixtures)( + '$name is classified idle=$expectedIdle by isIdleStatus (N=0)', + (fixture) => { + expect(isIdleStatus(fixture.status, 0)).toBe(fixture.expectedIdle); + }, + ); - await vi.waitFor(() => expect(runPaneCloseReaper).toHaveBeenCalledWith( - root, - { idleThresholdMinutes: 30, ledgerPath: undefined }, - )); - // Mechanism B remains off — the reaper is the only active auto-close path. - expect(getItemLifecycleState).not.toHaveBeenCalled(); + it('a raw payload with missing slot fields is treated as busy (fail-closed)', () => { + const partial = ambiguousMissingFieldsRaw as unknown as LlamaStatus; + expect(isIdleStatus(partial, 0)).toBe(false); }); }); @@ -12195,27 +634,18 @@ describe('per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK)', () => { // RELEASED (otherwise the duplicate-dispatch marker guard would skip the // item before the budget filter runs). const WINDOW_MS = 1_000; - const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); - const tempDirs: string[] = []; + const tempDirs = createTempDirTracker(); afterEach(() => { - for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + tempDirs.cleanup(); }); function makeCwd(): string { - const dir = mkdtempSync(join(tmpdir(), 'dt-attempt-budget-')); - tempDirs.push(dir); - return dir; - } - - function writeRawLog(cwd: string, raw: string): void { - mkdirSync(join(cwd, '.worklog'), { recursive: true }); - writeFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), raw, 'utf8'); + return tempDirs.make('dt-attempt-budget-'); } - function writeLog(cwd: string, entries: Array>): void { - writeRawLog(cwd, entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); - } + const writeRawLog = writeRawDowntimeLog; + const writeLog = writeDowntimeLog; const implementHead = (id: string): DowntimeHerdrItem => ({ id, @@ -12901,17 +1331,14 @@ describe('own-backlog fallback safety gates (WL-0MUZPBRX3007STFK)', () => { // RELEASED (the dispatched-marker guard) while the attempt-budget filter // still counts them. const WINDOW_MS = 1_000; - const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); - const tempDirs: string[] = []; + const tempDirs = createTempDirTracker(); afterEach(() => { - for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + tempDirs.cleanup(); }); function makeDir(prefix: string): string { - const dir = mkdtempSync(join(tmpdir(), prefix)); - tempDirs.push(dir); - return dir; + return tempDirs.make(prefix); } /** An eligible non-critical plan_complete out-of-view item (implement tier). */ @@ -13033,20 +1460,14 @@ describe('own-backlog fallback safety gates (WL-0MUZPBRX3007STFK)', () => { const coordinationDir = makeDir('dt-oov-budget-'); const cwd = makeDir('dt-oov-budget-root-'); // Three same-stage implement markers exhaust the per-item/per-kind cap. - mkdirSync(join(cwd, '.worklog'), { recursive: true }); - writeFileSync( - join(cwd, '.worklog', DOWNTIME_LOG_FILE), - [0, 1, 2] - .map(() => - JSON.stringify({ - itemId: 'WL-OOV-BUDGET', - kind: 'implement', - stage: 'plan_complete', - dispatchedAt: ago(60_000), - }), - ) - .join('\n') + '\n', - 'utf8', + writeDowntimeLog( + cwd, + [0, 1, 2].map(() => ({ + itemId: 'WL-OOV-BUDGET', + kind: 'implement', + stage: 'plan_complete', + dispatchedAt: ago(60_000), + })), ); const deps = makeDeps({ getHerdrListHead: ownRoot([ diff --git a/packages/herdr/src/downtime-worker.tiers.test.ts b/packages/herdr/src/downtime-worker.tiers.test.ts new file mode 100644 index 000000000..7204183c7 --- /dev/null +++ b/packages/herdr/src/downtime-worker.tiers.test.ts @@ -0,0 +1,1294 @@ +/** + * packages/herdr/src/downtime-worker.tiers.test.ts — dispatch tier tests + * + * Split out of the monolithic `downtime-worker.test.ts` + * (WL-0MV28X3SL005R5KX / WL-0MV4VCZE9001BM06) to keep each test file focused + * on one concern. + * + * Covers the dispatch tier matrix and candidate selection/parsing: + * - `dispatch implement tier` — implement-tier priority and fall-through, + * - `dispatch code-freeze gate` — free-slot minimums under code freeze, + * - `dispatch scheduled-prompts tier` — scheduled-prompt dispatch, + * - `implement selection (selectImplementCandidate)` — candidate selection, + * - `parseImplementCandidatesOutput` — fail-closed output parsing, + * - `implement prompt & pane helpers` — prompt/pane argument helpers, + * - `audit selection (selectAuditCandidate)` — audit candidate selection, + * - `audit selection 7-day recency (selectAuditCandidate)` — recency gate, + * - `audit selection dispatched-marker exclusion (selectAuditCandidate)`, + * - `parseAuditCandidatesOutput` — fail-closed output parsing, + * - `parseInProgressOutput (active-audit single-flight)`, + * - `audit prompt & pane helpers` — prompt/pane argument helpers, + * - `plan/intake selection (selectNextCandidate)` — plan/intake fall-through, + * - `parseNextCandidatesOutput` — fail-closed output parsing, + * - `buildDowntimeDispatchComment` — dispatch comment formatting, and + * - `single-flight dispatch guard` — one in-flight dispatch at a time. + * + * Shared scaffolding (`makeDeps`, `ago`, etc.) is imported from + * `downtime-worker.fixtures.ts`; no setup logic is re-implemented here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + buildDowntimeDispatchComment, + buildDowntimePaneArgs, + buildDowntimePrompt, + dispatchDowntimeWork, + parseAuditCandidatesOutput, + parseImplementCandidatesOutput, + parseInProgressOutput, + parseNextCandidatesOutput, + selectAuditCandidate, + selectImplementCandidate, + selectNextCandidate, + skillKindFromPrompt, + toDowntimeCandidate, + type AuditCandidate, + type DowntimeCandidate, + type DowntimeStage, + type ImplementCandidate, + type ScheduledPrompt, +} from './downtime-worker.js'; +import { ago, makeDeps } from './downtime-worker.fixtures.js'; + +// ── Implement dispatch tier (WL-0MSMAYIKX005LLO4) ──────────────────── + +describe('dispatch implement tier', () => { + const implementCandidate: DowntimeCandidate = { + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }; + + it('dispatches /skill:implement on the implement candidate after the audit tier', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledWith('/repo'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-IMP', { status: 'open', stage: 'plan_complete' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:implement WL-IMP'), + { model: 'plan', cwd: '/repo', itemId: 'WL-IMP', itemTitle: 'Implement me' }, + ); + }); + + it('records the implement dispatch with kind implement', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-IMP'); + expect(event.kind).toBe('implement'); + expect(event.cwd).toBe('/repo'); + }); + + it('dispatch priority is audit → implement → plan → intake (audit stays first)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' } }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Audit wins — the implement tier is never consulted when an audit + // candidate exists (audit is the release gate). + expect(outcome.kind).toBe('audit'); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(deps.getNextItem).not.toHaveBeenCalled(); + }); + + it('falls back to the implement tier when no audit candidate exists, and audit runs first', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('implement'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).not.toHaveBeenCalled(); + }); + + it('falls back to plan (intake_complete) when no implement candidate exists', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('plan'); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + }); + + it('falls back to intake (idea) when no implement or plan candidate exists', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDE', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('intake'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete', '/repo'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea', '/repo'); + }); + + it('a wl next CLI error at the implement tier does NOT short-circuit the plan/intake fallback', async () => { + // Mirrors the tier-2 (intake_complete) error handling: getNextImplementCandidate + // is fail-closed at the deps boundary (a wl failure yields null), so the + // plan/intake tiers still run. The implement tier itself never signals a + // hard error — null IS the fail-closed shape (AC6). + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), // wl error → null + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-PLN', title: 'Plan me', stage: 'intake_complete' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('plan'); + expect(deps.getNextItem).toHaveBeenCalled(); + }); + + it('no-candidate when all tiers (audit, implement, plan, intake) are empty', async () => { + const deps = makeDeps(); // all four tiers empty + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextItem).toHaveBeenCalledTimes(2); + }); +}); + +// ── Code-freeze gate (WL-0MSQ0RPQP00636JY) ────────────────────────── +// The dispatcher must honour the ship-it code-freeze marker: while frozen +// (or ambiguous — fail-closed), the audit and implement tiers are skipped +// (no new implementation work / audits during a release) and the plan/intake +// tiers still dispatch (low-risk prep). The marker is re-read on EVERY +// dispatch (never cached) so a freeze that starts or ends mid-idle-period is +// honored on the next dispatch attempt. A freeze skip is reason +// 'code-freeze' — never 'no-candidate' — so it cannot trigger the worker's +// no-candidate cooldown: polling continues and resume is immediate. + +describe('dispatch code-freeze gate', () => { + const auditCandidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; + const implementCandidate: DowntimeCandidate = { id: 'WL-IMP', title: 'Implement me', stage: 'implement' }; + const planCandidate: DowntimeCandidate = { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }; + + it('skips the audit and implement tiers while frozen and still dispatches plan', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Frozen → the audit/implement tiers are never consulted. + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + // Plan/intake tiers still dispatch. + expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete', '/repo'); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-PLAN', { status: 'open', stage: 'intake_complete' }, '/repo'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-PLAN'), + expect.anything(), + ); + }); + + it('treats an ambiguous marker as frozen (fail-closed): no audit/implement, plan still dispatches', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + it('frozen with an empty plan/intake backlog reports code-freeze (never no-candidate)', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + // The freeze itself must never look like a genuine empty backlog: the + // worker must NOT enter the no-candidate cooldown (polling continues). + expect(outcome.reason).toBe('code-freeze'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('ambiguous with an empty plan/intake backlog also reports code-freeze', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); + }); + + it('frozen still surfaces wl CLI errors as wl-error (a strike, not hidden by the freeze)', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextItem: vi.fn().mockResolvedValue({ ok: false }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + }); + + it('re-reads the marker on every dispatch: implement/audit resume immediately when the freeze lifts', async () => { + // First dispatch: frozen → no audit/implement, empty plan/intake → code-freeze. + const freezeStatus = vi.fn().mockReturnValue('frozen'); + const deps = makeDeps({ + readCodeFreezeStatus: freezeStatus, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const frozenOutcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(frozenOutcome.reason).toBe('code-freeze'); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + + // The freeze lifts (marker now reads not-frozen): the SAME deps object is + // re-read on the next dispatch — no caching — and the implement tier + // dispatches again. + freezeStatus.mockReturnValue('not-frozen'); + deps.getNextImplementCandidate = vi.fn().mockResolvedValue(implementCandidate); + + const resumed = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(resumed.dispatched).toBe(true); + expect(resumed.kind).toBe('implement'); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:implement WL-IMP'), + expect.anything(), + ); + }); + + it('not-frozen behaves as before: the audit tier still runs', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledWith('/repo'); + }); +}); + +// ── Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX) ───────────────────── + +describe('dispatch scheduled-prompts tier', () => { + const duePrompt: ScheduledPrompt = { + id: '/skill:refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, + }; + + it('dispatches a due scheduled prompt FIRST (before audit/implement/plan/intake)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + // The backlog tiers must never be consulted — the prompt dispatches + // instead of reaching them (AC6). + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, + }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + status: 'open', + risk: 'low', + effort: 'small', + } as ImplementCandidate), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('scheduled'); + // The prompt text is the dispatch payload — not a skill+work-item wrap. + expect(deps.spawnAgentPane).toHaveBeenCalledWith('/skill:refactor', { + model: 'plan', + cwd: '/repo', + paneName: 'Downtime /skill:refactor', + }); + // No pre-dispatch claim — there is no work item (AC4). + expect(deps.claimItem).not.toHaveBeenCalled(); + // The backlog tiers are never reached (AC6). + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(deps.getNextItem).not.toHaveBeenCalled(); + }); + + it('persists lastTriggeredAt and writes the scheduled log marker before the spawn (AC4)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // The trigger timestamp is persisted (atomic config update) and the + // rolling log marker is written with kind scheduled + noItemComment. + expect(deps.recordScheduledPromptTrigger).toHaveBeenCalledWith( + '/repo', + '/skill:refactor', + expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + ); + expect(deps.recordDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + itemId: '/skill:refactor', + kind: 'scheduled', + cwd: '/repo', + noItemComment: true, + }), + ); + // Marker + persist before spawn: the dispatch is recorded before the + // pane opens (fail-closed: an unrecorded dispatch never runs). + const persistOrder = + (deps.recordScheduledPromptTrigger as ReturnType).mock.invocationCallOrder[0]; + const markerOrder = + (deps.recordDispatch as ReturnType).mock.invocationCallOrder[0]; + const spawnOrder = + (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(persistOrder).toBeLessThan(markerOrder); + expect(markerOrder).toBeLessThan(spawnOrder); + }); + + it('falls through to the existing tiers when no prompt is due', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + }); + + it('skips the scheduled tier while frozen (frozen OR ambiguous are fail-closed, AC5)', async () => { + for (const freezeStatus of ['frozen', 'ambiguous'] as const) { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue(freezeStatus), + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Scheduled prompts are held by the same freeze gate as audit/implement + // (no new code changes during a release) — the tier is never consulted. + expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-PLAN'), + expect.anything(), + ); + } + }); + + it('a frozen+empty plan/intake backlog reports code-freeze even when a prompt is due (AC5)', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('aborts before spawn when the lastTriggeredAt persist fails (fail-closed, AC4)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + recordScheduledPromptTrigger: vi.fn().mockResolvedValue(false), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('marker-write-failed'); + // No log record, no pane: an unrecorded dispatch never runs; the entry + // stays due for the next idle slot. + expect(deps.recordDispatch).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('aborts before spawn when the log marker cannot be written (fail-closed, AC4)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + recordDispatch: vi.fn().mockResolvedValue(false), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('marker-write-failed'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('records a spawn-failure trace and resolves spawn-failed when the pane fails (AC4)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('spawn-failed'); + expect(outcome.error).toBe('ENOENT'); + expect(deps.recordDispatchFailure).toHaveBeenCalledWith( + expect.objectContaining({ + itemId: '/skill:refactor', + kind: 'scheduled', + error: 'ENOENT', + noItemComment: true, + }), + ); + }); + + it('reports no-candidate (the cooldown trigger) when no prompt is due and the backlog is empty (AC6)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + }); + + it('buildDowntimePaneArgs honours an explicit paneName (Downtime for scheduled prompts)', () => { + const args = buildDowntimePaneArgs('plan', '/skill:refactor', { + model: 'plan', + cwd: '/repo', + paneName: 'Downtime /skill:refactor', + }); + expect(args).toEqual([ + '--pane-name', + 'Downtime /skill:refactor', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + '/skill:refactor', + ]); + }); +}); + +// ── Implement candidate selection (WL-0MSMAYIKX005LLO4) ────────────── + +describe('implement selection (selectImplementCandidate)', () => { + const open: ImplementCandidate = { + id: 'WL-OPEN', + title: 'open item', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 100, + }; + const completed: ImplementCandidate = { + id: 'WL-DONE', + title: 'completed item', + status: 'completed', + risk: 'low', + effort: 'small', + sortIndex: 200, + }; + + it('selects the first open candidate in wl next priority order (ascending sortIndex)', () => { + expect(selectImplementCandidate([open, completed])?.id).toBe('WL-OPEN'); + }); + + it('excludes completed candidates (status=open client-side filter, AC2)', () => { + expect(selectImplementCandidate([completed, { ...open, id: 'WL-OPEN2', sortIndex: 300 }])?.id).toBe('WL-OPEN2'); + }); + + it('returns null when no open candidate exists', () => { + expect(selectImplementCandidate([completed])).toBeNull(); + }); + + it('returns null on an empty list', () => { + expect(selectImplementCandidate([])).toBeNull(); + }); + + it('sorts ascending by sortIndex (wl next priority order preserved)', () => { + const low = { ...open, id: 'WL-LOW', sortIndex: 50 }; + const high = { ...open, id: 'WL-HIGH', sortIndex: 900 }; + expect(selectImplementCandidate([high, low])?.id).toBe('WL-LOW'); + }); + + it('excludes candidates present in the dispatched marker set (kind implement)', () => { + const dispatched = new Set(['WL-DUP']); + const dup = { ...open, id: 'WL-DUP', sortIndex: 10 }; + const fresh = { ...open, id: 'WL-FRESH', sortIndex: 20 }; + expect(selectImplementCandidate([dup, fresh], dispatched)?.id).toBe('WL-FRESH'); + }); + + it('an empty dispatched set is a no-op', () => { + expect(selectImplementCandidate([open], new Set())?.id).toBe('WL-OPEN'); + }); + + // ── Risk/effort threshold boundaries (AC1) ────────────────────────── + // Belt-and-suspenders client-side guard: even though `wl next --risk low + // --effort small` filters server-side, the herdr tier verifies the + // thresholds again so a malformed/absent server filter can never dispatch + // a Medium+/Large+ item (fail-closed). + + it('risk ≤ Medium eligible (High/Critical excluded)', () => { + const low = { ...open, id: 'WL-LOW', risk: 'low' as const }; + const medium = { ...open, id: 'WL-MED', risk: 'medium' as const }; + const high = { ...open, id: 'WL-HIGH-R', risk: 'high' as const }; + const critical = { ...open, id: 'WL-CRIT', risk: 'critical' as const }; + // medium is first eligible in input order (all share sortIndex 100) + expect(selectImplementCandidate([medium, low, high, critical, open])?.id).toBe('WL-MED'); + expect(selectImplementCandidate([medium])?.id).toBe('WL-MED'); + expect(selectImplementCandidate([low])?.id).toBe('WL-LOW'); + expect(selectImplementCandidate([high])).toBeNull(); + expect(selectImplementCandidate([critical])).toBeNull(); + }); + + it('effort ≤ Medium eligible (Large/Extra Large excluded)', () => { + const xs = { ...open, id: 'WL-XS', effort: 'xs' as const }; + const small = { ...open, id: 'WL-S', effort: 'small' as const }; + const medium = { ...open, id: 'WL-MED-E', effort: 'medium' as const }; + const large = { ...open, id: 'WL-LARGE', effort: 'large' as const }; + const xl = { ...open, id: 'WL-XL', effort: 'xl' as const }; + // medium is first eligible in input order (all share sortIndex 100) + expect(selectImplementCandidate([medium, large, xl, open])?.id).toBe('WL-MED-E'); + expect(selectImplementCandidate([xs])?.id).toBe('WL-XS'); + expect(selectImplementCandidate([small])?.id).toBe('WL-S'); + expect(selectImplementCandidate([medium])?.id).toBe('WL-MED-E'); + expect(selectImplementCandidate([large])).toBeNull(); + expect(selectImplementCandidate([xl])).toBeNull(); + }); + + it('unset/empty risk or effort items are excluded (fail-closed)', () => { + const noRisk = { ...open, id: 'WL-NORISK', risk: undefined }; + const noEffort = { ...open, id: 'WL-NOEFFORT', effort: undefined }; + expect(selectImplementCandidate([noRisk, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([noEffort, open])?.id).toBe('WL-OPEN'); + expect(selectImplementCandidate([noRisk])).toBeNull(); + expect(selectImplementCandidate([noEffort])).toBeNull(); + }); + + it('recognizes long-form effort spellings (Small / Medium / Extra Small)', () => { + const small = { ...open, id: 'WL-S', effort: 'Small' as const }; + const medium = { ...open, id: 'WL-M', effort: 'Medium' as const }; + const extraSmall = { ...open, id: 'WL-ES', effort: 'Extra Small' as const }; + expect(selectImplementCandidate([small])?.id).toBe('WL-S'); + expect(selectImplementCandidate([medium])?.id).toBe('WL-M'); + expect(selectImplementCandidate([extraSmall])?.id).toBe('WL-ES'); + }); + + it('handles verbose risk/effort descriptions (agent-produced fields)', () => { + const verboseRisk = { ...open, id: 'WL-VERBOSE-RISK', risk: 'medium — NVIDIA driver changes can affect GPU functionality system-wide. Wrong driver version could worsen the problem. Mitigation: test in non-production context first if possible; have rollback plan.' }; + const verboseEffort = { ...open, id: 'WL-VERBOSE-EFFORT', effort: '1–4 hours (estimate: o=1, m=2, p=4) — Small. Diagnostic investigation + targeted fix on a single system. No code changes expected.' }; + // Verbose descriptions with em-dash should extract the leading keyword + expect(selectImplementCandidate([verboseRisk])?.id).toBe('WL-VERBOSE-RISK'); + expect(selectImplementCandidate([verboseEffort])?.id).toBe('WL-VERBOSE-EFFORT'); + // High risk should still be excluded even with verbose description + const highVerbose = { ...open, id: 'WL-HIGH-V', risk: 'high — something went wrong' }; + expect(selectImplementCandidate([highVerbose])).toBeNull(); + }); + + it('regression: a critical item with verbose risk/effort fields wins over a medium item (WL-0MSN3FWV5008KQE9 dispatch)', () => { + // Real-world regression: the downtime dispatcher skipped the critical NVIDIA + // item because its risk/effort fields carry descriptions, selecting the + // medium mode-switch item instead. + const nvidia = { + ...open, + id: 'WL-0MT1KJNDK004SNPK', + title: 'Fix NVIDIA kernel/userspace driver API mismatch (chrome-headless)', + risk: 'Medium — NVIDIA driver changes can affect GPU functionality system-wide. Wrong driver version could worsen the problem. Mitigation: test in non-production context first if possible; have rollback plan.', + effort: '1–4 hours (estimate: o=1, m=2, p=4) — Small. Diagnostic investigation + targeted fix on a single system. No code changes expected.', + sortIndex: 900, + priority: 'critical', + }; + const modeSwitch = { + ...open, + id: 'WL-0MSN3FWV5008KQE9', + title: 'Herdr plugin: activity-gated proxy mode switching', + risk: 'Medium', + effort: 'Small', + sortIndex: 1600, + priority: 'medium', + }; + // Critical (sortIndex 900) must be selected ahead of medium (sortIndex 1600) + expect(selectImplementCandidate([modeSwitch, nvidia])?.id).toBe('WL-0MT1KJNDK004SNPK'); + }); +}); + +describe('parseImplementCandidatesOutput', () => { + it('parses the wl next -n N { workItems: [{ workItem }] } shape', () => { + const stdout = JSON.stringify({ + success: true, + count: 2, + requested: 10, + results: [ + { workItem: { id: 'WL-A', title: 'A', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-B', title: 'B', status: 'open', risk: 'low', effort: 'xs' } }, + ], + workItems: [ + { workItem: { id: 'WL-A', title: 'A', status: 'open', risk: 'low', effort: 'small' } }, + { workItem: { id: 'WL-B', title: 'B', status: 'open', risk: 'low', effort: 'xs' } }, + ], + }); + const parsed = parseImplementCandidatesOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed).toHaveLength(2); + expect(parsed?.[0]).toMatchObject({ id: 'WL-A', status: 'open', risk: 'low', effort: 'small' }); + expect(parsed?.[1]).toMatchObject({ id: 'WL-B', status: 'open', effort: 'xs' }); + }); + + it('fails closed (null) on malformed JSON', () => { + expect(parseImplementCandidatesOutput('not json')).toBeNull(); + }); + + it('fails closed (null) on output without a workItems list', () => { + expect(parseImplementCandidatesOutput(JSON.stringify({ success: true }))).toBeNull(); + }); + + it('returns an empty array for an empty workItems list', () => { + expect(parseImplementCandidatesOutput(JSON.stringify({ success: true, count: 0, workItems: [] }))).toEqual([]); + }); + + it('skips entries without an id but keeps valid ones', () => { + const stdout = JSON.stringify({ + success: true, + workItems: [{ workItem: { title: 'no id' } }, { workItem: { id: 'WL-OK', title: 'ok' } }], + }); + const parsed = parseImplementCandidatesOutput(stdout); + expect(parsed?.length).toBe(1); + expect(parsed?.[0].id).toBe('WL-OK'); + }); +}); + +describe('implement prompt & pane helpers', () => { + const candidate: DowntimeCandidate = { id: 'WL-IMP', title: 'Implement me', stage: 'implement' }; + + it('builds an /skill:implement prompt', () => { + const prompt = buildDowntimePrompt('implement', candidate); + expect(prompt).toContain('/skill:implement WL-IMP'); + }); + + it('builds a Downtime implement pane name', () => { + const args = buildDowntimePaneArgs('implement', 'Run /skill:implement WL-IMP — Implement me.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toContain('Downtime implement'); + expect(args).toContain('--no-focus'); + }); + + it('skillKindFromPrompt detects an implement prompt', () => { + expect(skillKindFromPrompt('Run /skill:implement WL-IMP — x.')).toBe('implement'); + }); + + // risk-effort kind (WL-0MTTSWCJR003OMN7) + it('skillKindFromPrompt detects a risk-effort prompt', () => { + expect(skillKindFromPrompt('Run /skill:effort-and-risk WL-RE — x.')).toBe('risk-effort'); + }); + + it('buildDowntimeDispatchComment renders /skill:implement', () => { + const comment = buildDowntimeDispatchComment('WL-IMP', 'implement', '2026-01-01T00:00:00.000Z'); + expect(comment).toContain('/skill:implement WL-IMP'); + }); +}); + +describe('audit selection (selectAuditCandidate)', () => { + // Fixed clock so the 7-day recency filter is deterministic (all fixture + // dates fall within the window). + const NOW = new Date('2026-01-01T00:05:00.000Z').getTime(); + const fresh: AuditCandidate = { + id: 'FRESH', + title: 'fresh', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:30.000Z', + sortIndex: 200, + }; + const stale: AuditCandidate = { + id: 'STALE', + title: 'stale', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:02:00.000Z', // >60s later -> stale + sortIndex: 300, + }; + const unaudited: AuditCandidate = { id: 'NOAUDIT', title: 'no audit', sortIndex: 100 }; + + it('selects an item with a missing audit', () => { + expect(selectAuditCandidate([fresh, unaudited], NOW)?.id).toBe('NOAUDIT'); + }); + + it('does not select an item with a fresh audit', () => { + expect(selectAuditCandidate([fresh], NOW)).toBeNull(); + }); + + it('selects a stale item (audit older than the 60s buffer)', () => { + expect(selectAuditCandidate([fresh, stale], NOW)).toEqual(stale); + }); + + it('sorts ascending by sortIndex and returns the first', () => { + expect(selectAuditCandidate([stale, unaudited], NOW)?.id).toBe('NOAUDIT'); + expect(selectAuditCandidate([{ ...unaudited, sortIndex: 500 }, stale], NOW)?.id).toBe('STALE'); + }); + + it('returns null on an empty list', () => { + expect(selectAuditCandidate([], NOW)).toBeNull(); + }); + + it('never selects a covered child — children are never dispatched independently (AC2)', () => { + // A covered child: completed/in_review, no own fresh audit, parent has the + // fresh covering audit. The audit tier is root-only, and this client-side + // guard is belt-and-suspenders for a leaking/faulty response. + const coveredChild: AuditCandidate = { + id: 'WL-COVERED-CHILD', + title: 'Covered child', + parentId: 'WL-PARENT', + updatedAt: '2026-01-01T00:00:00.000Z', + sortIndex: 1, + }; + expect(selectAuditCandidate([coveredChild, unaudited], NOW)?.id).toBe('NOAUDIT'); + expect(selectAuditCandidate([coveredChild], NOW)).toBeNull(); + }); + + it('never selects an uncovered child either (AC2)', () => { + // Uncovered child: parent demoted/stale, so no covering audit — it must + // still never enter the audit dispatch tier (flagged, not dispatched; + // WL-0MUBVH9FV0027COG owns durable uncovered reporting). + const uncoveredChild: AuditCandidate = { + id: 'WL-UNCOVERED-CHILD', + title: 'Uncovered child', + parentId: 'WL-DEMOTED-PARENT', + updatedAt: '2026-01-01T00:00:00.000Z', + sortIndex: 1, + }; + expect(selectAuditCandidate([uncoveredChild], NOW)).toBeNull(); + }); + + it('classifies the 60s freshness boundary correctly', () => { + // auditedAt exactly 60s before updatedAt -> stale (selected) + const boundaryStale: AuditCandidate = { + id: 'B1', + title: 'Boundary stale', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:01:00.000Z', + }; + // auditedAt just under 60s before updatedAt -> fresh (not selected) + const boundaryFresh: AuditCandidate = { + id: 'B2', + title: 'Boundary fresh', + auditedAt: '2026-01-01T00:00:30.000Z', + updatedAt: '2026-01-01T00:01:00.000Z', + }; + expect(selectAuditCandidate([boundaryStale], NOW)?.id).toBe('B1'); + expect(selectAuditCandidate([boundaryFresh], NOW)).toBeNull(); + }); + + it('does not select an item whose updatedAt did not move (flag-only flip, WL-0MSN6ZCTN0027U2R)', () => { + // The worklog core guarantees (packages/shared/src/database.ts update()) + // that flipping needsProducerReview does NOT bump updatedAt — so after + // the flip the item still has its pre-audit updatedAt and the audit + // remains fresh. selectAuditCandidate must therefore not re-dispatch + // a redundant /skill:audit for it. + const afterFlagFlip: AuditCandidate = { + id: 'FLAGFLIP', + title: 'flagged for review', + auditedAt: '2026-01-01T00:00:30.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', // unchanged by the flag flip + sortIndex: 10, + }; + expect(selectAuditCandidate([afterFlagFlip], NOW)).toBeNull(); + }); +}); + +describe('audit selection 7-day recency (selectAuditCandidate)', () => { + const DAY_MS = 24 * 60 * 60 * 1000; + const NOW = new Date('2026-01-01T12:00:00.000Z').getTime(); + const recentStale: AuditCandidate = { + id: 'RECENT', + title: 'modified 6 days ago', + // audited >60s before updatedAt -> stale audit (selected by freshness) + auditedAt: new Date(NOW - 6 * DAY_MS - 120_000).toISOString(), + updatedAt: new Date(NOW - 6 * DAY_MS).toISOString(), + sortIndex: 100, + }; + const ancientStale: AuditCandidate = { + id: 'ANCIENT', + title: 'modified 8 days ago', + auditedAt: new Date(NOW - 8 * DAY_MS - 120_000).toISOString(), + updatedAt: new Date(NOW - 8 * DAY_MS).toISOString(), + sortIndex: 100, + }; + + it('selects a stale-audit item modified within the last 7 days', () => { + expect(selectAuditCandidate([ancientStale, recentStale], NOW)?.id).toBe('RECENT'); + }); + + it('excludes stale-audit items not modified within the last 7 days', () => { + expect(selectAuditCandidate([ancientStale], NOW)).toBeNull(); + }); + + it('includes a candidate with a missing updatedAt (recency cannot be verified)', () => { + const missing: AuditCandidate = { id: 'MISSING', title: 'no updatedAt', sortIndex: 100 }; + expect(selectAuditCandidate([missing], NOW)?.id).toBe('MISSING'); + }); + + it('excludes a candidate with an unparseable updatedAt (fail-closed)', () => { + const garbage: AuditCandidate = { + id: 'GARBAGE', + title: 'bad date', + updatedAt: 'not-a-date', + sortIndex: 100, + }; + expect(selectAuditCandidate([garbage], NOW)).toBeNull(); + }); + + it('includes a candidate modified exactly 7 days ago (boundary inclusive)', () => { + const boundary: AuditCandidate = { + id: 'EDGE', + title: 'exactly 7 days', + auditedAt: new Date(NOW - 7 * DAY_MS - 120_000).toISOString(), + updatedAt: new Date(NOW - 7 * DAY_MS).toISOString(), + sortIndex: 100, + }; + expect(selectAuditCandidate([boundary], NOW)?.id).toBe('EDGE'); + }); + + it('excludes a candidate modified 7 days and 1ms ago', () => { + const justOutside: AuditCandidate = { + id: 'OUT', + title: '7 days + 1ms', + auditedAt: new Date(NOW - 7 * DAY_MS - 120_001).toISOString(), + updatedAt: new Date(NOW - 7 * DAY_MS - 1).toISOString(), + sortIndex: 100, + }; + expect(selectAuditCandidate([justOutside], NOW)).toBeNull(); + }); +}); + +describe('audit selection dispatched-marker exclusion (selectAuditCandidate)', () => { + // Regression (WL-0MSGTLSUT002NF29): an item already dispatched for audit + // by the downtime worker must never be re-selected while it still lacks a + // fresh audit — even though it is completed/in_review (the dispatched run + // reverts the status without recording a fresh audit). + const NOW = new Date('2026-01-01T00:05:00.000Z').getTime(); + const dispatchedUnaudited: AuditCandidate = { + id: 'WL-DUP', + title: 'already dispatched, no audit', + sortIndex: 100, + }; + const otherUnaudited: AuditCandidate = { + id: 'WL-OTHER', + title: 'not dispatched', + sortIndex: 200, + }; + + it('excludes a candidate present in the dispatched set with no fresh audit (regression)', () => { + const dispatched = new Set(['WL-DUP']); + expect(selectAuditCandidate([dispatchedUnaudited], NOW, dispatched)).toBeNull(); + }); + + it('selects another candidate when the first is excluded', () => { + const dispatched = new Set(['WL-DUP']); + expect(selectAuditCandidate([dispatchedUnaudited, otherUnaudited], NOW, dispatched)?.id).toBe( + 'WL-OTHER', + ); + }); + + it('composes with audit freshness: a fresh audit still excludes the item (unchanged)', () => { + const freshDispatched: AuditCandidate = { + id: 'WL-FRESH', + title: 'fresh audit since dispatch', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:30.000Z', + sortIndex: 100, + }; + const dispatched = new Set(['WL-FRESH']); + // isAuditFresh governs: fresh → not a candidate, even without the marker. + expect(selectAuditCandidate([freshDispatched], NOW)).toBeNull(); + // Marker + fresh audit → still not a candidate (unchanged). + expect(selectAuditCandidate([freshDispatched], NOW, dispatched)).toBeNull(); + }); + + it('a stale/absent audit plus an audit marker is excluded', () => { + const staleDispatched: AuditCandidate = { + id: 'WL-STALE', + title: 'stale audit since dispatch', + auditedAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:02:00.000Z', // >60s later -> stale + sortIndex: 100, + }; + expect(selectAuditCandidate([staleDispatched], NOW, new Set(['WL-STALE']))).toBeNull(); + }); + + it('an item without a marker is selected as before (empty set is a no-op)', () => { + expect(selectAuditCandidate([dispatchedUnaudited], NOW, new Set())?.id).toBe('WL-DUP'); + }); +}); + +describe('parseAuditCandidatesOutput', () => { + it('parses a { workItems: [...] } shape', () => { + const stdout = JSON.stringify({ + success: true, + count: 1, + workItems: [{ id: 'WL-1', title: 'A', sortIndex: 100, auditedAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:30.000Z' }], + }); + const parsed = parseAuditCandidatesOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed![0].id).toBe('WL-1'); + expect(parsed![0].sortIndex).toBe(100); + }); + + it('parses a bare array shape', () => { + const stdout = JSON.stringify([{ id: 'WL-2', title: 'B' }]); + expect(parseAuditCandidatesOutput(stdout)?.[0].id).toBe('WL-2'); + }); + + it('fails closed (null) on malformed JSON', () => { + expect(parseAuditCandidatesOutput('not json')).toBeNull(); + }); + + it('fails closed (null) on output without a list', () => { + expect(parseAuditCandidatesOutput(JSON.stringify({ success: false }))).toBeNull(); + }); + + it('skips items without an id but keeps valid ones', () => { + const stdout = JSON.stringify({ workItems: [{ title: 'no id' }, { id: 'WL-3', title: 'C' }] }); + const parsed = parseAuditCandidatesOutput(stdout); + expect(parsed).not.toBeNull(); + expect(parsed!.length).toBe(1); + expect(parsed![0].id).toBe('WL-3'); + }); + + it('returns an empty array for an empty workItems list', () => { + expect(parseAuditCandidatesOutput(JSON.stringify({ workItems: [] }))).toEqual([]); + }); + + it('parses parentId so child candidates can be excluded client-side (AC2)', () => { + const stdout = JSON.stringify({ + workItems: [ + { id: 'WL-ROOT', title: 'Root', sortIndex: 1 }, + { id: 'WL-CHILD', title: 'Child', parentId: 'WL-ROOT', sortIndex: 2 }, + ], + }); + const parsed = parseAuditCandidatesOutput(stdout); + expect(parsed?.[0].parentId).toBeUndefined(); + expect(parsed?.[1].parentId).toBe('WL-ROOT'); + }); +}); + +describe('parseInProgressOutput (active-audit single-flight)', () => { + it('parses bare workItems entries into their ids', () => { + const stdout = JSON.stringify({ success: true, count: 2, workItems: [{ id: 'WL-A' }, { id: 'WL-B' }] }); + expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A', 'WL-B']); + }); + + it('also accepts the { workItem } wrapper shape inside workItems', () => { + const stdout = JSON.stringify({ workItems: [{ workItem: { id: 'WL-A' } }, { workItem: { id: 'WL-B' } }] }); + expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A', 'WL-B']); + }); + + it('fails closed (null) on malformed JSON', () => { + expect(parseInProgressOutput('not json')).toBeNull(); + }); + + it('fails closed (null) on output without a list', () => { + expect(parseInProgressOutput(JSON.stringify({ success: false }))).toBeNull(); + }); + + it('skips entries without an id but keeps valid ones', () => { + const stdout = JSON.stringify({ workItems: [{ title: 'no id' }, { id: 'WL-A' }] }); + expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A']); + }); + + it('returns an empty set for an empty workItems list', () => { + expect([...parseInProgressOutput(JSON.stringify({ workItems: [] }))!]).toEqual([]); + }); +}); + +describe('audit prompt & pane helpers', () => { + const candidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; + + it('builds an /skill:audit prompt', () => { + const prompt = buildDowntimePrompt('audit', candidate); + expect(prompt).toContain('/skill:audit WL-AUD'); + }); + + it('builds a Downtime audit pane name', () => { + const args = buildDowntimePaneArgs('audit', 'Run /skill:audit WL-AUD — Audit me.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toContain('Downtime audit'); + expect(args).toContain('--no-focus'); + }); + + it('skillKindFromPrompt detects an audit prompt', () => { + expect(skillKindFromPrompt('Run /skill:audit WL-AUD — x.')).toBe('audit'); + expect(skillKindFromPrompt('Run /skill:plan WL-1 — x.')).toBe('plan'); + expect(skillKindFromPrompt('Run /skill:intake WL-2 — x.')).toBe('intake'); + }); + + it('buildDowntimeDispatchComment renders /skill:audit', () => { + const comment = buildDowntimeDispatchComment('WL-AUD', 'audit', '2026-01-01T00:00:00.000Z'); + expect(comment).toContain('/skill:audit WL-AUD'); + }); + + it('toDowntimeCandidate produces a stage-audit candidate', () => { + expect(toDowntimeCandidate({ id: 'X', title: 'T' })).toEqual({ + id: 'X', + title: 'T', + stage: 'audit', + }); + }); +}); + +// ── Plan/intake selection (RCA WL-0MSRBFFLN005W3VT RC-2 + amplifier) ── + +describe('plan/intake selection (selectNextCandidate)', () => { + const openCandidate = (id: string, sortIndex = 0, stage: DowntimeStage = 'intake_complete') => ({ + id, + title: id, + stage, + status: 'open', + sortIndex, + }); + + it('selects the first open candidate in wl next priority order (ascending sortIndex)', () => { + const selected = selectNextCandidate([ + openCandidate('WL-2', 20), + openCandidate('WL-1', 10), + ]); + expect(selected?.id).toBe('WL-1'); + }); + + it('excludes a candidate still at its dispatched-at stage (change-guard)', () => { + const selected = selectNextCandidate( + [openCandidate('WL-ONCE', 5), openCandidate('WL-NEXT', 20)], + new Map([['WL-ONCE', 'intake_complete']]), + ); + // WL-ONCE was dispatched for /skill:plan at intake_complete and is still + // at intake_complete → excluded; the next candidate is selected. + expect(selected?.id).toBe('WL-NEXT'); + }); + + it('releases a candidate whose stage advanced past its dispatched-at stage', () => { + // The marker recorded intake_complete but the candidate is now at idea + // (a different stage) → not suppressed. + const selected = selectNextCandidate( + [openCandidate('WL-FREE', 5, 'idea')], + new Map([['WL-FREE', 'intake_complete']]), + ); + expect(selected?.id).toBe('WL-FREE'); + }); + + it('a legacy marker without a recorded stage never suppresses selection', () => { + const selected = selectNextCandidate( + [openCandidate('WL-LEGACY', 5)], + new Map([['WL-LEGACY', '']]), + ); + expect(selected?.id).toBe('WL-LEGACY'); + }); + + it('excludes completed candidates (client-side open-status guard, amplifier fix)', () => { + const selected = selectNextCandidate([ + { ...openCandidate('WL-DONE', 5), status: 'completed' }, + openCandidate('WL-OPEN', 10), + ]); + expect(selected?.id).toBe('WL-OPEN'); + }); + + it('a candidate without a verifiable status is never selected (fail-closed)', () => { + const selected = selectNextCandidate([ + { id: 'WL-NO-STATUS', title: 'x', stage: 'intake_complete' }, + ]); + expect(selected).toBeNull(); + }); + + it('returns null on an empty list', () => { + expect(selectNextCandidate([])).toBeNull(); + }); +}); + +describe('parseNextCandidatesOutput', () => { + it('parses the batch shape (wl next -n N) preserving status and sortIndex', () => { + const candidates = parseNextCandidatesOutput( + JSON.stringify({ + success: true, + workItems: [ + { workItem: { id: 'WL-1', title: 'One', status: 'open', sortIndex: 7 } }, + { workItem: { id: 'WL-2', title: 'Two', status: 'completed', sortIndex: 3 } }, + ], + }), + 'idea', + ); + expect(candidates).toEqual([ + { id: 'WL-1', title: 'One', stage: 'idea', status: 'open', sortIndex: 7 }, + { id: 'WL-2', title: 'Two', stage: 'idea', status: 'completed', sortIndex: 3 }, + ]); + }); + + it('parses the legacy single-item shape', () => { + const candidates = parseNextCandidatesOutput( + JSON.stringify({ success: true, workItem: { id: 'WL-1', title: 'One', status: 'open' } }), + 'intake_complete', + ); + expect(candidates).toEqual([ + { id: 'WL-1', title: 'One', stage: 'intake_complete', status: 'open', sortIndex: undefined }, + ]); + }); + + it('treats workItem:null as an empty backlog (not a parse failure)', () => { + const candidates = parseNextCandidatesOutput( + JSON.stringify({ success: false, workItem: null, reason: 'none' }), + 'idea', + ); + expect(candidates).toEqual([]); + }); + + it('returns null on malformed JSON (fail-closed)', () => { + expect(parseNextCandidatesOutput('not json', 'idea')).toBeNull(); + expect(parseNextCandidatesOutput(JSON.stringify({}), 'idea')).toBeNull(); + }); +}); + +describe('buildDowntimeDispatchComment', () => { + it('states the skill, item id, and ISO timestamp', () => { + const comment = buildDowntimeDispatchComment('WL-ABC', 'plan', '2026-01-01T00:00:00.000Z'); + expect(comment).toContain('/skill:plan WL-ABC'); + expect(comment).toContain('2026-01-01T00:00:00.000Z'); + expect(comment).toContain('herdr downtime worker'); + }); + + it('sanitizes newlines out of the embedded title', () => { + const comment = buildDowntimeDispatchComment('WL-ABC', 'intake', '2026-01-01T00:00:00.000Z', 'multi\nline\rtitle'); + expect(comment).toContain('multi line title'); + expect(comment).not.toMatch(/[\r\n]/); + }); + + // risk-effort comment (WL-0MTTSWCJR003OMN7) + it('renders /skill:effort-and-risk for risk-effort kind', () => { + const comment = buildDowntimeDispatchComment('WL-RE', 'risk-effort', '2026-09-09T12:00:00.000Z'); + expect(comment).toContain('/skill:effort-and-risk WL-RE'); + expect(comment).toContain('herdr downtime worker'); + }); +}); + +// ── Single-flight (AC5) ─────────────────────────────────────────────── + +describe('single-flight dispatch guard', () => { + it('does not dispatch a second time while one dispatch is in flight', async () => { + let release!: () => void; + const gate = new Promise<{ ok: true }>((resolve) => { + release = () => resolve({ ok: true }); + }); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + spawnAgentPane: vi.fn().mockImplementation(() => gate), + }); + + const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + release(); + const [firstOutcome, secondOutcome] = await Promise.all([first, second]); + + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(firstOutcome.dispatched).toBe(true); + expect(secondOutcome.dispatched).toBe(false); + expect(secondOutcome.reason).toBe('dispatch-in-flight'); + }); + + it('allows a new dispatch once the previous one has completed', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(first.dispatched).toBe(true); + expect(second.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 90edb6ff3..bc291cecc 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -2149,7 +2149,10 @@ export interface DowntimeWorkerDeps { * `herdr pane list` record for a pane hosting a non-terminal item whose * agent has stopped working, and return the resumable candidates (each * carrying its OWN worklog root). Consulted ONCE per idle cycle, - * immediately before new-item selection. OPTIONAL — when absent (or when + * immediately before new-item selection. Since WL-0MV5639GG00085Q7 the scan + * also reads the machine-wide model-cooldown signal and offers a DUE + * cooldown pane as a candidate that bypasses the generic stall threshold + * (an active cooldown is never woken). OPTIONAL — when absent (or when * `resumeStalledPane` is absent) the scan degrades to "no stalled resume" * and normal dispatch proceeds unchanged. Must never throw: an * unreadable/unparseable scan resolves an empty array. diff --git a/packages/herdr/src/drain-regression-guard.test.ts b/packages/herdr/src/drain-regression-guard.test.ts new file mode 100644 index 000000000..72f0a4c94 --- /dev/null +++ b/packages/herdr/src/drain-regression-guard.test.ts @@ -0,0 +1,240 @@ +/** + * Regression guard for the withdrawn client-side drain (WL-0MUWIHUL2000JEFR). + * Owned by WL-0MV563AU2007FM56, a child of WL-0MV54ZP3Y00514Y9 ("Resume + * model-exhausted jobs instead of switching to cheap"). + * + * The withdrawn predecessor removed three behaviours from the cheap-switch + * path. The model-cooldown machinery must not have reintroduced any of them: + * + * 1. **No client-side slot-count completion gate.** The old drain waited for + * `MODE_SWITCH_DRAIN_FREE_SLOTS` (= `DOWNTIME_AUDIT_MIN_FREE_SLOTS` = 2) + * free slots before switching to cheap. The proxy's own server-side + * bounded drain stays authoritative; the client only applies the per-slot + * operator minimum (1 free slot), so a 3-slot pool with 1 free slot still + * switches. + * 2. **No dispatch pause.** The old `drainPaused` worker config made the + * downtime dispatcher skip NEW dispatches while draining. The + * model-cooldown gate is confined to the cheap-switch decision and never + * pauses dispatch. + * 3. **No unbounded latch.** The old `draining` boolean could hold the switch + * indefinitely. The cooldown hold is bounded by `cooldownUntil` — the + * reader returns only active entries, so the first tick at/after expiry + * resumes the normal decision. + * + * The tests assert observable behaviour through the public worker API and the + * absence of the withdrawn module surface; they do not grep the source. + * + * Run: npx vitest run packages/herdr/src/drain-regression-guard.test.ts + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import * as modeSwitchModule from './mode-switch-worker.js'; +import { + createModeSwitchWorker, + type AdminApiFetcher, +} from './mode-switch-worker.js'; +import { + createDowntimePoller, + createDowntimeWorker, + type DowntimeWorkerConfig, + type LlamaStatus, +} from './downtime-worker.js'; +import { + idleAllSlotsFree, + jsonResponseFixture, + makeDeps, +} from './downtime-worker.fixtures.js'; +import { writeModelCooldown } from '@worklog/shared/model-cooldown'; + +// ── Helpers ──────────────────────────────────────────────────────────── + +/** Flush fire-and-forget promise chains (the worker's cheap POST). */ +async function flushAsync(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +interface RecordedCall { + url: string; + method: string; + body?: string; +} + +/** + * Minimal admin-API fetcher: records POSTs (the mode switches) and reports + * `fast` on GET so the cheap switch is never suppressed by a stale mode. + */ +function recordAdminCalls(): { fetcher: AdminApiFetcher; posts: RecordedCall[] } { + const posts: RecordedCall[] = []; + const fetcher: AdminApiFetcher = async (url, init) => { + const method = init?.method ?? 'GET'; + if (method === 'POST') { + posts.push({ url, method, body: init?.body }); + return { ok: true, status: 200, json: async () => ({ ok: true }) }; + } + return { ok: true, status: 200, json: async () => ({ mode: 'fast' }) }; + }; + return { fetcher, posts }; +} + +/** 3-slot proxy with `free` slots free and the remaining slots processing. */ +function threeSlotStatus(free: number): LlamaStatus { + return { + llama_server_running: true, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: free, + total_slots: 3, + slots: [0, 1, 2].map((i) => ({ + slot_id: `slot-${i + 1}`, + is_processing: i >= free, + })), + }; +} + +// ── (1) No client-side slot-count completion gate + (3) no unbounded latch ── + +describe('withdrawn client-side drain — cheap-switch path (WL-0MV563AU2007FM56)', () => { + let cooldownDir: string; + let clock: number; + const now = () => clock; + + beforeEach(() => { + cooldownDir = mkdtempSync(join(tmpdir(), 'wl-drain-regression-')); + clock = 1_000_000; + }); + + afterEach(() => { + rmSync(cooldownDir, { recursive: true, force: true }); + }); + + it('has no slot-count completion gate: a 3-slot pool with 1 free slot still switches to cheap', async () => { + const { fetcher, posts } = recordAdminCalls(); + const worker = createModeSwitchWorker({ + fetcher, + now, + isLeader: () => true, + coordinationDir: cooldownDir, + }); + clock += 900_000; // idle window met + + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + // The withdrawn drain completion gate required 2 free slots; this status + // has only 1, which must not block the cheap switch any more. + proxyStatus: threeSlotStatus(1), + }); + await flushAsync(); + + expect(posts).toHaveLength(1); + expect(posts[0].body).toBe(JSON.stringify({ mode: 'cheap' })); + + // The withdrawn drain-state surface and its completion budget are gone. + expect('getIsDraining' in worker).toBe(false); + expect( + (modeSwitchModule as Record).MODE_SWITCH_DRAIN_FREE_SLOTS, + ).toBeUndefined(); + }); + + it('has no unbounded latch: the cooldown hold ends exactly at cooldownUntil', async () => { + const { fetcher, posts } = recordAdminCalls(); + const worker = createModeSwitchWorker({ + fetcher, + now, + isLeader: () => true, + coordinationDir: cooldownDir, + }); + clock += 900_000; + const cooldownUntil = clock + 60_000; + writeModelCooldown( + cooldownDir, + { paneId: 'pane-exhausted', reason: 'all providers exhausted', cooldownUntil }, + clock, + ); + + // Held while the cooldown is active (an all-free idle proxy would otherwise + // switch immediately). + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: threeSlotStatus(3), + }); + await flushAsync(); + expect(posts).toHaveLength(0); + + // The first tick at/after `cooldownUntil` resumes the normal cheap decision + // — the hold is not a latch. + clock = cooldownUntil; + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: threeSlotStatus(3), + }); + await flushAsync(); + expect(posts).toHaveLength(1); + expect(posts[0].body).toBe(JSON.stringify({ mode: 'cheap' })); + }); +}); + +// ── (2) No dispatch pause ────────────────────────────────────────────── + +describe('withdrawn dispatch pause — downtime dispatcher (WL-0MV563AU2007FM56)', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'wl-drain-pause-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('ignores a legacy drainPaused config and still dispatches a new pane', async () => { + const poller = createDowntimePoller( + 'http://proxy:8000', + vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)), + ); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-DRAIN', title: 'Drain regression', stage: 'intake_complete' }, + }), + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, + count: 0, + paneIds: [], + records: [], + }), + }); + // The withdrawn `drainPaused` worker config: if it were honoured again the + // worker would skip the NEW dispatch below and return "draining". + const legacyConfig = { + enabled: true, + thresholdMs: 0, + requiredFreeSlots: 1, + model: 'plan', + cwd: root, + noCandidateCooldownMs: 3_600_000, + browseItemCount: 20, + drainPaused: true, + } as unknown as ReturnType; + + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...legacyConfig }), + }); + const outcome = await worker.tick(); + + expect(outcome.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/herdr/src/fetcher-in-review-bucket.test.ts b/packages/herdr/src/fetcher-in-review-bucket.test.ts new file mode 100644 index 000000000..38b255eed --- /dev/null +++ b/packages/herdr/src/fetcher-in-review-bucket.test.ts @@ -0,0 +1,339 @@ +/** + * In Review bucket ordering across the two worklist fetch paths + * (WL-0MUWDW0N6003KF6G). + * + * The 6-bucket in_review sort classifies an item as passed-fresh (bucket 6) + * when its stored audit content fingerprint matches the item's current + * fingerprint, even when the 60 s time gate would call it stale. `fetchItemsByStage` + * reads `wl list --json` (which emits both fingerprints) and `fetchNextItems` + * merges `wl next` (which does NOT emit fingerprints) with the mandatory + * `wl list` review subset. Without forwarding the fingerprints from the + * mandatory subset, the default worklist classified the fingerprint-fresh item + * as passed-stale (bucket 5) and ranked it ahead of a higher-priority + * passed-fresh item (bucket 6) — a "medium before high" inversion. + * + * Since WL-0MUWDW0N6003KF6G priority is the primary sort key and the bucket + * only breaks ties within a priority band; these tests keep verifying that + * both fetch paths classify buckets identically (so a high item leads a medium + * one), now through the priority-first comparator. + * + * Run: npx vitest run packages/herdr/src/fetcher-in-review-bucket.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + fetchItemsByStage, + fetchNextItems, + setExecFileAsync, + resetExecFileAsync, + resetWorklogDir, +} from './fetcher.js'; + +const UPDATED_AT = '2026-10-10T10:00:00.000Z'; +/** 20 minutes before updatedAt → stale under the time gate. */ +const STALE_AUDITED_AT = '2026-10-10T09:40:00.000Z'; +const FINGERPRINT = 'sha256-match'; + +/** Medium item: audit fresh only via the content fingerprint (time-stale). */ +function mediumFpFresh(withFingerprint: boolean): Record { + return { + id: 'WL-medium-fp-fresh', + title: 'Medium fingerprint-fresh', + status: 'completed', + stage: 'in_review', + priority: 'medium', + updatedAt: UPDATED_AT, + auditResult: true, + auditedAt: STALE_AUDITED_AT, + ...(withFingerprint ? { fingerprint: FINGERPRINT, currentFingerprint: FINGERPRINT } : {}), + }; +} + +/** High item: audit fresh by time (bucket 6). */ +function highTimeFresh(): Record { + return { + id: 'WL-high-time-fresh', + title: 'High time-fresh', + status: 'completed', + stage: 'in_review', + priority: 'high', + updatedAt: UPDATED_AT, + auditResult: true, + auditedAt: UPDATED_AT, + }; +} + +beforeEach(() => { + resetExecFileAsync(); + resetWorklogDir(); +}); + +afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); +}); + +describe('in_review bucket ordering across fetch paths (WL-0MUWDW0N6003KF6G)', () => { + it('fetchItemsByStage(in_review) ranks a high passed-fresh item before a fingerprint-fresh medium item', async () => { + setExecFileAsync((async () => ({ + stdout: JSON.stringify({ success: true, workItems: [mediumFpFresh(true), highTimeFresh()] }), + stderr: '', + })) as never); + + const items = await fetchItemsByStage('in_review'); + + expect(items.map((i) => i.id)).toEqual(['WL-high-time-fresh', 'WL-medium-fp-fresh']); + }); + + it('fetchNextItems enriches wl-next items with fingerprints from the mandatory wl list subset', async () => { + // `wl next` (no fingerprints) + mandatory `wl list` review subset (with + // fingerprints). Deduplication keeps the first occurrence, so the + // fingerprint metadata must be copied across before regrouping. + setExecFileAsync((async (_bin: string, args: string[]) => { + if (args.includes('next')) { + return { + stdout: JSON.stringify({ + success: true, + results: [{ workItem: mediumFpFresh(false) }, { workItem: highTimeFresh() }], + }), + stderr: '', + }; + } + if (args.includes('--priority')) { + // Critical mandatory subset — empty for this fixture. + return { stdout: JSON.stringify({ success: true, workItems: [] }), stderr: '' }; + } + // Mandatory completed/in_review subset — carries the fingerprints. + return { + stdout: JSON.stringify({ success: true, workItems: [mediumFpFresh(true), highTimeFresh()] }), + stderr: '', + }; + }) as never); + + const items = await fetchNextItems(10); + const inReviewOrder = items.filter((i) => i.stage === 'in_review').map((i) => i.id); + + expect(inReviewOrder).toEqual(['WL-high-time-fresh', 'WL-medium-fp-fresh']); + }); + + it('fetchItemsByStage(in_review) ranks a high no-audit item before a medium failed-audit item (WL-0MUWDW0N6003KF6G)', async () => { + // The producer-reported symptom: the audit bucket was primary, so a medium + // failed (stale) audit (bucket 3) led a high no-audit item (bucket 4). + // Priority is now the primary key; the bucket breaks ties inside a band. + setExecFileAsync((async () => ({ + stdout: JSON.stringify({ + success: true, + workItems: [ + { + id: 'WL-medium-failed-stale', + title: 'Medium failed stale', + status: 'completed', + stage: 'in_review', + priority: 'medium', + updatedAt: UPDATED_AT, + auditResult: false, + auditedAt: STALE_AUDITED_AT, + }, + { + id: 'WL-high-no-audit', + title: 'High no audit', + status: 'completed', + stage: 'in_review', + priority: 'high', + updatedAt: UPDATED_AT, + auditResult: null, + auditedAt: null, + }, + { + id: 'WL-medium-no-audit', + title: 'Medium no audit', + status: 'completed', + stage: 'in_review', + priority: 'medium', + updatedAt: UPDATED_AT, + auditResult: null, + auditedAt: null, + }, + ], + }), + stderr: '', + })) as never); + + const items = await fetchItemsByStage('in_review'); + + expect(items.map((i) => i.id)).toEqual([ + 'WL-high-no-audit', + 'WL-medium-failed-stale', + 'WL-medium-no-audit', + ]); + }); + + it('fetchNextItems rescues an in-progress/in_review item that only exists in the mandatory wl list subset (WL-0MV6JGVPH009YDXB)', async () => { + // An item being re-worked after review feedback keeps stage=in_review but + // flips status to in-progress. It may fall OUTSIDE the wl next top-N + // window — the mandatory `wl list` review subset (now `--status + // completed,in-progress`) must still surface it regardless of count. + const reworkedReview = { + id: 'WL-reworked-review', + title: 'Reworked review item', + status: 'in-progress', + stage: 'in_review', + priority: 'medium', + updatedAt: UPDATED_AT, + }; + const windowItem = { id: 'WL-in-window', title: 'In window', status: 'open', stage: 'idea', priority: 'medium' }; + + setExecFileAsync((async (_bin: string, args: string[]) => { + if (args.includes('next')) { + // The reworked item is NOT in the wl next top-N window — only the + // window item is. + return { + stdout: JSON.stringify({ success: true, results: [{ workItem: windowItem }] }), + stderr: '', + }; + } + if (args.includes('--priority')) { + return { stdout: JSON.stringify({ success: true, workItems: [] }), stderr: '' }; + } + // Mandatory review subset — now fetched with --status completed,in-progress. + return { + stdout: JSON.stringify({ success: true, workItems: [reworkedReview] }), + stderr: '', + }; + }) as never); + + // count=1: the mandatory in-progress/in_review item must be included, so + // the window item is pushed out (mandatory items are never capped). + const items = await fetchNextItems(1); + const ids = items.map((i) => i.id); + expect(ids).toContain('WL-reworked-review'); + expect(ids).not.toContain('WL-in-window'); + }); + + it('fetchNextItems keeps an actively-worked in-progress item when the review queue saturates the window (WL-0MV6KV22S004NYIU)', async () => { + // The reported symptom: 22 root in_review items saturate browseItemCount + // (20), leaving zero "others" slots — an in-progress/plan_complete item + // returned first by `wl next` was cut. Actively-worked items must be + // mandatory regardless of review-queue depth. + const activeItem = { + id: 'WL-active-plan', + title: 'Actively worked plan item', + status: 'in-progress', + stage: 'plan_complete', + priority: 'medium', + updatedAt: UPDATED_AT, + }; + const reviewItems = Array.from({ length: 22 }, (_, i) => ({ + id: `WL-review-${i + 1}`, + title: `Review ${i + 1}`, + status: 'completed', + stage: 'in_review', + priority: 'medium', + updatedAt: UPDATED_AT, + })); + const windowItem = { id: 'WL-other', title: 'Other', status: 'open', stage: 'idea', priority: 'medium' }; + + setExecFileAsync((async (_bin: string, args: string[]) => { + if (args.includes('next')) { + // The active item leads the wl next window, and the "other" item is + // also in the feed — but the 20-item selection window cuts both once + // the 22 reviews are mandatory. + return { + stdout: JSON.stringify({ + success: true, + results: [ + { workItem: activeItem }, + ...reviewItems.slice(0, 18).map((w) => ({ workItem: w })), + { workItem: windowItem }, + ], + }), + stderr: '', + }; + } + if (args.includes('--priority')) { + return { stdout: JSON.stringify({ success: true, workItems: [] }), stderr: '' }; + } + if (args.includes('--stage')) { + // Mandatory review subset: all 22 reviews (completed/in-progress). + return { stdout: JSON.stringify({ success: true, workItems: reviewItems }), stderr: '' }; + } + // Mandatory actively-worked in-progress subset. + return { stdout: JSON.stringify({ success: true, workItems: [activeItem] }), stderr: '' }; + }) as never); + + // browseItemCount=20 with 22 reviews: the merged mandatory subsets plus + // selection must guarantee the active item and every review survive, and + // cut the non-mandatory other. + const items = await fetchNextItems(20); + const ids = items.map((i) => i.id); + + expect(ids).toContain('WL-active-plan'); + // All 22 mandatory reviews survive the cap too. + expect(reviewItems.every((r) => ids.includes(r.id))).toBe(true); + // The non-mandatory other item is cut. + expect(ids).not.toContain('WL-other'); + }); + + it('reviews arguments pass --status completed,in-progress for the mandatory review subset (WL-0MV6JGVPH009YDXB)', async () => { + let reviewQuery: string[] | null = null; + setExecFileAsync((async (_bin: string, args: string[]) => { + if (args.includes('next')) { + return { stdout: JSON.stringify({ success: true, results: [] }), stderr: '' }; + } + if (args.includes('--priority')) { + return { stdout: JSON.stringify({ success: true, workItems: [] }), stderr: '' }; + } + // The mandatory review subset is the ONLY query carrying --stage; the + // actively-worked in-progress subset (WL-0MV6KV22S004NYIU) is + // stage-agnostic, so exclude it from the capture. + if (args.includes('--stage')) { + reviewQuery = args; + } + return { stdout: JSON.stringify({ success: true, workItems: [] }), stderr: '' }; + }) as never); + + await fetchNextItems(10); + + expect(reviewQuery).not.toBeNull(); + const statusIdx = reviewQuery!.indexOf('--status'); + expect(statusIdx).toBeGreaterThan(-1); + const statusValue = reviewQuery![statusIdx + 1]; + expect(statusValue).toContain('in-progress'); + expect(statusValue).toContain('completed'); + }); + + it('fetches actively-worked in-progress root items as a mandatory subset (WL-0MV6KV22S004NYIU)', async () => { + const calls: string[][] = []; + const activeItem = { + id: 'WL-active', + title: 'Active', + status: 'in-progress', + stage: 'plan_complete', + priority: 'low', + updatedAt: UPDATED_AT, + }; + + setExecFileAsync((async (_bin: string, args: string[]) => { + calls.push(args); + if (args.includes('next')) { + // The active item is NOT in the wl next window (low priority). + return { stdout: JSON.stringify({ success: true, results: [] }), stderr: '' }; + } + if (args.includes('--priority')) { + return { stdout: JSON.stringify({ success: true, workItems: [] }), stderr: '' }; + } + if (args.includes('--stage')) { + return { stdout: JSON.stringify({ success: true, workItems: [] }), stderr: '' }; + } + // The stage-agnostic in-progress subset. + return { stdout: JSON.stringify({ success: true, workItems: [activeItem] }), stderr: '' }; + }) as never); + + const items = await fetchNextItems(20); + + expect(items.map((i) => i.id)).toContain('WL-active'); + const activeQuery = calls.find((a) => a.includes('--status') && a.includes('in-progress') && !a.includes('--stage')); + expect(activeQuery).toBeDefined(); + expect(activeQuery).toContain('--root-only'); + }); +}); diff --git a/packages/herdr/src/fetcher-include-blocked.test.ts b/packages/herdr/src/fetcher-include-blocked.test.ts new file mode 100644 index 000000000..ca129aad4 --- /dev/null +++ b/packages/herdr/src/fetcher-include-blocked.test.ts @@ -0,0 +1,174 @@ +/** + * packages/herdr/src/fetcher-include-blocked.test.ts — Herdr selection list + * must not filter dependency-blocked items (WL-0MV6TFX0600145M2). + * + * `wl next` excludes dependency-blocked items by default (the shared + * `filterCandidates` pipeline removes them unless `includeBlocked` is set). + * The Herdr worklist selection list is populated by `fetchNextItems`, so it + * must pass `--include-blocked` to `wl next` or blocked work silently + * disappears from the list. The implement-tier candidate lookup + * (`getNextImplementCandidate` in index.ts) is a SEPARATE fetch path and + * deliberately keeps the default exclusion (pinned by + * implement-tier-blocked-exclusion.test.ts) — this change must not touch it. + * + * Run: npx vitest run packages/herdr/src/fetcher-include-blocked.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + fetchNextItems, + setExecFileAsync, + resetExecFileAsync, + resetWorklogDir, +} from './fetcher.js'; + +/** Spawn-recorded `wl` invocations, reset per test. */ +let spawns: string[]; + +/** + * Install a recording exec mock: `wl next` returns an open item plus a + * dependency-blocked item (with dependency still open, as reported by the + * real CLI when `--include-blocked` is used); the mandatory subsets are empty. + */ +function installExecMock(): void { + spawns = []; + setExecFileAsync((async (_bin: string, args: string[]) => { + spawns.push(args.join(' ')); + if (args.includes('next')) { + return { + stdout: JSON.stringify({ + success: true, + count: 2, + results: [ + { workItem: { id: 'WL-OPEN', title: 'Open item', status: 'open', stage: 'idea' } }, + { + workItem: { + id: 'WL-BLOCKED', + title: 'Dependency-blocked item', + status: 'blocked', + stage: 'idea', + }, + }, + ], + }), + stderr: '', + }; + } + // Mandatory critical / review / actively-worked / blocked subsets — all empty. + return { stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), stderr: '' }; + }) as never); +} + +/** + * Install a recording exec mock for the low-ranked-blocked regression + * (WL-0MV71WBHB007D8ZE): the base `wl next -n` feed does NOT include the + * blocked item (it ranks below the window, like WL-0MTF5WO1N0088P5P at ~35 + * of 52), but the mandatory blocked subset query returns it — so the item + * can only appear via the explicit `--status blocked` subset fetch. + */ +function installLowRankedBlockedMock(): void { + spawns = []; + setExecFileAsync((async (_bin: string, args: string[]) => { + spawns.push(args.join(' ')); + if (args.includes('next')) { + return { + stdout: JSON.stringify({ + success: true, + count: 2, + results: [ + { workItem: { id: 'WL-OPEN', title: 'Open item', status: 'open', stage: 'idea' } }, + { workItem: { id: 'WL-OTHER', title: 'Other item', status: 'open', stage: 'idea' } }, + ], + }), + stderr: '', + }; + } + if (args.includes('--status') && args.includes('blocked')) { + return { + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { + id: 'WL-LOWRANK-BLOCKED', + title: 'Low-ranked blocked review item', + status: 'blocked', + stage: 'in_review', + }, + ], + }), + stderr: '', + }; + } + // Mandatory critical / review / actively-worked subsets — all empty. + return { stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), stderr: '' }; + }) as never); +} + +beforeEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + installExecMock(); +}); + +afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); +}); + +describe('fetchNextItems includes dependency-blocked items (WL-0MV6TFX0600145M2)', () => { + it('passes --include-blocked to wl next', async () => { + await fetchNextItems(10); + + const nextSpawns = spawns.filter((s) => s.startsWith('next') || s.includes(' next ')); + expect(nextSpawns.length).toBe(1); + expect(nextSpawns[0]).toContain('--include-blocked'); + expect(nextSpawns[0]).toContain('--include-in-progress'); + expect(nextSpawns[0]).toContain('-n 10'); + }); + + it('keeps a wl-next dependency-blocked item in the smart-selected list', async () => { + const items = await fetchNextItems(10); + + const blocked = items.find((i) => i.id === 'WL-BLOCKED'); + expect(blocked).toBeDefined(); + expect(blocked?.status).toBe('blocked'); + expect(items.map((i) => i.id)).toContain('WL-OPEN'); + }); + + it('does not add --include-blocked to the mandatory-subset wl list queries', async () => { + await fetchNextItems(10); + + const listSpawns = spawns.filter((s) => s.startsWith('list') || s.includes(' list ')); + expect(listSpawns.length).toBeGreaterThan(0); + for (const spawn of listSpawns) { + expect(spawn).not.toContain('--include-blocked'); + } + }); +}); + +describe('fetchNextItems shows a low-ranked blocked item via the mandatory subset (WL-0MV71WBHB007D8ZE)', () => { + it('fetches status=blocked root items as a mandatory subset', async () => { + installLowRankedBlockedMock(); + + await fetchNextItems(20); + + const blockedSpawn = spawns.find((s) => s.includes('--status blocked')); + expect(blockedSpawn).toBeDefined(); + expect(blockedSpawn).toContain('--root-only'); + }); + + it('keeps a blocked item that the base wl-next window never returned', async () => { + installLowRankedBlockedMock(); + + const items = await fetchNextItems(20); + + // The base feed only carried WL-OPEN/WL-OTHER; the blocked item arrives + // solely through the mandatory `--status blocked` subset and must be + // shown (it is release-blocking and must not be hidden by the -n window). + expect(items.map((i) => i.id)).toContain('WL-LOWRANK-BLOCKED'); + const blocked = items.find((i) => i.id === 'WL-LOWRANK-BLOCKED'); + expect(blocked?.status).toBe('blocked'); + expect(blocked?.stage).toBe('in_review'); + }); +}); diff --git a/packages/herdr/src/fetcher-inprocess.test.ts b/packages/herdr/src/fetcher-inprocess.test.ts new file mode 100644 index 000000000..5885b43e6 --- /dev/null +++ b/packages/herdr/src/fetcher-inprocess.test.ts @@ -0,0 +1,278 @@ +/** + * packages/herdr/src/fetcher-inprocess.test.ts — In-process SQLite fast path + * for the default-view auxiliary reads (WL-0MV5CBQ6L005SPAG). + * + * A cold default-view refresh used to spawn ~one `wl` process per query: + * `wl next`, two mandatory subsets, the actionable count, the review-queue + * state and one child fetch per expanded parent (≈25 spawns at 20 parents). + * These tests pin the consolidated behaviour: + * + * - `fetchActionableCount` counts in-process (no spawn). + * - `fetchChildrenForItems` reads all expanded parents' children in one + * in-process read (no `wl list --parent` spawn), preserving parentId, + * depth, childCount and deleted-item exclusion. + * - `fetchReviewQueueState` reuses the identical query already issued by + * `fetchMandatorySubsets` (one spawn, not two). + * - Any failure to read in-process falls back to the `wl` spawn path, and + * an audited child (stored fingerprint) falls back so the CLI's + * `currentFingerprint` enrichment is preserved. + * + * Run: npx vitest run packages/herdr/src/fetcher-inprocess.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { WorklogDatabase } from '@worklog/shared'; +import { + fetchNextItems, + fetchActionableCount, + fetchReviewQueueState, + fetchChildrenForItems, + setExecFileAsync, + resetExecFileAsync, + setWorklogDir, + resetWorklogDir, +} from './fetcher.js'; + +interface Seeded { + rootDir: string; + worklogDir: string; + parentA: string; + parentB: string; + childA1: string; + childA2: string; + grandchild: string; + actionable: number; +} + +function seedWorklog(): Seeded { + const rootDir = mkdtempSync(join(tmpdir(), 'herdr-fetcher-inproc-')); + const worklogDir = join(rootDir, '.worklog'); + mkdirSync(worklogDir, { recursive: true }); + + const db = new WorklogDatabase( + 'WL', + join(worklogDir, 'worklog.db'), + join(worklogDir, 'worklog-data.jsonl'), + true, + false, + ); + + let actionable = 0; + const open = (title: string, extra: Record = {}) => { + actionable += 1; + return db.create({ title, description: `${title} description`, ...extra }); + }; + + open('Root one'); + open('Root two'); + open('Critical root', { priority: 'critical' }); + open('Blocked root', { status: 'blocked' }); + // Not actionable (completed) and not actionable (deleted). + db.create({ title: 'Review root', status: 'completed', stage: 'in_review' }); + db.create({ title: 'Deleted root', status: 'deleted' }); + + const parentA = open('Parent A'); + const parentB = open('Parent B'); + const childA1 = open('Child A.1', { parentId: parentA.id }); + const childA2 = open('Child A.2', { parentId: parentA.id }); + const grandchild = open('Grandchild A.1.a', { parentId: childA1.id }); + open('Child B.1', { parentId: parentB.id }); + db.create({ title: 'Deleted child A', parentId: parentA.id, status: 'deleted' }); + + db.close(); + + return { + rootDir, + worklogDir, + parentA: parentA.id, + parentB: parentB.id, + childA1: childA1.id, + childA2: childA2.id, + grandchild: grandchild.id, + actionable, + }; +} + +/** Spawn-recorded `wl` invocations, reset per test. */ +let spawns: string[]; + +/** Install a counting exec mock that answers the consolidated refresh path. */ +function installExecMock(): void { + spawns = []; + setExecFileAsync((async (_bin: string, args: string[]) => { + spawns.push(args.join(' ')); + if (args.includes('next')) { + return { + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [{ id: 'WL-NEXT', title: 'Next', status: 'open' }], + }), + stderr: '', + }; + } + // Mandatory critical subset. + if (args.includes('list') && args.includes('--priority')) { + return { stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), stderr: '' }; + } + // Review queue / in_review subset. + if (args.includes('list') && args.includes('--stage')) { + return { stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), stderr: '' }; + } + // Actionable-count fallback query. + if (args.includes('list')) { + return { stdout: JSON.stringify({ success: true, count: 7, workItems: [] }), stderr: '' }; + } + return { stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), stderr: '' }; + }) as never); +} + +let seeded: Seeded; + +beforeEach(() => { + seeded = seedWorklog(); + installExecMock(); +}); + +afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + rmSync(seeded.rootDir, { recursive: true, force: true }); +}); + +describe('in-process default-view reads (WL-0MV5CBQ6L005SPAG)', () => { + it('counts actionable items in-process without spawning wl', async () => { + setWorklogDir(seeded.worklogDir); + const count = await fetchActionableCount(); + expect(count).toBe(seeded.actionable); + expect(spawns).toEqual([]); + }); + + it('fetches all expanded parents\u2019 children in one in-process read', async () => { + setWorklogDir(seeded.worklogDir); + const depthByParent = new Map([ + [seeded.parentA, 2], + [seeded.parentB, 2], + ]); + + const children = await fetchChildrenForItems([seeded.parentA, seeded.parentB], depthByParent); + + // No `wl list --parent` spawns. + expect(spawns).toEqual([]); + + const a = children.get(seeded.parentA) ?? []; + const b = children.get(seeded.parentB) ?? []; + // Deleted child A is excluded, matching `wl list --parent`. + expect(a.map((i) => i.id).sort()).toEqual([seeded.childA1, seeded.childA2].sort()); + expect(b).toHaveLength(1); + // parentId + depth preserved, and childCount reflects the child's OWN + // children (child A.1 has a grandchild) — the CLI `childCount` enrichment. + const a1 = a.find((i) => i.id === seeded.childA1); + expect(a1?.parentId).toBe(seeded.parentA); + expect(a1?.depth).toBe(2); + expect(a1?.childCount).toBe(1); + expect(a.find((i) => i.id === seeded.childA2)?.depth).toBe(2); + }); + + it('deduplicates the review-queue query into a single spawn across a consolidated refresh', async () => { + setWorklogDir(seeded.worklogDir); + + const [items, children, review, actionable] = await Promise.all([ + fetchNextItems(30), + fetchChildrenForItems([seeded.parentA]), + fetchReviewQueueState(), + fetchActionableCount(), + ]); + + expect(items.map((i) => i.id)).toContain('WL-NEXT'); + expect(children.get(seeded.parentA)).toHaveLength(2); + expect(review).toEqual({ count: 0, auditsOutstanding: false }); + expect(actionable).toBe(seeded.actionable); + + // Only `wl next` + the four mandatory-subset `wl list` queries spawn + // (critical, in_review review queue, actively-worked in-progress, and + // status=blocked root items — WL-0MV6KV22S004NYIU / WL-0MV71WBHB007D8ZE); + // the review query is shared with the banner and the count/children reads + // are in-process. Exactly one query carries `--stage` (the review subset; + // the critical, active and blocked subsets are stage-agnostic). + expect(spawns).toHaveLength(5); + expect(spawns.some((s) => s.includes('next'))).toBe(true); + expect(spawns.some((s) => s.includes('--priority') && s.includes('critical'))).toBe(true); + expect(spawns.filter((s) => s.includes('--stage')).length).toBe(1); + expect(spawns.some((s) => s.includes('--parent'))).toBe(false); + // The actively-worked in-progress subset is fetched explicitly. + expect(spawns.some((s) => s.includes('--status') && s.includes('in-progress'))).toBe(true); + // The blocked subset is fetched explicitly so a low-ranked blocked item + // is never cut by the -n window (WL-0MV71WBHB007D8ZE). + expect(spawns.some((s) => s.includes('--status') && s.includes('blocked'))).toBe(true); + }); + + it('falls back to spawning wl when no worklog dir is resolved', async () => { + resetWorklogDir(); + const count = await fetchActionableCount(); + expect(count).toBe(7); + expect(spawns).toHaveLength(1); + expect(spawns[0]).toContain('list'); + }); + + it('falls back to wl for an audited child so currentFingerprint enrichment is preserved', async () => { + const db = new WorklogDatabase( + 'WL', + join(seeded.worklogDir, 'worklog.db'), + join(seeded.worklogDir, 'worklog-data.jsonl'), + true, + false, + ); + db.importAuditResults([ + { + workItemId: seeded.childA1, + readyToClose: true, + auditedAt: '2026-10-01T00:00:00.000Z', + summary: null, + rawOutput: null, + author: 'audit', + fingerprint: 'fp-stored', + }, + ]); + db.close(); + + // Re-install the mock to answer the `--parent` fallback with the child. + spawns = []; + setExecFileAsync((async (_bin: string, args: string[]) => { + spawns.push(args.join(' ')); + if (args.includes('list') && args.includes('--parent')) { + return { + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { + id: seeded.childA1, + title: 'Child A.1', + status: 'open', + parentId: seeded.parentA, + auditedAt: '2026-10-01T00:00:00.000Z', + auditResult: true, + fingerprint: 'fp-stored', + currentFingerprint: 'fp-stored', + }, + ], + }), + stderr: '', + }; + } + return { stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), stderr: '' }; + }) as never); + + setWorklogDir(seeded.worklogDir); + const children = await fetchChildrenForItems([seeded.parentA]); + + expect(spawns.some((s) => s.includes('--parent'))).toBe(true); + const a1 = (children.get(seeded.parentA) ?? []).find((i) => i.id === seeded.childA1); + // The CLI-enriched currentFingerprint is preserved on the fallback path. + expect(a1?.currentFingerprint).toBe('fp-stored'); + }); +}); diff --git a/packages/herdr/src/fetcher-review-queue.test.ts b/packages/herdr/src/fetcher-review-queue.test.ts index aed6f2297..4208c300f 100644 --- a/packages/herdr/src/fetcher-review-queue.test.ts +++ b/packages/herdr/src/fetcher-review-queue.test.ts @@ -6,7 +6,11 @@ * count and correctly flags an outstanding audit: a missing `auditedAt`, a * stale audit (updatedAt after auditedAt beyond the freshness tolerance), or * a mismatched fingerprint counts as outstanding; a fresh audit does not. - * `fetchCompletedItemCount` remains a count-only wrapper. + * The underlying query now spans `--status completed,in-progress` (the shared + * mandatory-review-subset query, WL-0MV6JGVPH009YDXB) but the banner state + * keeps only the `completed` items — in-progress/in_review items being + * re-worked are not audit candidates. `fetchCompletedItemCount` remains a + * count-only wrapper. * * Run: npx vitest run packages/herdr/src/fetcher-review-queue.test.ts */ @@ -80,6 +84,17 @@ describe('fetchReviewQueueState', () => { expect(await fetchReviewQueueState()).toEqual({ count: 0, auditsOutstanding: false }); }); + it('excludes in-progress/in_review items from the banner count (WL-0MV6JGVPH009YDXB)', async () => { + // The shared query returns both statuses, but re-worked in-progress items + // are not audit candidates, so the banner count stays completed-only. + mockWl([ + inReview('WL-1', FRESH), + { id: 'WL-2', title: 'Reworked review', status: 'in-progress', stage: 'in_review', auditedAt: null, updatedAt: '2026-09-27T10:00:00.000Z', auditResult: null }, + { id: 'WL-3', title: 'Reworked review 2', status: 'in-progress', stage: 'in_review', auditedAt: null, updatedAt: '2026-09-27T10:00:00.000Z', auditResult: null }, + ]); + expect(await fetchReviewQueueState()).toEqual({ count: 1, auditsOutstanding: false }); + }); + it('resolves to undefined on a wl CLI error (fail-closed)', async () => { setExecFileAsync((async () => { throw new Error('wl exploded'); diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index aac97e785..7a8d54171 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -10,6 +10,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { join } from 'node:path'; +import { existsSync } from 'node:fs'; import { selectWorkItems } from './smart-selection.js'; import { regroupWorkItems } from './grouping.js'; import { STAGES } from './worklist.js'; @@ -423,6 +424,123 @@ export function _fetchMemoSize(): number { return inflightFetchMemo.size; } +// ── In-process SQLite reads (WL-0MV5CBQ6L005SPAG) ───────────────────── + +/** + * Minimal structural type for the `@worklog/shared` WorklogDatabase surface + * the default-view auxiliary fetchers use for in-process reads. Kept + * structural so the heavy native `better-sqlite3` dependency is loaded + * LAZILY (only when an in-process read is actually attempted) and a load + * failure degrades to the `wl` spawn path instead of breaking the plugin at + * import time. + */ +interface InProcessDb { + list(query?: { + status?: string[]; + priority?: string; + parentId?: string | null; + rootOnly?: boolean; + stage?: string; + }): any[]; + getAllAuditResults(): { + workItemId: string; + readyToClose: boolean; + auditedAt: string; + fingerprint?: string | null; + }[]; + getChildCounts(items?: any[]): Map; + close(): void; +} + +type InProcessDbCtor = new ( + prefix: string, + dbPath: string, + jsonlPath: string, + silent: boolean, + autoSync: boolean, +) => InProcessDb; + +let _dbCtorPromise: Promise | null = null; + +/** + * Lazily resolve the `@worklog/shared` WorklogDatabase constructor. The + * dynamic import keeps the native `better-sqlite3` dependency out of the + * plugin's import graph until a refresh actually reads in-process. Any load + * failure (native module unavailable, incompatible runtime) resolves `null` + * and the caller falls back to spawning `wl` (fail-open). + */ +function loadInProcessDbCtor(): Promise { + if (_dbCtorPromise === null) { + _dbCtorPromise = import('@worklog/shared') + .then((mod) => ((mod as { WorklogDatabase?: InProcessDbCtor }).WorklogDatabase) ?? null) + .catch(() => null); + } + return _dbCtorPromise; +} + +/** + * Run `fn` against a FRESH in-process WorklogDatabase for the current worklog + * dir, or resolve `undefined` when no in-process read is possible (no dir + * resolved, no SQLite file on disk, `@worklog/shared` unavailable, or the + * read throws). + * + * The handle is opened per call and closed in `finally`, so a read always + * reflects the latest committed state: the store's in-memory query cache can + * never serve data across a refresh boundary. This is the "no stale cache + * served or left behind" invariant for the default-view fast path + * (WL-0MV5CBQ6L005SPAG). Callers MUST fall back to the `wl` spawn path on + * `undefined`. + */ +async function withInProcessDb(fn: (db: InProcessDb) => T): Promise { + const dir = _worklogDir; + if (!dir) return undefined; + const dbPath = join(dir, 'worklog.db'); + if (!existsSync(dbPath)) return undefined; + + const Ctor = await loadInProcessDbCtor(); + if (!Ctor) return undefined; + + let db: InProcessDb | null = null; + try { + db = new Ctor('WI', dbPath, join(dir, 'worklog-data.jsonl'), true, false); + return fn(db); + } catch { + // Fail-open: any open/read failure degrades to the wl spawn path. + return undefined; + } finally { + try { + db?.close(); + } catch { + // ignore: closing a broken handle must not mask the result. + } + } +} + +/** + * Apply the audit enrichment `wl list --json` adds (auditResult, auditedAt and + * the stored fingerprint) to in-process items. `currentFingerprint` is + * deliberately NOT set: deriving it requires the canonical git-based + * fingerprint computation that only the `wl` CLI performs, and the shared + * `isAuditFresh` predicate fails safe to the time gate when it is absent. + * Callers therefore only use this enrichment for items WITHOUT a stored + * fingerprint (an audited item falls back to spawning `wl` so its exact + * fingerprint freshness is preserved). + */ +function enrichInProcessItems(db: InProcessDb, items: any[]): WorkItem[] { + const auditMap = new Map(db.getAllAuditResults().map((audit) => [audit.workItemId, audit])); + return items.map((raw) => { + const item = normalizeItem(raw); + const audit = auditMap.get(item.id); + if (!audit) return item; + return { + ...item, + auditResult: audit.readyToClose, + auditedAt: audit.auditedAt ?? null, + fingerprint: audit.fingerprint ?? null, + }; + }); +} + async function runWlInner( args: string[], includeJson: boolean, @@ -546,15 +664,35 @@ function mergeUniqueById(...arrays: WorkItem[][]): WorkItem[] { return merged; } +/** + * The exact `wl list` invocation backing the root-only in_review review + * queue. Shared so {@link fetchMandatorySubsets} and + * {@link fetchReviewQueueState} build an IDENTICAL argument vector — the + * in-flight fetch memo then deduplicates the two concurrent calls into a + * single `wl` spawn per refresh (WL-0MV5CBQ6L005SPAG). Changing one query + * without the other silently reintroduces the duplicate spawn. + * + * The status filter spans BOTH `completed` (awaiting producer review) and + * `in-progress` (being re-worked after review feedback, WL-0MV6JGVPH009YDXB) + * — the mandatory review set must show both, matching the smart-selection + * predicate in selectWorkItems. + */ +function reviewQueueArgs(): string[] { + return ['list', '--status', 'completed,in-progress', '--stage', 'in_review', '--root-only']; +} + /** * Fetch the mandatory subsets that must ALWAYS be shown in the default - * worklist: all critical items and all completed/in_review items (the - * producer-review queue). + * worklist: all critical items, all in_review items (the producer-review + * queue — status completed awaiting review or in-progress being re-worked + * after review feedback, WL-0MV6JGVPH009YDXB), all actively-worked + * in-progress root items (WL-0MV6KV22S004NYIU) and all status=blocked root + * items (WL-0MV71WBHB007D8ZE). * * These are fetched explicitly via `wl list` because `wl next -n N` hard-caps * at 32 items — even `-n 500` returns only 32, so a large superset cannot - * capture all critical/in_review items. Runs the two queries in parallel to - * mitigate refresh latency. + * capture all critical/in_review/in-progress/blocked items. Runs the four + * queries in parallel to mitigate refresh latency. */ async function fetchMandatorySubsets(): Promise { // Critical items: fetch ALL critical items across all statuses (open, @@ -569,23 +707,55 @@ async function fetchMandatorySubsets(): Promise { // // Review items: root-only is appropriate because the in_review queue // is about producer review of parent items; child items are handled by - // the parent's review lifecycle. - const [criticalOutput, reviewOutput] = await Promise.all([ + // the parent's review lifecycle. The query spans status completed AND + // in-progress (re-worked items stay in_review, WL-0MV6JGVPH009YDXB) so + // re-worked reviews are never dropped from the mandatory set. + // + // Actively-worked items: root-only, status in-progress at ANY stage. These + // can otherwise fall outside the `wl next` top-N window (the base feed is + // capped at 32, and a low-priority in-progress item is outranked by + // in-review (+600) and audit-not-ready (+3500) boosts), hiding the + // producer's real-time "what am I working on" signal + // (WL-0MV6KV22S004NYIU). The query is NOT merged into reviewQueueArgs() + // because it is a distinct concern with a different stage scope — adding it + // there would break the review-queue single-spawn dedup invariant. + // + // Blocked items: root-only, status=blocked at ANY stage (root-only keeps + // them at the same top-level visibility as the other mandatory subsets). + // `wl next` penalises blocked items by -10000, so a release-blocking + // blocked in_review item ranks far below the browseItemCount window and is + // never returned by the base feed — the explicit subset fetch guarantees it + // is still shown (WL-0MV71WBHB007D8ZE). The query is NOT merged into + // reviewQueueArgs() (the review queue is stage-scoped; blocked spans stages). + const [criticalOutput, reviewOutput, activeOutput, blockedOutput] = await Promise.all([ runWl(['list', '--priority', 'critical', '--status', 'open,in-progress,blocked,completed']), - runWl(['list', '--status', 'completed', '--stage', 'in_review', '--root-only']), + runWl(reviewQueueArgs()), + runWl(['list', '--status', 'in-progress', '--root-only']), + runWl(['list', '--status', 'blocked', '--root-only']), ]); const criticalItems = extractItems(extractJson(criticalOutput)); const reviewItems = extractItems(extractJson(reviewOutput)); - return mergeUniqueById(criticalItems, reviewItems); + const activeItems = extractItems(extractJson(activeOutput)); + const blockedItems = extractItems(extractJson(blockedOutput)); + return mergeUniqueById(criticalItems, reviewItems, activeItems, blockedItems); } /** * Fetch the next available work items (via `wl next`). * - * When a count is given, smart selection is applied: all critical and - * completed/in_review items are always included (regardless of count) and - * the count limits only the remaining "other" items. The mandatory subsets - * are merged from explicit `wl list` queries (see fetchMandatorySubsets). + * Dependency-blocked items are SHOWN in the selection list: the query passes + * `--include-blocked` so `wl next`'s default exclusions do not hide blocked + * backlog work (WL-0MV6TFX0600145M2). Blocked items still rank low (the + * shared scorer applies a heavy blocked penalty), so they surface at the + * bottom of the list rather than displacing actionable work. + * + * When a count is given, smart selection is applied: all critical items, all + * in_review review-queue items, all actively-worked in-progress items and all + * status=blocked root items are always included (regardless of count, + * WL-0MV6KV22S004NYIU / WL-0MV71WBHB007D8ZE) and the count limits only the + * remaining "other" items. The mandatory subsets are merged from explicit + * `wl list` queries (see fetchMandatorySubsets), so a blocked item that ranks + * below the `-n` window is still shown. */ export async function fetchNextItems(count?: number): Promise { const args = ['next']; @@ -593,8 +763,26 @@ export async function fetchNextItems(count?: number): Promise { args.push('-n', String(count)); } args.push('--include-in-progress'); - - const output = await runWl(args); + // Show dependency-blocked items in the Herdr selection list. `wl next` + // excludes them by default (shared filterCandidates, unless includeBlocked); + // without this flag blocked backlog work silently disappears from the + // worklist (WL-0MV6TFX0600145M2). The base feed includes them within its + // `-n` window; the status=blocked mandatory subset (see + // fetchMandatorySubsets) additionally guarantees a low-ranked blocked item + // is never cut (WL-0MV71WBHB007D8ZE). The implement-tier candidate lookup + // (getNextImplementCandidate) deliberately keeps the default exclusion. + args.push('--include-blocked'); + + // Run the base `wl next` selection and the mandatory subsets CONCURRENTLY: + // they are independent reads, and overlapping them removes one full `wl` + // startup from the critical path of a cold refresh (WL-0MV5CBQ6L005SPAG). + // The mandatory subsets are only needed when a count window is applied; + // the count-less path returns the base selection unchanged. + const needMandatory = count !== undefined; + const mandatoryPromise: Promise = needMandatory + ? fetchMandatorySubsets() + : Promise.resolve([] as WorkItem[]); + const [output, mandatory] = await Promise.all([runWl(args), mandatoryPromise]); const payload = extractJson(output); const items = extractItems(payload); if (count === undefined) { @@ -604,8 +792,30 @@ export async function fetchNextItems(count?: number): Promise { // Smart selection: merge the mandatory subsets with the regular wl next // results (deduplicated by ID), then always show the mandatory set and // limit only the "other" items to fill the remaining count slots. - const mandatory = await fetchMandatorySubsets(); - const merged = mergeUniqueById(items, mandatory); + // + // Audit-metadata merge (WL-0MUWDW0N6003KF6G): `wl next` does not emit the + // audit content fingerprints (`fingerprint`/`currentFingerprint`) that + // `wl list` does. Deduplication below keeps the FIRST occurrence, so without + // this enrichment the default worklist would silently drop the fingerprint + // metadata for any item also present in a mandatory `wl list` subset — the + // in_review 6-bucket sort then degrades to the timestamp gate only, which + // can rank a fingerprint-fresh (but time-stale) item into the wrong bucket + // and reorder it ahead of a higher-priority item. Prefer the richer + // `wl list` representation for audit fields so the default worklist and the + // stage-filtered view (`fetchItemsByStage`) classify buckets identically. + const mandatoryById = new Map(mandatory.map((item) => [item.id, item])); + const enrichedItems = items.map((item) => { + const rich = mandatoryById.get(item.id); + if (!rich) return item; + return { + ...item, + auditResult: rich.auditResult, + auditedAt: rich.auditedAt, + fingerprint: rich.fingerprint, + currentFingerprint: rich.currentFingerprint, + }; + }); + const merged = mergeUniqueById(enrichedItems, mandatory); const selected = selectWorkItems(merged, count); // Regroup the final selected set so every displayed item (mandatory wl // list items included) receives a correct group assignment and no @@ -623,6 +833,11 @@ export async function fetchNextItems(count?: number): Promise { * `in-progress` while it is being re-worked after review feedback), so * restricting to `status=open` would hide the entire review queue. `open` * is included for robustness (no in_review item carries it today). + * `blocked` is also included (WL-0MV70XY6D008013C): the release gate + * queries `wl list --stage in_review` with NO status filter, so a + * status=blocked item in the in_review stage blocks the release; the + * review filter must show it or the selection list and the release gate + * disagree about what is in the review queue. * * plan_complete and intake_complete are also exceptions (WL-0MUIB7D30009KG00): * after WL-0MTOHS5B4001Y9FX removed the in_progress stage, actively-worked @@ -632,7 +847,7 @@ export async function fetchNextItems(count?: number): Promise { * stage-filtered worklists. */ const STAGE_STATUS: Record = { - in_review: 'completed,in-progress,open', + in_review: 'completed,in-progress,open,blocked', plan_complete: 'open,in-progress', intake_complete: 'open,in-progress', }; @@ -647,9 +862,12 @@ const STAGE_STATUS: Record = { * and `completed`. * * Status per valid stage: - * - `in_review` (WL-0MSKCRX730052IIW): `completed`, `in-progress`, `open` - * — in_review items carry `completed`/`in-progress` status per the project - * workflow. + * - `in_review` (WL-0MSKCRX730052IIW): `completed`, `in-progress`, `open`, + * `blocked` — in_review items carry `completed`/`in-progress` status per + * the project workflow; `blocked` is included (WL-0MV70XY6D008013C) so the + * review filter matches the release gate's unfiltered `wl list --stage + * in_review`, which treats a status=blocked in_review item as + * release-blocking. * - `plan_complete` / `intake_complete` (WL-0MUIB7D30009KG00): `open`, * `in-progress` — actively-worked items retain their pipeline stage and * flip `status` to `in-progress` (WL-0MTOHS5B4001Y9FX removed the @@ -709,6 +927,15 @@ export async function fetchItemDetails(id: string): Promise { * Fetch the total count of actionable work items. */ export async function fetchActionableCount(): Promise { + // Fast path: count in-process against the SQLite store. This removes a + // whole `wl` process spawn from every refresh and is exact: the CLI's + // actionable count is the number of non-deleted items in those statuses. + const inProcess = await withInProcessDb((db) => + db.list({ status: ['open', 'in-progress', 'blocked'] }).length, + ); + if (inProcess !== undefined) { + return inProcess; + } try { const output = await runWl(['list', '--status', 'open,in-progress,blocked']); const payload = JSON.parse(output); @@ -745,22 +972,32 @@ export async function runWlSync(): Promise<{ success: boolean; error?: string }> * that is not current per `isAuditFresh`). */ export interface ReviewQueueState { - /** Number of root-level completed/in_review items. */ + /** Number of root-level in_review items with status completed. */ count: number; /** True when any of those items lacks a current (fresh) audit. */ auditsOutstanding: boolean; } /** - * Fetch the review-queue state (root-only completed/in_review items) for - * the sprint-complete / queue-depth banner (parent WL-0MTHSHN5V008R5L0; - * banner copy selection AH-0MUDYTQ55002NUSJ). Returns the count and whether - * an outstanding audit remains, or undefined on failure — callers treat - * undefined as "unknown" and leave state unchanged (fail-closed, AC1). + * Fetch the review-queue state (root-only in_review items with status + * `completed`) for the sprint-complete / queue-depth banner (parent + * WL-0MTHSHN5V008R5L0; banner copy selection AH-0MUDYTQ55002NUSJ). Returns + * the count and whether an outstanding audit remains, or undefined on + * failure — callers treat undefined as "unknown" and leave state unchanged + * (fail-closed, AC1). + * + * Uses `wl list --status completed,in-progress --stage in_review + * --root-only --json` — the SAME query as the mandatory review subset (see + * {@link reviewQueueArgs}), so the in-flight memo deduplicates the two into + * ONE spawn — and then keeps only the `completed` items in-process. This + * preserves the banner's historical "completed/in_review" semantics while + * the mandatory worklist subset consumes the full (completed + in-progress) + * set (WL-0MV6JGVPH009YDXB). In-progress/in_review items are excluded here + * because they are being re-worked after review feedback: they are not + * dispatchable to the audit tier (which selects completed/in_review only), + * so counting them in "focus on audits" would be misleading. * - * Uses `wl list --status completed --stage in_review --root-only --json` - * (the same query as the former `fetchCompletedItemCount`). "Outstanding - * audit" reuses the shared `isAuditFresh` predicate: a missing `auditedAt` + * "Outstanding audit" reuses the shared `isAuditFresh` predicate: a missing `auditedAt` * or a stored audit that is not current counts as outstanding; a fresh * audit (passed or failed) does not. `wl list --json` exposes * `currentFingerprint` for items that carry a stored fingerprint, so the @@ -776,9 +1013,12 @@ export interface ReviewQueueState { */ export async function fetchReviewQueueState(): Promise { try { - const output = await runWl(['list', '--status', 'completed', '--stage', 'in_review', '--root-only', '--json']); + // Identical args to {@link fetchMandatorySubsets}'s review query so the + // in-flight memo deduplicates the two into ONE spawn when a refresh + // schedules both concurrently (WL-0MV5CBQ6L005SPAG). + const output = await runWl(reviewQueueArgs()); const payload = extractJson(output); - const items = extractItems(payload); + const items = extractItems(payload).filter((item) => item.status === 'completed'); const auditsOutstanding = items.some((item) => !isAuditFresh(item.auditedAt, item.updatedAt, item.fingerprint, item.currentFingerprint), ); @@ -826,6 +1066,89 @@ export async function fetchChildrenForItem(parentId: string, depth = 1): Promise return regroupWorkItems(items.map((item) => ({ ...item, depth }))); } +/** + * Fetch the direct children of MULTIPLE expanded parents in ONE pass + * (WL-0MV5CBQ6L005SPAG). The default worklist can have dozens of expanded + * parents; spawning `wl list --parent ` once per parent made the + * expanded-children cost the dominant share of a cold refresh. This reads + * the children in-process from the SQLite store (one read for all parents) + * and groups them per parent with the SAME ordering and fields as + * {@link fetchChildrenForItem}. + * + * Fail-open / correctness: + * - When no in-process read is possible (no resolved worklog dir, no SQLite + * file, `@worklog/shared` unavailable) it falls back to one + * `wl list --parent` spawn per parent, so behaviour is unchanged there. + * - When any matched child carries a stored audit fingerprint the CLI's + * `currentFingerprint` enrichment is required for exact freshness, so the + * whole batch falls back to spawning `wl`. Audited children are rare + * because audit dispatch is root-only, so this is the uncommon path. + * + * `depthByParent` overrides the display depth per parent (default 1); the + * worklist passes each expanded item's hierarchy depth + 1 so nested + * expansion still renders at the correct depth (WL-0MSQ3FH1K000MMJW). + */ +export async function fetchChildrenForItems( + parentIds: string[], + depthByParent?: Map, +): Promise> { + const uniqueParents = [...new Set(parentIds)]; + const result = new Map(); + for (const id of uniqueParents) result.set(id, []); + if (uniqueParents.length === 0) return result; + + const wanted = new Set(uniqueParents); + const inProcess = await withInProcessDb((db) => { + // childCounts is computed over ALL items (deleted included), matching the + // CLI's `getChildCounts()` enrichment. Children themselves are non-deleted + // only, matching `wl list --parent`'s default filtering. + const allRaw = db.list({}); + const childCounts = db.getChildCounts(allRaw); + const matched = allRaw.filter( + (item) => item.parentId && wanted.has(String(item.parentId)) && item.status !== 'deleted', + ); + const enriched = enrichInProcessItems(db, matched); + // A stored fingerprint needs the CLI's currentFingerprint enrichment, so + // the fast path must not serve the batch in that case. + const needsCli = enriched.some((item) => Boolean(item.fingerprint)); + const byParent = new Map(); + for (const item of enriched) { + const parentId = item.parentId; + if (!parentId || !wanted.has(parentId)) continue; + const list = byParent.get(parentId) ?? []; + list.push({ ...item, childCount: childCounts.get(item.id) ?? 0 }); + byParent.set(parentId, list); + } + return { byParent, needsCli }; + }); + + if (inProcess && !inProcess.needsCli) { + for (const id of uniqueParents) { + const depth = depthByParent?.get(id) ?? 1; + const children = (inProcess.byParent.get(id) ?? []).map((item) => ({ ...item, depth })); + result.set(id, regroupWorkItems(children)); + } + return result; + } + + // Fallback: preserve the exact per-parent CLI semantics (including + // currentFingerprint enrichment for audited children). + const fetched = await Promise.all( + uniqueParents.map(async (id) => { + try { + const children = await fetchChildrenForItem(id, depthByParent?.get(id) ?? 1); + return [id, children] as const; + } catch { + // Fail-open: a failed child fetch leaves the previously rendered + // children in place (the caller carries them over). + return [id, [] as WorkItem[]] as const; + } + }), + ); + for (const [id, children] of fetched) result.set(id, children); + return result; +} + // ── Work-item claiming ──────────────────────────────────────────────── /** diff --git a/packages/herdr/src/form-dialog.test.ts b/packages/herdr/src/form-dialog.test.ts index 5ca0b8bd7..df3d88e14 100644 --- a/packages/herdr/src/form-dialog.test.ts +++ b/packages/herdr/src/form-dialog.test.ts @@ -580,7 +580,7 @@ describe('shellQuote', () => { describe('substituteIdentifiers — shell-route escaping', () => { const AR_TEMPLATE = - "!!wl reviewed false && wl update --status open --stage plan_complete --priority medium && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '"; + "!!wl reviewed false && wl update --status open --stage plan_complete && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '"; it('escapes only the apostrophe inside the existing single-quoted summary', () => { const out = substituteIdentifiers(AR_TEMPLATE, { reason: "don't" }); diff --git a/packages/herdr/src/grouping.test.ts b/packages/herdr/src/grouping.test.ts index 95389ae42..a5eaabe6d 100644 --- a/packages/herdr/src/grouping.test.ts +++ b/packages/herdr/src/grouping.test.ts @@ -384,5 +384,116 @@ describe('in_review 6-bucket sort (WL-0MSLPM5ZB003TADT)', () => { }); expect(inReviewBucket(item)).toBe(5); }); + + it('regroupWorkItems honours fingerprint freshness so a fingerprint-fresh item is not ranked behind a lower-priority one (WL-0MUWDW0N6003KF6G)', () => { + // Regression: regroupWorkItems used to drop `fingerprint`/`currentFingerprint` + // when building its sort comparator. The medium item below is fresh by the + // primary content gate (fingerprints match) but stale by the 60s time gate; + // without the fingerprint fields it was classified bucket 5 (passed-stale) + // while the high item was bucket 6 (passed-fresh), so the medium appeared + // BEFORE the high. With the fingerprint fields forwarded, both land in + // bucket 6 and priority decides (high first). + const fingerprint = 'sha256-match'; + const merged: WorkItem[] = [ + makeItem('WL-medium-fp-fresh', { + stage: 'in_review', + priority: 'medium', + auditResult: true, + auditedAt: staleAuditedAt, + updatedAt, + fingerprint, + currentFingerprint: fingerprint, + }), + makeItem('WL-high-time-fresh', { + stage: 'in_review', + priority: 'high', + auditResult: true, + auditedAt: updatedAt, + updatedAt, + }), + ]; + + const inReviewOrder = regroupWorkItems(merged, 3) + .filter((i) => i.stage === 'in_review') + .map((i) => i.id); + + expect(inReviewOrder).toEqual(['WL-high-time-fresh', 'WL-medium-fp-fresh']); + }); + + it('regroupWorkItems keeps a fingerprint-mismatch item in bucket 5 but priority still dominates a passed-fresh item (WL-0MUWDW0N6003KF6G)', () => { + // A medium item whose stored/current fingerprints disagree is stale even + // though its timestamps look fresh (bucket 5). Priority is the primary + // sort key (WL-0MUWDW0N6003KF6G): the high passed-fresh item (bucket 6) + // must still rank first; the bucket only orders items within the same + // priority band. + const merged: WorkItem[] = [ + makeItem('WL-high-time-fresh', { + stage: 'in_review', + priority: 'high', + auditResult: true, + auditedAt: freshAuditedAt, + updatedAt, + }), + makeItem('WL-medium-fp-stale', { + stage: 'in_review', + priority: 'medium', + auditResult: true, + auditedAt: freshAuditedAt, + updatedAt, + fingerprint: 'sha256-old', + currentFingerprint: 'sha256-new', + }), + ]; + + // Sanity: the mismatch is still classified as bucket 5. + expect(inReviewBucket(merged[1])).toBe(5); + + const inReviewOrder = regroupWorkItems(merged, 3) + .filter((i) => i.stage === 'in_review') + .map((i) => i.id); + + expect(inReviewOrder).toEqual(['WL-high-time-fresh', 'WL-medium-fp-stale']); + }); + + it('ranks a high no-audit item ahead of a medium failed-audit item (WL-0MUWDW0N6003KF6G)', () => { + // Regression for the producer-reported symptom: a medium-priority item + // with a failed (stale) audit was ranking ahead of a high-priority item + // with no audit because the audit bucket was the primary sort key. With + // priority primary the high item must lead, and the two medium items are + // then ordered by their bucket (failed-stale 3 before no-audit 4). + const merged: WorkItem[] = [ + makeItem('WL-medium-no-audit', { + stage: 'in_review', + priority: 'medium', + auditResult: null, + auditedAt: null as unknown as string, + updatedAt, + }), + makeItem('WL-medium-failed-stale', { + stage: 'in_review', + priority: 'medium', + auditResult: false, + auditedAt: staleAuditedAt, + updatedAt, + }), + makeItem('WL-high-no-audit', { + stage: 'in_review', + priority: 'high', + auditResult: null, + auditedAt: null as unknown as string, + updatedAt, + }), + ]; + + const inReviewOrder = regroupWorkItems(merged, 3) + .filter((i) => i.stage === 'in_review') + .map((i) => i.id); + + expect(inReviewOrder).toEqual([ + 'WL-high-no-audit', + 'WL-medium-failed-stale', + 'WL-medium-no-audit', + ]); + }); }); diff --git a/packages/herdr/src/grouping.ts b/packages/herdr/src/grouping.ts index d06cdc809..1df1f5c41 100644 --- a/packages/herdr/src/grouping.ts +++ b/packages/herdr/src/grouping.ts @@ -411,13 +411,18 @@ export function inReviewBucket(item: { } /** - * Compare two in_review items using the 6-bucket predicate. + * Compare two in_review items. * - * Primary sort: bucket number (ascending — lower bucket = higher priority). - * Secondary: priority (high → medium → low). + * Primary sort: priority (high → medium → low). The review queue must + * surface the most important work first: a medium-priority failed-audit item + * must never rank ahead of a high-priority item (WL-0MUWDW0N6003KF6G). + * Secondary: audit bucket number (ascending — lower bucket = higher priority). * Tertiary: updatedAt (older items first). * Quaternary: id (deterministic tie-break). * + * The 6-bucket predicate is applied consistently *within* each priority band + * rather than as the global primary key. + * * Returns 0 when either item lacks stage === 'in_review', letting the * caller fall back to the standard comparator. */ @@ -431,15 +436,16 @@ export function compareInReviewItems(a: GroupableItem & { }, b: typeof a): number { if (a.stage !== 'in_review' || b.stage !== 'in_review') return 0; - const bucketDiff = inReviewBucket(a) - inReviewBucket(b); - if (bucketDiff !== 0) return bucketDiff; - - // Within bucket: priority (high → medium → low) + // Primary: priority (high → medium → low). const prioA = WITHIN_GROUP_PRIORITY_ORDER[a.priority ?? ''] ?? DEFAULT_PRIORITY_ORDER; const prioB = WITHIN_GROUP_PRIORITY_ORDER[b.priority ?? ''] ?? DEFAULT_PRIORITY_ORDER; if (prioA !== prioB) return prioA - prioB; - // Within bucket: updatedAt (older items first) + // Secondary: audit bucket (within the same priority band). + const bucketDiff = inReviewBucket(a) - inReviewBucket(b); + if (bucketDiff !== 0) return bucketDiff; + + // Tertiary: updatedAt (older items first) const tsA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; const tsB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; if (tsA !== tsB) return tsA - tsB; @@ -454,17 +460,18 @@ export function compareInReviewItems(a: GroupableItem & { * remaining stages), then priority (high → medium → low), then id as a * deterministic tie-break. * - * When BOTH items have stage === 'in_review', the 6-bucket predicate - * (see `compareInReviewItems`) takes priority over the default stage - * sub-sort. All in_review items map to the same stage-sub-order value - * (REMAINING_STAGE_ORDER) anyway, but the bucket predicate provides the - * deterministic ordering specified in WL-0MSLPM5ZB003TADT. + * When BOTH items have stage === 'in_review', `compareInReviewItems` + * orders them by priority (high → medium → low) and then by the 6-bucket + * audit predicate (WL-0MSLPM5ZB003TADT, refined by WL-0MUWDW0N6003KF6G so a + * medium-priority item can never rank ahead of a high-priority one). This + * takes precedence over the default stage sub-sort: all in_review items map + * to the same stage-sub-order value (REMAINING_STAGE_ORDER) anyway. * - * If the bucket comparator returns 0 (items in the same bucket, same - * priority, same timestamp), falls through to stage/priority/id tie-break. + * If the in_review comparator returns 0 (same priority, same bucket, same + * timestamp, same id), falls through to stage/priority/id tie-break. */ export function compareGroupableItems(a: GroupableItem, b: GroupableItem): number { - // Fast-path: if both are in_review, use bucket ordering + // Fast-path: if both are in_review, use the in_review ordering if (a.stage === 'in_review' && b.stage === 'in_review') { const bucketCmp = compareInReviewItems(a, b); if (bucketCmp !== 0) return bucketCmp; @@ -504,6 +511,10 @@ export function regroupWorkItems( items: T[], maxFilePathGroups: number = 3, @@ -518,6 +529,8 @@ export function regroupWorkItems { @@ -40,7 +48,7 @@ function percentile(samples: number[], p: number): number { describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { it('keeps event-loop delay under 100 ms p95 during a worst-case hydrate', async () => { - const panes = makeWorstCasePanes(60); + const tabs = makeWorstCaseTabs(60); const items = makeWorstCaseItems(25); const deps: HydratorDeps = { // Deliberately slow seams: each CLI-shaped lookup yields the event @@ -49,9 +57,9 @@ describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { await delay(4); return items; }, - listActivePanes: async () => { + listTabs: async () => { await delay(4); - return panes; + return tabs; }, listOutboundDeps: async () => { await delay(4); @@ -93,7 +101,7 @@ describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { // wall-clock bound well under the 20 s scheduler watchdog. const deps: HydratorDeps = { listInProgressItems: () => new Promise(() => {}), - listActivePanes: async () => [], + listTabs: async () => [], listOutboundDeps: async () => [], applyDemotion: async () => true, stepTimeoutMs: 50, @@ -116,7 +124,7 @@ describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { const touched: string[] = []; const deps: HydratorDeps = { listInProgressItems: async () => items, - listActivePanes: async () => [] as HydratorPane[], + listTabs: async () => [] as HydratorTab[], listOutboundDeps: async (id: string) => { touched.push(id); return []; @@ -132,3 +140,88 @@ describe('hydrate UI responsiveness (WL-0MUY1CZES0099A04)', () => { expect(touched).toHaveLength(10); }); }); + +/** + * F5 (WL-0MV5CBQTS008ZWHR) — the event-driven watch must never block the + * pane's event loop. + * + * In `worklist.ts` the watch callback dispatches the refresh asynchronously + * (`void requestWatchRefresh()`), so a burst of agent writes is coalesced and + * applied without starving keyboard input. This suite exercises the real + * {@link watchDbChanges} primitive (real `fs.watch`, real timers) with a + * refresh workload shaped like the cold default-view refresh — awaited steps + * that each yield to the event loop (the real `doRefresh` awaits bounded `wl` + * spawns). It samples event-loop delay, the same deterministic proxy for + * command-input lag used above; a synchronous (loop-blocking) refresh would + * push the p95 well past the 100 ms budget and fail the test. + * + * The pane wiring (watch → gates → single-flight/trailing-coalescing + * `doRefresh`) is pinned by `worklist-watch.test.ts` (F1/F3); this test pins + * the non-blocking responsiveness guarantee on top of it. + */ +describe('event-driven watch UI responsiveness (WL-0MV5CBQTS008ZWHR)', () => { + it('keeps event-loop delay under 100 ms p95 during and after a watch-triggered refresh', async () => { + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-watch-resp-cache-')); + const worklogDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-watch-resp-log-')); + const counterFile = stateCounterFilePath(cacheDir, worklogDir); + fs.mkdirSync(path.dirname(counterFile), { recursive: true }); + fs.writeFileSync(counterFile, JSON.stringify({ state: 0 }), 'utf-8'); + + // A non-blocking refresh: several awaited steps, each yielding to the + // event loop, mirroring the real `doRefresh` spawn path. Counted so the + // burst-coalescing and post-dispose idle contracts are asserted too. + let refreshes = 0; + const refresh = async (): Promise => { + refreshes += 1; + for (let i = 0; i < 5; i++) await delay(6); + }; + + const watch: DbChangeWatch = watchDbChanges( + cacheDir, + worklogDir, + () => { + void refresh(); + }, + { debounceMs: 20 }, + ); + + // Sample event-loop delay (command-input lag proxy) throughout. + const intervalMs = 5; + const delays: number[] = []; + let last = Date.now(); + const sampler = setInterval(() => { + const tick = Date.now(); + delays.push(Math.max(0, tick - last - intervalMs)); + last = tick; + }, intervalMs); + + try { + expect(watch.active).toBe(true); + + // A burst of agent writes lands; the watch coalesces it into one + // trailing refresh without blocking the loop. + for (let i = 1; i <= 8; i++) { + fs.writeFileSync(counterFile, JSON.stringify({ state: i }), 'utf-8'); + await delay(3); + } + // Let the debounce fire and the refresh run to completion. + await delay(150); + } finally { + clearInterval(sampler); + watch.dispose(); + } + + expect(refreshes).toBeGreaterThanOrEqual(1); + // Command-input lag proxy: the loop was never starved for long, during + // or after the watch-triggered refresh. + expect(percentile(delays, 0.95)).toBeLessThan(100); + + // After dispose the watch schedules nothing further (idle no-op). + const settled = refreshes; + await delay(60); + expect(refreshes).toBe(settled); + + fs.rmSync(cacheDir, { recursive: true, force: true }); + fs.rmSync(worklogDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/herdr/src/hydrator.fixtures.ts b/packages/herdr/src/hydrator.fixtures.ts index 0330e73a4..3155477d9 100644 --- a/packages/herdr/src/hydrator.fixtures.ts +++ b/packages/herdr/src/hydrator.fixtures.ts @@ -9,15 +9,15 @@ * hydrator CLI shape with minimal valid JSON and records the exact * `(bin, args, options)` of every spawn, so tests can assert the stdio * and argument contract. - * • `makeWorstCasePanes()` / `makeWorstCaseItems()` build the load profile - * the fix must survive (50+ panes, 20+ in-progress items). + * • `makeWorstCaseTabs()` / `makeWorstCaseItems()` build the load profile + * the fix must survive (50+ tabs, 20+ in-progress items). * * No `vitest` import: these are plain builders consumed by the test file (and * by the implementation children's tests), so they stay usable outside a * vitest runtime. */ -import type { HydratorItem, HydratorPane } from './hydrator.js'; +import type { HydratorItem, HydratorTab } from './hydrator.js'; // ── Recording exec seam ─────────────────────────────────────────────── @@ -44,7 +44,8 @@ export interface HydratorExecHarness { /** * Build a recording exec seam that answers the four hydrator CLI shapes with - * minimal valid JSON and records every call. + * minimal valid JSON and records every call. The tab-listing shape answers + * `herdr tab list` (the hydrator's active-detection input). */ export function hydratorExecRouter(): HydratorExecHarness { const calls: RecordedSpawn[] = []; @@ -56,8 +57,8 @@ export function hydratorExecRouter(): HydratorExecHarness { options: (options ?? {}) as Record, }); - if (bin === 'herdr' && args.includes('pane')) { - return { stdout: JSON.stringify({ result: { panes: [] } }), stderr: '' }; + if (bin === 'herdr' && args.includes('tab') && args.includes('list')) { + return { stdout: JSON.stringify({ result: { tabs: [] } }), stderr: '' }; } if (args.includes('dep')) { return { stdout: JSON.stringify({ outbound: [] }), stderr: '' }; @@ -82,20 +83,20 @@ function padded(n: number, width: number): string { } /** - * Worst-case pane volume: `count` panes (default 60) split across the current - * workspace (`wCurrent`, even indices) and another workspace, each carrying a - * work-item ID in its label as a real pane title would. + * Worst-case tab volume: `count` tabs (default 60) split across the current + * workspace (`wCurrent`, even indices) and another workspace, each labelled + * with an exact work-item ID as a real per-item tab would be. */ -export function makeWorstCasePanes(count = 60): HydratorPane[] { +export function makeWorstCaseTabs(count = 60): HydratorTab[] { return Array.from({ length: count }, (_, i) => ({ - pane_id: `pane-${i}`, - label: `implement WL-PANE${padded(i, 7)}`, + tab_id: `tab-${i}`, + label: `WL-TAB${padded(i, 7)}`, workspace_id: i % 2 === 0 ? 'wCurrent' : 'wOther', })); } /** - * Worst-case in-progress volume: `count` (default 25) pane-less candidates, + * Worst-case in-progress volume: `count` (default 25) tab-less candidates, * all at `plan_complete` so every one is eligible for demotion. */ export function makeWorstCaseItems(count = 25): HydratorItem[] { diff --git a/packages/herdr/src/hydrator.test.ts b/packages/herdr/src/hydrator.test.ts index 4447a07ec..928a0fe2f 100644 --- a/packages/herdr/src/hydrator.test.ts +++ b/packages/herdr/src/hydrator.test.ts @@ -12,25 +12,33 @@ import { resetExecFileAsync, setExecFileAsync } from './fetcher.js'; import { hydratorExecRouter, makeWorstCaseItems, - makeWorstCasePanes, + makeWorstCaseTabs, } from './hydrator.fixtures.js'; import { ALLOWED_STAGES_BY_STATUS, - collectPaneWorkItemIds, + applyActiveTabMarkers, + collectTabWorkItemIds, compatibleStage, + createHydratorFileLog, createHydratorRunner, createProductionHydratorDeps, decideDemotion, + extractWorkItemIdsFromPaneTitles, extractWorkItemIdsFromText, isActiveBlocker, isActivePaneMatch, + hasItemTab, + markItemsActiveByTab, runHydrationOnce, type HydrationResult, type HydratorDepTarget, type HydratorDeps, type HydratorItem, - type HydratorPane, + type HydratorTab, } from './hydrator.js'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; // ── Test helpers ────────────────────────────────────────────────────── @@ -63,7 +71,7 @@ function makeDeps(overrides: Partial = {}): { const applied: AppliedDemotion[] = []; const deps: ContractDeps = { listInProgressItems: async () => [], - listActivePanes: async () => [], + listTabs: async () => [], listOutboundDeps: async () => [], applyDemotion: async (id, status, stage) => { applied.push({ id, status, stage }); @@ -104,35 +112,142 @@ describe('extractWorkItemIdsFromText', () => { }); }); -// ── Active-pane matching ────────────────────────────────────────────── +// ── Active-tab matching ─────────────────────────────────────────────── -describe('active-pane matching', () => { - const pane = (label: string, workspace_id?: string): HydratorPane => ({ - pane_id: `p-${label.length}-${workspace_id ?? 'x'}`, +describe('active-tab matching', () => { + const tab = (label: string, workspace_id?: string): HydratorTab => ({ + tab_id: `t-${label.length}-${workspace_id ?? 'x'}`, label, workspace_id, }); - it('matches an item whose ID appears in any live pane title', () => { - const panes = [pane(`implement Foo - AA-111111111`), pane(`plan ${WL}`)]; - expect(isActivePaneMatch(WL, panes)).toBe(true); - expect(collectPaneWorkItemIds(panes).has(WL)).toBe(true); + it('matches an item whose ID is the exact label of a tab', () => { + const tabs = [tab('Work Items'), tab(WL)]; + expect(hasItemTab(WL, tabs)).toBe(true); + expect(collectTabWorkItemIds(tabs).has(WL)).toBe(true); }); - it('returns false when no pane carries the ID', () => { - const panes = [pane('Manually triggered implement Enable Main Street…')]; - expect(isActivePaneMatch(WL, panes)).toBe(false); + it('rejects a tab label that merely contains the item ID (exact match only)', () => { + const tabs = [tab(`implement ${WL}`), tab(`plan ${WL} - extra`)]; + expect(hasItemTab(WL, tabs)).toBe(false); + expect(collectTabWorkItemIds(tabs).has(WL)).toBe(false); + }); + + it('ignores tabs whose label is not a work-item ID', () => { + const tabs = [tab('Work Items'), tab('Downtime')]; + expect(collectTabWorkItemIds(tabs).size).toBe(0); + expect(hasItemTab(WL, tabs)).toBe(false); }); - it('only considers panes in the given workspace', () => { - const panes = [pane(`implement ${WL}`, 'wOther')]; - expect(isActivePaneMatch(WL, panes, 'wCurrent')).toBe(false); - expect(isActivePaneMatch(WL, panes, 'wOther')).toBe(true); + it('only considers tabs in the given workspace', () => { + const tabs = [tab(WL, 'wOther')]; + expect(hasItemTab(WL, tabs, 'wCurrent')).toBe(false); + expect(hasItemTab(WL, tabs, 'wOther')).toBe(true); }); - it('includes panes with an unknown workspace when filtering (fail-open)', () => { - const panes = [pane(`implement ${WL}`)]; - expect(isActivePaneMatch(WL, panes, 'wCurrent')).toBe(true); + it('includes tabs with an unknown workspace when filtering (fail-open)', () => { + const tabs = [tab(WL)]; + expect(hasItemTab(WL, tabs, 'wCurrent')).toBe(true); + }); +}); + +// ── Tab-based active marker for the selection list (WL-0MV1BWETT005GSUJ) ── + +describe('tab-based active marker (selection list)', () => { + const OTHER = 'WL-0MV24T2VJ007Q49J'; + + it('marks an item with a matching tab as in-progress even when the worklog status is open', () => { + const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; + markItemsActiveByTab(items, new Set([WL])); + expect(items[0].status).toBe('in-progress'); + }); + + it('leaves an item without a matching tab unchanged', () => { + const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; + markItemsActiveByTab(items, new Set([OTHER])); + expect(items[0].status).toBe('open'); + }); + + it('marks matching children recursively', () => { + const child = { id: WL, status: 'open', stage: 'plan_complete' }; + const parent = { id: 'WL-PARENT000000', status: 'open', stage: 'idea', children: [child] }; + markItemsActiveByTab([parent], new Set([WL])); + expect(parent.status).toBe('open'); + expect(child.status).toBe('in-progress'); + }); + + it('never re-opens terminal in_review / completed items (they keep their review marker)', () => { + const items = [ + { id: WL, status: 'open', stage: 'in_review' }, + { id: OTHER, status: 'completed', stage: 'done' }, + ]; + markItemsActiveByTab(items, new Set([WL, OTHER])); + expect(items[0].status).toBe('open'); + expect(items[1].status).toBe('completed'); + }); + + it('is idempotent: a still-open tab keeps the marker and a closed tab clears it', () => { + const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; + markItemsActiveByTab(items, new Set([WL])); + expect(items[0].status).toBe('in-progress'); + // Tab still open — a repeated pass must not mistake the marker for the + // true status and clobber the base bookkeeping. + markItemsActiveByTab(items, new Set([WL])); + expect(items[0].status).toBe('in-progress'); + // Tab closed — the marker clears back to the true worklog status without + // any `wl` re-fetch. + markItemsActiveByTab(items, new Set()); + expect(items[0].status).toBe('open'); + // Tab re-opened — the marker comes back. + markItemsActiveByTab(items, new Set([WL])); + expect(items[0].status).toBe('in-progress'); + }); + + it('keeps the bookkeeping status out of the serialized item shape', () => { + const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; + markItemsActiveByTab(items, new Set([WL])); + expect(Object.keys(items[0])).toEqual(['id', 'status', 'stage']); + }); + + it('applyActiveTabMarkers scopes the tab lookup to the current workspace', async () => { + const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; + const seen: Array = []; + await applyActiveTabMarkers( + items, + async (workspaceId) => { + seen.push(workspaceId); + return [{ tab_id: 't1', label: WL, workspace_id: 'wCurrent' }]; + }, + 'wCurrent', + ); + expect(seen).toEqual(['wCurrent']); + expect(items[0].status).toBe('in-progress'); + }); + + it('does not mark an item whose only matching tab is in another workspace', async () => { + const items = [{ id: WL, status: 'open', stage: 'plan_complete' }]; + await applyActiveTabMarkers( + items, + async () => [{ tab_id: 't1', label: WL, workspace_id: 'wOther' }], + 'wCurrent', + ); + expect(items[0].status).toBe('open'); + }); + + it('fails open when the tab lookup throws or returns null (no marker cleared)', async () => { + const thrown = [{ id: WL, status: 'open', stage: 'plan_complete' }]; + await applyActiveTabMarkers( + thrown, + async () => { + throw new Error('herdr unavailable'); + }, + 'wCurrent', + ); + expect(thrown[0].status).toBe('open'); + + const nulled = [{ id: WL, status: 'open', stage: 'plan_complete' }]; + await applyActiveTabMarkers(nulled, async () => null, 'wCurrent'); + expect(nulled[0].status).toBe('open'); }); }); @@ -207,51 +322,133 @@ describe('decideDemotion', () => { }); }); - it('releases an otherwise-stuck item to open at its claimed stage', () => { + it('releases an otherwise-stuck item to open at intake_complete (AC3 else-branch)', () => { expect(decideDemotion(inProgress('plan_complete'), { hasActivePane: false, blocked: false })).toEqual({ status: 'open', - stage: 'plan_complete', + stage: 'intake_complete', }); }); }); +// ── Running-pane liveness (AC2 / F1) ────────────────────────────────── + +describe('running-pane liveness (AC2)', () => { + const tab = (label: string, agent_status?: string, workspace_id?: string): HydratorTab => ({ + tab_id: `t-${label}-${agent_status ?? 'none'}`, + label, + ...(workspace_id !== undefined ? { workspace_id } : {}), + ...(agent_status !== undefined ? { agent_status } : {}), + }); + + it('isActivePaneMatch: matches an exact-label tab hosting a working agent', () => { + const panes = [tab('Work Items'), tab(WL, 'working')]; + expect(isActivePaneMatch(WL, panes)).toBe(true); + }); + + it('isActivePaneMatch: does NOT match a done (finished) pane (F1 repro)', () => { + const panes = [tab(WL, 'done')]; + expect(isActivePaneMatch(WL, panes)).toBe(false); + }); + + it('isActivePaneMatch: does NOT match an exited pane', () => { + const panes = [tab(WL, 'exited')]; + expect(isActivePaneMatch(WL, panes)).toBe(false); + }); + + it('isActivePaneMatch: fail-open — an un-reported agent_status still counts as active', () => { + const panes = [tab(WL)]; + expect(isActivePaneMatch(WL, panes)).toBe(true); + }); + + it('isActivePaneMatch: exact label only (a containing label is not a match)', () => { + const panes = [tab(`implement ${WL}`, 'working')]; + expect(isActivePaneMatch(WL, panes)).toBe(false); + }); + + it('isActivePaneMatch: scopes to the current workspace', () => { + expect(isActivePaneMatch(WL, [tab(WL, 'working', 'wOther')], 'wCurrent')).toBe(false); + expect(isActivePaneMatch(WL, [tab(WL, 'working', 'wCurrent')], 'wCurrent')).toBe(true); + }); +}); + +// ── AC1: pane-title ID extraction ───────────────────────────────────── + +describe('extractWorkItemIdsFromPaneTitles (AC1)', () => { + it('extracts every work-item ID found in pane labels', () => { + const panes = [ + { tab_id: 't1', label: `implement ${WL} --focused` }, + { tab_id: 't2', label: 'Work Items' }, + ]; + expect(extractWorkItemIdsFromPaneTitles(panes)).toEqual([WL]); + }); + + it('returns an empty array when no pane label carries an ID', () => { + expect(extractWorkItemIdsFromPaneTitles([{ tab_id: 't1', label: 'Nothing here' }])).toEqual([]); + expect(extractWorkItemIdsFromPaneTitles([])).toEqual([]); + }); +}); + // ── Orchestration ───────────────────────────────────────────────────── describe('runHydrationOnce', () => { - it('demotes a pane-less in_review item to completed', async () => { + it('demotes a tab-less in_review item to completed', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'in_review' }], - listActivePanes: async () => [], + listTabs: async () => [], }); const result = await runHydrationOnce(deps); expect(result).toMatchObject({ ok: true, considered: 1, demoted: 1 }); expect(applied).toEqual([{ id: WL, status: 'completed', stage: 'in_review' }]); }); - it('demotes a pane-less plain item to open at its claimed stage', async () => { + it('demotes a tab-less plain item to open at intake_complete (AC3 else-branch)', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listActivePanes: async () => [], + listTabs: async () => [], }); await runHydrationOnce(deps); - expect(applied).toEqual([{ id: WL, status: 'open', stage: 'plan_complete' }]); + expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); + }); + + it('demotes an item whose only matching tab hosts a done agent (F1)', async () => { + const { deps, applied } = makeDeps({ + listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], + listTabs: async () => [ + { tab_id: 't1', label: WL, agent_status: 'done', workspace_id: 'wCurrent' }, + ], + }); + const result = await runHydrationOnce(deps, 'wCurrent'); + expect(result.demoted).toBe(1); + expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); + }); + + it('keeps an item whose matching tab hosts a working agent', async () => { + const { deps, applied } = makeDeps({ + listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], + listTabs: async () => [ + { tab_id: 't1', label: WL, agent_status: 'working', workspace_id: 'wCurrent' }, + ], + }); + const result = await runHydrationOnce(deps, 'wCurrent'); + expect(result.demoted).toBe(0); + expect(applied).toEqual([]); }); it('demotes to blocked when an outbound dependency is active', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'in_progress' }], - listActivePanes: async () => [], + listTabs: async () => [], listOutboundDeps: async () => [{ id: 'WL-OTHER', status: 'open', stage: 'plan_complete' }], }); await runHydrationOnce(deps); expect(applied).toEqual([{ id: WL, status: 'blocked', stage: 'plan_complete' }]); }); - it('does not query dependencies when a live pane matches', async () => { + it('does not query dependencies when a matching tab exists', async () => { const listOutboundDeps = vi.fn(async () => []) as unknown as HydratorDeps['listOutboundDeps']; const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listActivePanes: async () => [{ pane_id: 'p1', label: `implement ${WL}`, workspace_id: 'w1' }], + listTabs: async () => [{ tab_id: 't1', label: WL, workspace_id: 'w1' }], listOutboundDeps, }); const result = await runHydrationOnce(deps, 'w1'); @@ -260,26 +457,26 @@ describe('runHydrationOnce', () => { expect(listOutboundDeps).not.toHaveBeenCalled(); }); - it('leaves a live-pane item untouched even when the dep list would block', async () => { + it('leaves a tab-matched item untouched even when the dep list would block', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'in_review' }], - listActivePanes: async () => [{ pane_id: 'p1', label: `implement ${WL}` }], + listTabs: async () => [{ tab_id: 't1', label: WL }], listOutboundDeps: async () => [{ id: 'WL-OTHER', status: 'open' }], }); await runHydrationOnce(deps); expect(applied).toEqual([]); }); - it('fails open (no demotion) when the pane list is unavailable', async () => { + it('fails open (no demotion) when the tab list is unavailable', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listActivePanes: async () => { + listTabs: async () => { throw new Error('herdr missing'); }, }); const result = await runHydrationOnce(deps); expect(result.ok).toBe(false); - expect(result.reason).toContain('pane list'); + expect(result.reason).toContain('tab list'); expect(applied).toEqual([]); }); @@ -288,7 +485,7 @@ describe('runHydrationOnce', () => { listInProgressItems: async () => { throw new Error('wl missing'); }, - listActivePanes: async () => [], + listTabs: async () => [], }); const result = await runHydrationOnce(deps); expect(result.ok).toBe(false); @@ -298,7 +495,7 @@ describe('runHydrationOnce', () => { it('counts a failed demotion as skipped, not demoted', async () => { const { deps } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listActivePanes: async () => [], + listTabs: async () => [], applyDemotion: async () => false, }); const result = await runHydrationOnce(deps); @@ -308,14 +505,14 @@ describe('runHydrationOnce', () => { it('treats a throwing dependency lookup as not-blocked and still releases the item', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listActivePanes: async () => [], + listTabs: async () => [], listOutboundDeps: async () => { throw new Error('dep lookup failed'); }, }); const result = await runHydrationOnce(deps); expect(result.demoted).toBe(1); - expect(applied).toEqual([{ id: WL, status: 'open', stage: 'plan_complete' }]); + expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); }); it('demotes multiple stuck items independently', async () => { @@ -325,13 +522,13 @@ describe('runHydrationOnce', () => { { id: WL, status: 'in-progress', stage: 'in_review' }, { id: other, status: 'in-progress', stage: 'plan_complete' }, ], - listActivePanes: async () => [], + listTabs: async () => [], }); const result = await runHydrationOnce(deps); expect(result.demoted).toBe(2); expect(applied).toEqual([ { id: WL, status: 'completed', stage: 'in_review' }, - { id: other, status: 'open', stage: 'plan_complete' }, + { id: other, status: 'open', stage: 'intake_complete' }, ]); }); }); @@ -352,12 +549,12 @@ describe('createHydratorRunner', () => { it('runs the hydration cycle when visible', async () => { const { deps, applied } = makeDeps({ listInProgressItems: async () => [{ id: WL, status: 'in-progress', stage: 'plan_complete' }], - listActivePanes: async () => [], + listTabs: async () => [], }); const run = createHydratorRunner(deps, { isVisible: async () => true }); const result = await run(); expect(result).toMatchObject({ ok: true, demoted: 1 }); - expect(applied).toEqual([{ id: WL, status: 'open', stage: 'plan_complete' }]); + expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); }); it('runs immediately on the hidden→visible focus-resume (no interval wait)', async () => { @@ -367,7 +564,7 @@ describe('createHydratorRunner', () => { ]); const { deps, applied } = makeDeps({ listInProgressItems, - listActivePanes: async () => [], + listTabs: async () => [], }); const run = createHydratorRunner(deps, { isVisible: async () => visible }); @@ -379,7 +576,7 @@ describe('createHydratorRunner', () => { visible = true; const result = await run(); expect(result).toMatchObject({ ok: true, demoted: 1 }); - expect(applied).toHaveLength(1); + expect(applied).toEqual([{ id: WL, status: 'open', stage: 'intake_complete' }]); }); }); @@ -389,7 +586,7 @@ describe('createProductionHydratorDeps', () => { it('exposes the four injectable seams', () => { const deps = createProductionHydratorDeps(); expect(typeof deps.listInProgressItems).toBe('function'); - expect(typeof deps.listActivePanes).toBe('function'); + expect(typeof deps.listTabs).toBe('function'); expect(typeof deps.listOutboundDeps).toBe('function'); expect(typeof deps.applyDemotion).toBe('function'); }); @@ -405,16 +602,267 @@ describe('createProductionHydratorDeps', () => { // // Contract pinned here (implement to this): // • every hydrator CLI spawn passes `{ stdin: 'ignore', stderr: 'pipe' }`; -// • `runHydrationOnce` passes the workspace id to `listActivePanes`, which -// the production deps translate to `herdr pane list [--workspace ]`; +// • `runHydrationOnce` passes the workspace id to `listTabs`, which +// the production deps translate to `herdr tab list [--workspace ]`; // • each step runs under a per-step timeout (`HydratorDeps.stepTimeoutMs`, // default below the 20 s watchdog); a timeout aborts the run with // `ok: false` and a `reason` containing the step label; // • each step logs `...