diff --git a/.worklog/config.yaml b/.worklog/config.yaml index a10c57ea6..12db49f2f 100644 --- a/.worklog/config.yaml +++ b/.worklog/config.yaml @@ -4,4 +4,3 @@ autoSync: false githubRepo: TheWizardsCode/ContextHub githubLabelPrefix: 'wl:' githubImportCreateNew: true -openBrainEnabled: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 247afa4ad..809b4d43c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## v1.0.18 (2026-10-04) +### Features +- Recent dispatches are now grouped by time of day, so you can see when work happened. (WL-0MUMM9NED009TLL3) +- When no questions are found, you now get a clear explanation of what's needed to clear the review flag. (WL-0MUH765F1003FKRK) +- Review and close idle agent panes in your workspace, with your approval before anything closes. (WL-0MUJMXVPO0016DZM) +- Failed tasks now wait before being retried, preventing wasted slots on repeated failures. (WL-0MUKYERLZ006ELL5) +- Downtime work now always picks the top item from your Herdr list, so automated tasks match what you see. (WL-0MTK1ILM2009QYB2) +- Automated work panes now open in your project's workspace, grouped by task. (WL-0MU321YK70035AYT) +- Press r then p for Producer Review, or r then i to start an interview. (WL-0MU95SGEB006HQAM) +- Automatically detects and safely fixes performance issues slowing down your machine. (WL-0MUJLCVRS0093EJJ) +- Covered items now show a dimmed audit icon so you can tell they're already reviewed. (WL-0MUBVH8QG0020H9L) +- Completed work can no longer ship without an audit unnoticed, with warnings and a report to catch gaps. (WL-0MUBVH9FV0027COG) +- Press f d to review the 20 most recent auto-dispatched work items and their outcomes. (WL-0MUGLL9SS002E1D2) +- Audits now run one at a time per machine, so they finish reliably instead of failing when several run at once. (WL-0MUIVE0YG000UVIA) +- Completed agent sessions now close automatically, freeing up your machine's resources. (WL-0MUJL1NAH0042GOS) +- Audit freshness now works consistently everywhere, so items won't appear fresh in one view and stale in another. (WL-0MUBVH7ZR009PP80) +- Automated downtime work now appears in its own project tab, keeping projects separate. (WL-0MTRQT482001SNXC) +- Adds automated checks to keep work item recovery reliable, so stalled tasks resume correctly. (WL-0MTF5WO4Q005YBVH) +- The review queue banner now tells you whether to keep auditing or ship. (WL-0MUJQFXYC009CFRJ) +### Bug Fixes +- Interview questions now display in full, so you can see every option and note before answering. (WL-0MUKCGV3X0030W6K) +- Stage filters now show in-progress work instead of hiding it. (WL-0MUIB7D30009KG00) +- Automated tasks now open in the correct project workspace instead of a shared one. (WL-0MUR5FUWD00024XN) +- The top info line now stays visible even when the help text wraps. (WL-0MTV979LK005YB1B) +- Rejecting work items with an apostrophe in the reason now works correctly. (WL-0MU7KEX65004X0U9) +- Long audit sessions no longer crash from a stale notification after a session reload. (WL-0MUIV50EJ007VH9B) +- Shortcut commands with special characters now work correctly. (WL-0MUKH3DVA00488U0) +- Fixed a test that was checking for an outdated keyboard shortcut hint. (WL-0MUKDNC7L000I1JO) +### Other +- Old debug logs are now cleaned up automatically, keeping the game running fast and smooth. (WL-0MUJL0PTS009MC8F) +- Completing a work item no longer shows stray error messages in your terminal. (WL-0MTH28WDS008WMXA) + ## v1.0.17 (2026-09-30) ### Features - Press r then i to start an interview on the selected work item. (WL-0MU95SGEB006HQAM) diff --git a/CLI.md b/CLI.md index 4f51ac086..a25c4a04f 100644 --- a/CLI.md +++ b/CLI.md @@ -1165,15 +1165,12 @@ wl plugins Worklog comes bundled with an example stats plugin installed. -- `openbrain` — Manage OpenBrain submission queue (`status`, `resubmit`). - `stats` — Show custom work item statistics (example plugin provided in this repo). - `ampa` — AMPA plugin: manage AMPA containers and workspace tasks (start, stop, status, run, list, start-work, finish-work). Examples: ```sh -wl openbrain status -wl openbrain resubmit WL-ABC123 wl ampa start # start AMPA services for this repo wl ampa status # show AMPA service status wl ampa list # list available AMPA containers/tasks @@ -1256,19 +1253,67 @@ Answers are persisted after each response, so an interrupted session can be resumed by re-running the command: only the questions that remain outstanding are asked. `needsProducerReview` is cleared once every question has an answer. +#### When there are no questions to answer + +If no clarifying-questions section is found (or it contains no parseable +questions) but the item is still flagged for producer review, the command +explains what the producer must do to clear the flag. The explanation is +produced by the local LLM proxy's `compact` model, prompted with the +instruction *"Explain what the producer needs to do in order to remove the +needsProducerReview flag"* plus item context (the description and the two most +recent comments). When the LLM is disabled or unreachable the command falls +back silently to the two most recent comments (`: `). + +The explanation is advisory only — it never mutates the description. + +After printing the explanation the command asks +`Clear the needsProducerReview flag? (y/N)` (default **No**); answering `y` +clears the flag via the store, anything else leaves it flagged. This prompt +only appears in the `noSection`/`noQuestions` cases; the **`allAnswered` +path is unchanged** (`WL-0MU55UDBJ008DJ67`) and still clears the flag +automatically without prompting. No prompt is shown — and the flag is never +mutated — in `--json` mode or other non-interactive contexts. + Options: - `--prefix ` — Operate on a specific prefix (optional). +- `--json` — Non-interactive mode: prints JSON with + `needsProducerReview` (boolean), `producerReviewExplanation` (string or + `null`), `noSection`, `noQuestions`, `total`, `outstanding` and + `allAnswered`. Performs no prompts, no mutation and (with `--no-llm`) no + LLM call. +- `--no-llm` — Disable the LLM explanation and use the comment fallback + (default: LLM on). +- `--model ` — Override the chat model used for the explanation. -> The command requires interactive (TTY) input; it cannot be used with -> `--json`. +> Without `--json`, the command requires interactive (TTY) input. Examples: ```sh wl interview WL-ABC123 +wl interview WL-ABC123 --no-llm +wl --json interview WL-ABC123 +``` + +#### `llm` configuration + +The chat provider used for the explanation is configured under the `llm` +section of `.worklog/config.yaml`, mirroring `embedding`: + +```yaml +llm: + baseUrl: http://192.168.0.199:8000/v1 + model: compact + apiKey: optional-key + timeoutMs: 15000 ``` +Defaults: `baseUrl` `http://192.168.0.199:8000/v1`, `model` `compact`, +`timeoutMs` `15000`. Environment variables `LLM_BASE_URL`, `LLM_MODEL`, +`LLM_API_KEY` and `LLM_TIMEOUT_MS` are used as fallbacks; config values take +precedence over environment variables. + ### `help` [command] Show help for a specific command. diff --git a/LOCAL_LLM.md b/LOCAL_LLM.md index 24bcb34ae..29e862c44 100644 --- a/LOCAL_LLM.md +++ b/LOCAL_LLM.md @@ -210,6 +210,29 @@ be used by agents via standard provider configuration (e.g. Ollama $env:OPENCODE_SERVER_PORT = 51625 ``` +### Direct LLM chat features (`llm.*` config) + +Some `wl` features call an OpenAI-compatible chat-completions endpoint +directly — currently the `wl interview` producer-review explanation, which +uses the `ChatClient` in `src/lib/llm.ts`. These are configured under the +`llm` section of `.worklog/config.yaml`, parallel to `embedding`: + +```yaml +llm: + baseUrl: http://192.168.0.199:8000/v1 + model: compact + apiKey: optional-key # omit for local providers that need no auth + timeoutMs: 15000 +``` + +Defaults: `baseUrl` `http://192.168.0.199:8000/v1`, `model` `compact`, +`timeoutMs` `15000`. Environment variables `LLM_BASE_URL`, `LLM_MODEL`, +`LLM_API_KEY` and `LLM_TIMEOUT_MS` are used as fallbacks; config values take +precedence over environment variables (matching the `embedding.*` precedence). +Every call is bounded by the timeout and degrades silently to a non-LLM +fallback on error. See [CLI.md](CLI.md#interview-options-id) for the +`wl interview --no-llm` opt-out. + --- ## Task routing guidance (what to run locally vs hosted) diff --git a/README.md b/README.md index 8a1d8eec3..46d0e48d6 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ You can get a lot of value from using Worklog as a memory for your agents. But y |----------|-------------| | [Heartbeat Skill](skill/heartbeat/SKILL.md) | Automated work item monitoring and audit orchestration for the Pi agent | | [PLUGIN_GUIDE.md](PLUGIN_GUIDE.md) | Plugin development guide and API reference | -| [LOCAL_LLM.md](LOCAL_LLM.md) | Configure local LLM providers (Ollama, Foundry) | +| [LOCAL_LLM.md](LOCAL_LLM.md) | Configure local LLM providers (Ollama, Foundry) and the direct `llm.*` chat config used by `wl interview` | | [MULTI_PROJECT_GUIDE.md](MULTI_PROJECT_GUIDE.md) | Multi-project setup with custom prefixes | | [API.md](API.md) | REST API endpoints and usage | | [docs/FILE_PATH_CONVENTION.md](docs/FILE_PATH_CONVENTION.md) | File path convention for work item descriptions | @@ -134,7 +134,6 @@ You can get a lot of value from using Worklog as a memory for your agents. But y | [AGENTS.md](AGENTS.md) | AI agent onboarding and workflow instructions | | [tests/README.md](tests/README.md) | Test suite documentation | | [examples/README.md](examples/README.md) | Example plugins | - | [OpenBrain Integration](docs/openbrain.md) | Documentation for the optional OpenBrain submission integration | ### Internal / Development @@ -148,6 +147,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; dedicated Dispatcher workspace with per-project tabs (one tab per work-item prefix) | +| [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/docs/design/mode-switch-worker.md b/docs/design/mode-switch-worker.md index b35874c45..e565eba56 100644 --- a/docs/design/mode-switch-worker.md +++ b/docs/design/mode-switch-worker.md @@ -43,6 +43,26 @@ presence and proxy idle state, without operator intervention: responses yield no switch and never crash or block the plugin. A `409` (mode-switch restart in progress) is a no-op, retried on a later tick. +### Drain before switching (WL-0MUL0KO7Q003O7YJ) + +When the idle window is met **and** the proxy is idle, the worker enters a +**draining** state instead of switching immediately: it waits until the active +session count has fallen to the cheap pool's budget (`freeSlots >= +DOWNTIME_AUDIT_MIN_FREE_SLOTS = 2`), then switches. In-flight work is never +killed — draining only delays the switch and blocks *new* dispatches. + +While draining: + +- The downtime dispatcher pauses **new** dispatches. The mode-switch worker + exposes `getIsDraining()`; `index.ts` forwards it to the dispatcher as + `config().drainPaused`, and `DowntimeWorker.tick()` refuses to dispatch while + it is `true`. The poll and `onProxyIdle` hook still run every tick, so the + mode-switch worker keeps observing the live free-slot count. +- Once the budget frees, the drain completes, the proxy switches to cheap, + `getIsDraining()` clears, and dispatch resumes on the next idle tick. +- An operator agent-route command (`onOperatorCommand`) cancels the drain + immediately, resets the idle clock, and keeps the proxy fast. + ### Restart semantics On plugin/pane restart the idle clock resets to "active now" (construction diff --git a/docs/dev/context-budget.thresholds.json b/docs/dev/context-budget.thresholds.json index 4d8b3766b..276032c4c 100644 --- a/docs/dev/context-budget.thresholds.json +++ b/docs/dev/context-budget.thresholds.json @@ -1,6 +1,6 @@ { "global_agents": 0, "project_agents": 1655, - "skills_prose": 239, - "total": 1894 + "skills_prose": 473, + "total": 2128 } diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index 0dc9a1887..a9ad26e60 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -266,96 +266,232 @@ coordination check-in. The legacy per-tier lookup chain in the Herdr head is genuinely empty — production-unreachable, see downtime-worker.ts) keeps the historical change-guard semantics unchanged. -### Project-workspace placement + item-ID tabs (WL-0MU321YK70035AYT) - -Automated downtime panes for a work item spawn **in the project's own herdr -workspace**, in a **tab labelled with the exact work-item id** — so automated -work is co-located with the project it belongs to and each item has a reusable -tab. The machine-wide `Dispatcher` anchor is retained only as the fallback for -the case where no project plugin pane can be resolved (and for scheduled -prompts, which have no work-item id). +### Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5) + +A dispatched session can end **without reaching a terminal stage** — the +agent exits, the pane is closed by the reaper, or the audit ends with no +recorded result. Those closes are recorded in the rolling dispatch log as +`entryType: 'pane-close'` entries carrying a `reasonCode` +(`agent-ended-no-terminal`, `audit-ended-no-result`, `producer-review`, +`risk-effort-incomplete`, …). Before this control, such an item became +re-eligible as soon as the dispatched success-marker was released, so a +repeatedly failing session could be re-selected on the very next idle cycle +and burn local-LLM slots in a tight loop. + +`isNonTerminalCooldownActive` (`packages/herdr/src/downtime-log.ts`) is a +**neutral, sequential filter** that excludes an item from re-dispatch of the +**same kind** for a minimum cooldown after its most recent pane closed +non-terminally. It is applied at the same point as the other non-safety +filters — after the dispatched-marker exclusion and before the code-freeze +gate — on every dispatch path: + +- `dispatchFromHerdrList` (direct Herdr-head dispatch), +- `computeMostImportantItem` (coordination offer computation), +- `dispatchFromCoordination` (leader dispatch of a remote offer). + +The filter consults the **most recent** `pane-close` entry for the +`(itemId, kind)` pair and holds the item only while **all** of the following +hold: + +1. the close was **non-terminal** — its `reasonCode` is not in the terminal + set `{none, reached-in-review}` (`none` covers + `closed-as-intake-complete` / `closed-as-plan-complete` / `audit-passed` / + `audit-failed`; `reached-in-review` is the implement-pane success close). + Any other/absent code is treated as non-terminal (fail-closed); +2. the item has **not advanced past the close's dispatched-at stage** — the + `stage` recorded on the close entry (written from the dispatched-at + marker). A genuinely progressed item is released immediately (parent AC4); +3. the close is **younger than the cooldown** — `now − closeTimestamp < + cooldownMs`. + +A later terminal close supersedes an earlier failure (most-recent wins), and +a later failure re-arms the cooldown. A missing or unparseable close +timestamp **fails closed** (skip), bounded by the stage-advancement release. + +**Neutral semantics.** A cooldown skip is **not** a strike and **not** a +`no-candidate`: it never counts towards the three-strike CLI-error rule and +never enters the no-candidate cooldown. The dispatch outcome carries the +distinct reason `non-terminal-cooldown` (`MostImportantItemResult` gains a +`{ok:true, nonTerminalCooldownHold:true}` variant), so a cooldown-held +backlog keeps polling rather than pausing. + +**Configuration.** `downtimeNonTerminalCooldownMs` is a plugin setting +(default **30 min**, `DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS`), clamped by +`clampDowntimeNonTerminalCooldownMs` to **[1 min, 24 h]** +(`DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS` / `..._MAX_MS`): the floor +prevents immediate retry, the ceiling prevents indefinite stranding. It is +re-read from settings every tick (live) and wired through +`DowntimeWorkerConfig.config().nonTerminalCooldownMs` into +`dispatchDowntimeWork`, `computeMostImportantItem`, `runCoordinationCheckIn` +and `dispatchFromCoordination`. + +### Pane placement: project workspace first, `Dispatcher` fallback (WL-0MUR5FUWD00024XN) + +Automated downtime panes spawn **inside the owning project's herdr +workspace**, in a **tab labelled with the exact work-item id** (primary +path). The machine-wide `Dispatcher` workspace is only the fail-closed +**fallback** when no project plugin pane resolves for the item's root. + +`dispatchClaimedTier` evaluates placement in this precedence order +(WL-0MUR5FUWD00024XN AC1): + +1. **Project workspace + item-ID tab** — `resolveProjectWorkspace` resolves + the herdr plugin pane whose logical root equals the item's worklog root + `R`, then `getItemTabAnchor` ensures/reuses the tab labelled with the exact + work-item id. On success the `Dispatcher` workspace is NOT used (AC2). +2. **Per-prefix tab in the `Dispatcher` workspace** — only when + `resolveProjectWorkspace` returns `null` (no plugin pane for `R`) and + `getDispatcherTabAnchor` is wired (AC3, AC5). +3. **Legacy single `Dispatcher` anchor** — only when neither project + workspace nor per-prefix resolver produces an anchor (pre-C1/test callers). + +A `null`/failed project-workspace resolution does **not** abort when a +fallback is wired; a project workspace that resolves but whose item-ID tab +cannot be provisioned aborts with `anchor-unavailable` (never a wrong +placement). An empty/unparseable per-prefix likewise fails closed. The +resolved anchor is forwarded as `--anchor ` to `send-to-pi.sh` +(`herdr pane split --pane `), `--no-focus` preserved; the new pane's +`workspace_id` is the project workspace (or `Dispatcher` fallback) and its +`tab_id` equals the item-ID (or prefix) tab. + +#### Project workspace + item-ID tab (primary path, WL-0MU321YK70035AYT) Invariant: for a `dispatchClaimedTier` spawn of `-` whose worklog root is `R`: -1. **Project workspace resolution** — resolve the herdr plugin pane - (`label == "Work Items"`) whose logical project root equals `R` and return - `{ paneId, workspaceId, tabId }`. The logical root is read from - `HERDR_RESOLVED_CWD` via `herdr pane process-info --pane ` + - `/proc//environ` — NOT the pane's reported `cwd` (which is the - plugin directory) and NOT the workspace label. -2. **Item-ID tab** — ensure/reuse a tab labelled exactly the work-item id in - that workspace, then anchor the pane to the tab's root pane. -3. **Spawn** — `spawnAgentPane` → `buildDowntimePaneArgs` → `--anchor - ` → `send-to-pi.sh` (`herdr pane split --pane `), - `--no-focus` preserved. The new pane's `workspace_id` equals the project - workspace and its `tab_id` equals the item tab. -4. **Root-pane cleanup** — after the dispatch pane has spawned, the item - tab's initial root pane (herdr's automatically-provisioned empty bash - pane, used only as the split anchor) is closed, so the tab shows only the - productive dispatch pane (WL-0MU2EOHK900425VU). Only the ROOT pane is ever - closed: the anchor is closed only when live-pane liveness positively - confirms it is not itself a downtime dispatch pane (fail-safe — an - absent/failed liveness query leaves it open). The retained `Dispatcher` - fallback anchor is deliberately exempt: closing it would trigger the - stale-detection re-provision loop. - -If step 1 cannot resolve a project workspace for `R`, the dispatcher falls -back to the **retained machine-wide `Dispatcher` anchor** (C0 -WL-0MTR01EU7005SYZG). A `null` anchor there degrades to the neutral +1. **Project-workspace resolution** — `resolveProjectWorkspace(cwd, deps, R)` + enumerates the machine-wide `herdr pane list`, keeps only plugin panes + (`label == "Work Items"`), and reads each candidate's logical project root + from `HERDR_RESOLVED_CWD` (via `herdr pane process-info --pane ` → + `shell_pid` → `/proc//environ`). Matching is on the logical root + ONLY — never the pane's reported `cwd`, never the workspace label. + Ambiguity (≥2 matching panes) is deterministic: the focused pane wins, else + the lowest pane id. Fail-closed `null` on any unreadable boundary + (`pane list`/`process-info` failure, missing `shell_pid`, unreadable + `/proc`, missing `HERDR_RESOLVED_CWD`, no match). +2. **Item-ID tab resolution** — `getItemTabAnchor(cwd, deps, workspaceId, + itemId)` fast-paths an existing tab labelled exactly the work-item id + whose root pane is alive, else creates it under the coordination lock with + a double-check (`herdr tab create --workspace --label + --no-focus`). No persistence file: the tab label IS the key, discovered + via `herdr tab list`, so a second dispatch for the same item reuses the + same tab (never a duplicate). +3. **Spawn** — `--anchor `, `--no-focus` preserved. + +When the workspace resolves but the item-ID tab cannot be provisioned, the +dispatch fails closed with `anchor-unavailable` — it never places the pane in +the `Dispatcher` workspace (WL-0MU321YK70035AYT). + +#### Per-prefix tabs in the Dispatcher workspace (fallback, C1, WL-0MTRQT482001SNXC) + +Retained as the AC3 fallback for roots with no resolvable project workspace +(and the path used by pre-project-workspace/test callers). For such a +`-` item: + +1. **Prefix extraction** — `prefix = candidate.id.split('-', 1)[0]` (the raw + substring before the first `-`, case-preserved; `WL-…` → `WL`, + `TCE-…` → `TCE`, `CG-…` → `CG`). +2. **Per-prefix tab resolution** — `getDispatcherTabAnchor(cwd, deps, prefix)` + ensures the single `Dispatcher` workspace exists, finds-or-creates a tab + labelled exactly `prefix` there, and returns its root (anchor) pane. +3. **Spawn** — the pane's `workspace_id` is the `Dispatcher` workspace and its + `tab_id` equals the prefix tab. + +**Fail-safe (no fallback):** when the per-prefix resolver is reached and +returns `null`/throws, the dispatch degrades to the neutral `{ dispatched: false, reason: 'anchor-unavailable' }` — **no pane and no -marker, never the leader's current pane**. If the project workspace resolves -but the item tab cannot be provisioned, the dispatch also fails closed -(`anchor-unavailable`) rather than dropping the pane into the `Dispatcher` -workspace. +marker, never the leader's current pane, never the legacy single anchor, and +never a wrong tab**. An empty/unparseable prefix likewise fails closed. + +**Idempotency & concurrency.** The persisted per-prefix map +(`~/.herdr/downtime/downtime-dispatch-tab-anchors.json`, atomic tmp+rename) is +the authority for reuse and has the shape: + +```json +{ + "workspaceId": "w13", + "byPrefix": { + "WL": { "tabId": "w13:t5", "paneId": "w13:p23" }, + "TCE": { "tabId": "w13:t6", "paneId": "w13:p31" } + } +} +``` + +- **Fast path:** a persisted entry whose pane is alive is reused directly — + no `herdr tab list` round-trip. +- **Stale entry:** a persisted entry whose pane is dead is ignored and + re-provisioned on the next dispatch for that prefix. +- **Create-or-reuse:** on a cache miss the resolver lists tabs in the + `Dispatcher` workspace and adopts a tab labelled exactly `prefix` whose root + pane is alive. +- **Concurrent first-dispatch:** tab creation runs under the coordination + lock (`tryAcquireCoordLock` in `~/.herdr/downtime`) with a **double-check + inside the lock**, so two leaders racing on a previously unseen prefix + produce exactly one tab. A holder that cannot acquire the lock re-checks + once and otherwise fails closed (never a duplicate tab). +- **CLI drift:** `tab list`/`tab create`/`pane list` parsers tolerate the + `tab_id`/`tabId`/`id` and `pane_id`/`paneId`/`id` key variants and a nested + `result` envelope; an unparseable response is **fail-closed** (`null`) and + logs the raw output (never silently treated as "no tab" → duplicate). Lifecycle (`packages/herdr/src/dispatcher-anchor.ts`): -- **Project resolver:** `resolveProjectWorkspace(cwd, deps, root)` enumerates - machine-wide `herdr pane list`, keeps panes with `label == "Work Items"`, - reads each candidate's logical root (above), and matches `root`. Ambiguity - (≥2 matches) is deterministic — the **focused** matching pane wins, else the - **lowest pane id** — and never selects another root's workspace. Fail-closed - `null` on any CLI/parse error, missing `shell_pid`, missing/unreadable - `/proc//environ`, missing `HERDR_RESOLVED_CWD`, or no match. -- **Item tab:** `getItemTabAnchor(cwd, deps, workspaceId, itemId)` fast-paths - an existing tab labelled exactly `itemId` whose root pane is alive; else - under the coordination lock (with a double-check) adopts a matching tab or - runs `herdr tab create --workspace --label --no-focus` and - returns the new `tab_id` + `root_pane.pane_id`. A second dispatch for the - same item reuses the same tab (never a duplicate); no persistence file is - needed — the tab label IS the key, discovered via `herdr tab list`. - After the first dispatch the tab's root pane is closed (see invariant - step 4) so the tab is never left with an empty bash pane; a later dispatch - for the same item reuses the surviving dispatch pane as the split anchor - (WL-0MU2EOHK900425VU). +- **Project workspace + item-ID tab (primary):** `resolveProjectWorkspace` + resolves the plugin pane for the item's root; `getItemTabAnchor` returns the + anchor pane for the exact item-ID tab, creating it on first use under the + coordination lock. `dispatchClaimedTier` consults this path first whenever + both deps are wired. +- **Per-prefix tab (fallback):** `getDispatcherTabAnchor(cwd, deps, prefix)` + ensures the `Dispatcher` workspace, fast-paths the persisted live anchor, + else lists tabs (`herdr tab list --workspace `), else under the + coordination lock (with a double-check) runs `herdr tab create --workspace + --label --no-focus` and returns the new `tab_id` + + `root_pane.pane_id`. Consulted only when no project workspace resolves. - **Fallback anchor:** `getDispatcherAnchor(cwd, deps)` provisions one `Dispatcher` workspace + persisted anchor pane in the machine coordination dir (`~/.herdr/downtime/downtime-dispatch-anchor.json`, atomic tmp+rename, - under the coordination lock). The freshly-provisioned root pane is adopted - into a `Downtime` tab (`herdr pane move --new-tab --tab-label Downtime - --no-focus`, best-effort) so the workspace never shows a blank pane - (WL-0MU2EOHK900425VU). `isPaneAlive` (`herdr pane get `) detects a closed - anchor and re-provisions. -- **Wiring:** `createDowntimeDeps` wires `defaultProjectWorkspaceResolver`, - `defaultItemTabAnchorResolver`, and (fallback + scheduled) - `defaultDispatcherAnchorResolver`. -- **CLI shape tolerance:** the `pane list` / `pane process-info` / `tab list` / - `tab create` / workspace-scoped `pane list` parsers accept a nested `result` - envelope, log lines before the JSON, `pane_id`/`paneId`, - `workspace_id`/`workspaceId`, `tab_id`/`tabId`, `shell_pid`/`shellPid`, - `label`/`title`, and `focused`/`is_focused`. A parse failure is - **fail-closed** (`null`) and logs the raw output. -- **Retired:** per-prefix routing (`getDispatcherTabAnchor`, - `candidate.id.split('-')[0]` tab labels) and - `downtime-dispatch-tab-anchors.json` are removed. `getDispatcherAnchor` and - `downtime-dispatch-anchor.json` remain **only** for the AC4 fallback and - scheduled prompts. - -Duplicate `Dispatcher` workspaces are harmless — the anchor file is the -authority, not the label count; close surplus idle ones one at a time without -disturbing active `Downtime triggered …` panes. Manual `open-worklist` / -`open-pi-agent` flows are unaffected. + under the coordination lock). It is used **only** for scheduled prompts + (which have no work-item id) and as the final fallback when neither the + project workspace nor the per-prefix resolver yields an anchor. The + freshly-provisioned root pane is adopted into a `Downtime` tab (`herdr pane + move --new-tab --tab-label Downtime --no-focus`, best-effort) so the + workspace never shows a blank pane (WL-0MU2EOHK900425VU). `isPaneAlive` + (`herdr pane get `) detects a closed anchor and re-provisions. +- **Wiring:** `createDowntimeDeps` wires `defaultProjectWorkspaceResolver` / + `defaultItemTabAnchorResolver` (primary), `defaultDispatcherTabAnchorResolver` + (per-prefix fallback), and `defaultDispatcherAnchorResolver` (final fallback + + scheduled prompts). + +#### Troubleshooting: pane landed in the wrong tab/workspace + +1. **Confirm the item's root resolves a project plugin pane** — `herdr pane + list` must include a pane labelled `Work Items` whose process environ has + `HERDR_RESOLVED_CWD` equal to the item's worklog root (`herdr pane + process-info --pane ` → `shell_pid` → `grep HERDR_RESOLVED_CWD + /proc//environ`). If it does not, the dispatch intentionally falls + back to the `Dispatcher` workspace (per-prefix tab) — run the plugin from + the project root so the plugin pane reports the right `HERDR_RESOLVED_CWD`. +2. **Project path** — `herdr tab list --workspace ` must + show a tab labelled exactly the work-item id, and the `Downtime triggered …` + pane's `tab_id` must equal that tab's `tab_id` (`herdr pane list --workspace + `). A missing/wrong tab means the item-ID tab provision failed (check + the worker log for `[worklog-plugin] Dispatcher tab create …`). +3. **Dispatcher fallback path** — when the project workspace did not resolve, + `herdr tab list --workspace ` must show a tab + labelled with the item's prefix, and the pane's `tab_id` must equal it. + Inspect `~/.herdr/downtime/downtime-dispatch-tab-anchors.json` — a stale + `paneId` (dead) is re-provisioned on the next dispatch; a malformed file is + treated as "no anchors yet" (tabs are rediscovered via `herdr tab list`). +4. If a dispatch reports `anchor-unavailable`, the resolved path's anchor + could not be provisioned this cycle — the resolver deliberately does **not** + fall back to the leader pane or (for the project path) a wrong tab; fix the + herdr CLI/parse failure and the next idle window retries. + +Duplicate `Dispatcher` workspaces are harmless — the persisted anchor file is +the authority, not the label count; close surplus idle ones one at a time +without disturbing active `Downtime triggered …` panes. Manual +`open-worklist` / `open-pi-agent` flows are unaffected (this placement applies +only to automated downtime dispatch). ### No-candidate cooldown & the empty offer file @@ -943,6 +1079,7 @@ status refresh unchanged at 30s.** | Follower check-in | 5 min (`DEFAULT_COORDINATION_CHECK_IN_MS`, WL-0MTMPSCL8000O45H) — non-leader re-offer | `downtime-worker.ts` | | No-candidate cooldown | 60 min (`downtimeNoCandidateCooldownMs`; probe-before-pause in coordination mode, re-offer cancels) | `downtime-worker.ts` | | Success-marker staleness window | **24 h** (`downtimeMarkerStaleWindowMs`; clamped to 1 h – 7 d; releases a stranded success marker at an unchanged stage, WL-0MU6UL0RJ008IHGT) | `DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS`, `clampDowntimeMarkerStaleWindowMs` (`downtime-worker.ts`) | +| Non-terminal pane-close cooldown | **30 min** (`downtimeNonTerminalCooldownMs`; clamped to 1 min – 24 h; holds same-kind re-dispatch after a non-terminal pane close, WL-0MUKYERLZ006ELL5) | `DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS`, `clampDowntimeNonTerminalCooldownMs` (`downtime-worker.ts`) | | Pane-closure reaper cadence | **60 s** (`PANE_CLOSE_REAPER_INTERVAL_MS`; gated by `paneCloseEnabled`, WL-0MUJL1NAH0042GOS) | `pane-close-scheduler.ts` | | Pane-closure idle threshold | **30 min** (`paneCloseIdleThresholdMinutes`; clamped to 1 min – 24 h) | `pane-close-scheduler.ts` | | (removed) Max running downtime panes | **none** — no client-side pane cap; the LLM idle / free-slot check is the concurrency limiter (WL-0MU2EP6JL006A1U3) | `downtime-worker.ts` | @@ -953,7 +1090,9 @@ settings file (`~/.config/herdr/worklog-plugin.json`, load (see `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` in `downtime-worker.ts`). The success-marker staleness window (`downtimeMarkerStaleWindowMs`) is likewise configurable and clamped on load -(`clampDowntimeMarkerStaleWindowMs`). +(`clampDowntimeMarkerStaleWindowMs`). The non-terminal pane-close cooldown +(`downtimeNonTerminalCooldownMs`) is likewise configurable and clamped on load +(`clampDowntimeNonTerminalCooldownMs`). ## Files & runtime artifacts @@ -1168,15 +1307,19 @@ trace. is a separate effort (`/skill:dispatch-logs`, WL-0MTJQOZ0K007KD40). - **Bounded window.** The log keeps only the most recent `DOWNTIME_LOG_MAX_ENTRIES` (100) entries, so a very busy period can push an - older candidate out of the retained window; the view therefore shows at most - the 20 most recent items that are still present in the log. -- **Projection semantics.** Rows are deduplicated by work item id, and the - 20 most recently active items are selected; the visible list is then - ordered exactly like the main selection list (Critical → plan/intake → - Idea → In Review, with the same within-group comparator). Each row is - annotated with the dispatch `kind` and the latest pane-close `outcome` - where available. The id and title come from the log for items absent from - `wl`, so closed/deleted items still appear. + older candidate out of the retained window. There is no separate result cap: + the view shows every item still present in the log. +- **Projection semantics.** Rows are deduplicated by work item id (one row + per id) and grouped into 4-hour **UTC** time blocks by each item's most + recent log timestamp (e.g. `00:00–04:00`, `04:00–08:00`, …, + `20:00–24:00`). Blocks are ordered **oldest first**, and within each block + items stay **newest-first**; a heading (`29 Sep 2026, 00:00–04:00`) is + rendered before each block through the existing group-heading path. Rows + whose timestamp is missing or unparseable collect into a trailing + **`Unknown time`** block. Each row is annotated with the dispatch `kind` + and the latest pane-close `outcome` where available. The id and title come + from the log for items absent from `wl`, so closed/deleted items still + appear. - **Same icons as the live views.** The log decides which items appear and their order, but a row for an item that still exists in `wl` is rendered from the **live work item** (the same data the other Herdr views use), so diff --git a/docs/dev/pause-when-hidden.md b/docs/dev/pause-when-hidden.md index 5c11ba3e1..6ee3ed0ea 100644 --- a/docs/dev/pause-when-hidden.md +++ b/docs/dev/pause-when-hidden.md @@ -94,16 +94,29 @@ a background `wl sync` / list refresh could fire while the user was typing and replace the render state mid-keystroke. The fix defers that background work while any text-input overlay is open. -- **Shared predicate** — `isInputActive(formState, shipItDialog)` in - `worklist.ts` returns `true` when `formState !== null || shipItDialog !== null`. +- **Shared predicate** — `isInputActive(formState, shipItDialog, + blockedNoticeActive)` in `worklist.ts` returns `true` when + `formState !== null || shipItDialog !== null || blockedNoticeActive`. It is defined once and exported for unit testing; the scheduler checks it at the top of both the `refresh` (30s) and `sync` (60s) task callbacks, alongside the existing `paneGate.visible()` check, and returns early when true. - **Coverage** — the command-parameter form (`FormState` in `form-dialog.ts`), the Ship It confirmation dialog (`ShipItDialogState` in - `ship-it-dialog.ts`), and `md-note-edit` (which opens a `FormState`) are all - covered. There is no separate note-edit input state. + `ship-it-dialog.ts`), the Ship-mode blocked notice, and `md-note-edit` + (which opens a `FormState`) are all covered. There is no separate note-edit + input state. +- **Fast typing: coalesced chunks are tokenised (WL-0MTV67MZU003H7SH)** — the + tick gate stops background work, but it cannot prevent the PTY from + coalescing several keystrokes into one raw `stdin` `data` event during fast + typing. Both `FormState.handleInput` and `ShipItDialogState.handleInput` run + each chunk through the shared `splitKeypresses()` tokeniser + (`packages/herdr/src/key-input.ts`) and apply every key in order. Escape + sequences (arrows, Ctrl+Enter, bracketed-paste markers, SS3, Alt/Meta) stay + intact as a single token, so existing whole-chunk checks are unchanged. + Before this, `handleInput` only accepted a single character + (`key.length === 1`) and silently discarded a multi-character chunk — the + residual dropped-keypress bug seen when typing fast. - **Resume contract: skip, don't coalesce** — ticks that fall inside a typing window are silently dropped; the next regular tick after the overlay closes fires normally. No immediate post-close refresh is queued (avoids refresh @@ -191,7 +204,8 @@ used panes). Before this fix, N idle agents produced dozens of concurrent list re-render — `ps -eo args | grep 'wl sync' | grep -v grep` stays empty and the form keeps every keystroke. 4. Type rapidly across a scheduled tick boundary. Expected: **zero dropped - characters**. + characters**. Fast typing can deliver several keystrokes in one stdin + chunk; the shared `splitKeypresses()` tokeniser preserves every one. 5. Close the overlay. Expected: the next scheduled tick refreshes/syncs normally (no queued immediate refresh) and the header returns to its normal state. diff --git a/docs/feature-requests/openbrain-playwright-fallback-retrieval.md b/docs/feature-requests/openbrain-playwright-fallback-retrieval.md deleted file mode 100644 index aa04611ca..000000000 --- a/docs/feature-requests/openbrain-playwright-fallback-retrieval.md +++ /dev/null @@ -1,229 +0,0 @@ -# Feature Request: OpenBrain Playwright Fallback Retrieval - -**Work item:** OB-0MNHT5HTC0070EL7 *(OpenBrain project; tracked in this repo as GitHub issue #5)* -**Stage:** intake_complete -**Prepared by:** SourceBase (Discord bot integration layer) -**Handoff target:** OpenBrain PM / Engineering - ---- - -## Problem Statement - -Some web pages render their primary content with client-side JavaScript. The current OpenBrain -retrieval path (fast HTML extraction) fails to return usable content for these pages, causing -`ob add ` to ingest an empty or near-empty document. A fallback that uses a headless browser -(Playwright) is needed so OpenBrain can ingest JavaScript-heavy pages reliably when the primary -extractor returns insufficient content. - ---- - -## Users and User Stories - -### Discord community operators -> As a community operator, when someone posts a JS-heavy article in our Discord I want the link -> ingested by OpenBrain so the knowledge is searchable — without needing to manually pre-render -> the page. - -### Automation authors / operators running `ob add` -> As an automation author I want a configurable opt-in fallback so I can control the additional -> resource cost and CI behaviour that a headless browser introduces. - -### OpenBrain engineers implementing the fallback -> As an engineer I need a clear set of technical acceptance criteria and test strategies so I can -> implement Playwright retrieval safely and verify it in CI without requiring a real browser on -> every run. - ---- - -## Technical Acceptance Criteria - -1. **PlaywrightExtractor class** — A new extractor (e.g. `src/lib/ingestion/extractor-playwright.ts`) - implements the same interface as the existing extractor so it can be swapped in without changes - to the ingestion pipeline (see `src/lib/ingestion/service.ts` and `src/lib/ingestion/extractor.ts`). - -2. **Opt-in configuration flag** — Playwright fallback is disabled by default. It is enabled via an - explicit configuration flag (e.g. `ingestion.playwrightFallback: true` in the OpenBrain config - file or `OB_PLAYWRIGHT_FALLBACK=1` environment variable). Running `ob add` without the flag must - not launch a browser process. - -3. **Trigger condition** — The fallback fires only when the primary extractor returns a document - whose extracted-text length is below a configurable threshold (e.g. `minContentLength: 200` - characters). The threshold must be configurable and default to a value determined during - implementation. - -4. **Content compatibility** — The HTML/text produced by PlaywrightExtractor must be parseable by - the same downstream ingestion pipeline that processes output from the existing extractor (entry - point: `src/cli/commands/add.ts`). No structural changes to the ingestion pipeline are required. - -5. **Graceful degradation** — If Playwright is not installed or fails to launch, the ingestion run - logs a warning and completes with whatever content the primary extractor returned (empty or - partial), rather than throwing an unhandled error. - -6. **No credential leakage** — The Playwright session must not persist cookies, local storage, or - auth tokens between runs. Each retrieval uses a fresh browser context. - -7. **Timeout** — Browser navigation has an explicit timeout (default: 30 s, configurable). A - timeout is treated the same as a Playwright launch failure (warn + continue). - -8. **Telemetry emitted** — On every fallback invocation the implementation emits a structured log - entry (see Telemetry section). - ---- - -## CI / Testing Strategy - -### Guiding principle -Playwright introduces a real browser runtime that is impractical on every public CI run. The -testing strategy separates fast unit tests (always run) from integration tests (opt-in or -gated). - -### Record / playback fixtures (recommended default) -1. Record a set of HTTP interaction fixtures (e.g. using Playwright's network interception or a - companion HTTP mock server such as `msw` / `nock`) covering: - - A JS-heavy page that the primary extractor would return empty content for. - - A page that the primary extractor handles correctly (fallback must not fire). - - A page that returns a Playwright navigation timeout. - - A page behind a redirect chain. -2. Store fixtures in `test/fixtures/playwright-fallback/`. -3. Unit / integration tests use the fixtures; real browser is never launched in public CI. - -### Mock / stub option (minimal) -An alternative for projects that cannot store HTTP fixtures: stub `PlaywrightExtractor.fetch(url)` -at the module boundary and assert on: -- The correct call sequencing (primary extractor first, then fallback). -- The telemetry payload emitted. -- Graceful-degradation paths (install error, timeout). - -### Full integration test (opt-in) -Gate behind an environment variable `OB_PLAYWRIGHT_INTEGRATION=1`. When set: -- Actually launch a Chromium browser. -- Fetch one real JS-heavy URL (or a local dev server that serves a SPA). -- Assert that ingested content is non-empty and passes the minimum-length threshold. - -Suggested CI gate: run only on `main` branch pushes or nightly schedules; never on pull request -CI from forks to avoid resource/cost issues. - -### Self-hosted runner consideration -Full integration tests should target a self-hosted runner with Chromium pre-installed, or use the -`microsoft/playwright` Docker image. Document the runner label requirement in the GitHub Actions -workflow file. - -### Existing test reference -See the YouTube ingestion fallback (OB-0MNFXR3E4005TGYX) for a prior example of provider-specific -fallback tests and diagnostics. - ---- - -## Telemetry / Diagnostics Requirements - -Every Playwright fallback invocation must emit a structured log entry. Telemetry must be -non-sensitive: record only metadata, never user content, URLs, or secrets. - -Suggested fields: - -| Field | Type | Description | -|---|---|---| -| `event` | string | Fixed value `"playwright_fallback"` | -| `triggered` | boolean | Whether the fallback actually ran (false = threshold not met) | -| `primaryContentLength` | number | Character count returned by primary extractor | -| `fallbackContentLength` | number | Character count returned by PlaywrightExtractor (0 if not run or failed) | -| `durationMs` | number | Wall-clock time of the Playwright fetch in milliseconds | -| `success` | boolean | Whether PlaywrightExtractor returned usable content | -| `errorType` | string \| null | One of: `"launch_failed"`, `"timeout"`, `"navigation_error"`, `null` | -| `provider` | string | Always `"playwright"` for this fallback | - -Log destination: existing OpenBrain diagnostics / structured logger. Do not write telemetry to a -remote endpoint; keep it local and opt-in to ship to any external service. - ---- - -## Implementation Sketch - -``` -src/lib/ingestion/ -├── extractor.ts (existing — primary extractor, no changes required) -├── extractor-playwright.ts (new — PlaywrightExtractor, same interface) -└── service.ts (existing — add fallback orchestration logic) - -src/cli/commands/add.ts (existing — passes through unchanged) -``` - -Suggested orchestration in `service.ts`: - -```typescript -const primaryResult = await primaryExtractor.extract(url); -if ( - config.playwrightFallback && - primaryResult.text.length < config.minContentLength -) { - const fallbackResult = await playwrightExtractor.extract(url); - emitTelemetry({ triggered: true, ...metrics }); - return fallbackResult.text.length > 0 ? fallbackResult : primaryResult; -} -emitTelemetry({ triggered: false, primaryContentLength: primaryResult.text.length }); -return primaryResult; -``` - -The above is illustrative. Exact method signatures must match the extractor interface defined in -`src/lib/ingestion/extractor.ts`. - -### Dependency management -Add `playwright` (or `@playwright/test`) as an optional peer dependency so users who do not -enable the fallback do not need to install it. Guard the `require`/`import` behind a runtime -check to avoid import errors when the package is absent. - ---- - -## Related Work and Code Pointers - -Implementers should review the following before starting: - -| Reference | Relevance | -|---|---| -| OB-0MN9HWGAL001452N — Ingest CLI: file and URL ingestion | Primary ingestion flow. Playwright output must be compatible with this pipeline. | -| OB-0MNFXR3E4005TGYX — Fix YouTube ingestion for ob add | Prior provider-specific fallback: tests, diagnostics, and error handling patterns to reuse. | -| OB-0MN9CZ48N0053L9Q — Create a full PRD for OpenBrain | Product-level guidance: local-first preferences and documented fallback policies. | -| `src/cli/commands/add.ts` | CLI entry point for `ob add`; Playwright output must be usable here. | -| `src/lib/ingestion/service.ts` | Ingestion orchestration; fallback logic belongs here. | -| `src/lib/ingestion/extractor.ts` | Extractor interface that PlaywrightExtractor must implement. | - ---- - -## Constraints - -- **Scope boundary:** Implementation belongs in the OpenBrain repo. SourceBase (this repository) - is the Discord bot integration layer and produced this document only. -- **Opt-in only:** Playwright must never run unless explicitly enabled; do not change default - behaviour of `ob add`. -- **No secrets in telemetry:** Telemetry fields must not include URL content, page text, cookies, - auth tokens, or any user-identifiable data. -- **Optional dependency:** Playwright is a heavy runtime dependency. Declare it as optional/peer - so existing installs are not forced to download browser binaries. -- **Record/playback for public CI:** Real browser launches must be gated; fixture-based tests must - be the default. - ---- - -## Open Questions - -1. Should the minimum-content-length threshold be per-domain (allow-list) or global? Global is - simpler; per-domain gives more control. -2. Which Playwright browser channel should be the default (`chromium`, `firefox`, `webkit`)? - Recommend `chromium` for widest compatibility, but make it configurable. -3. Should the fallback be retried on timeout, or fail immediately? Recommend fail-immediately on - first timeout to keep latency predictable. -4. Is there an existing structured-log / telemetry abstraction in OpenBrain to hook into, or does - the engineer need to introduce one? - ---- - -## Acceptance Checklist (for PM / Engineering reviewers) - -- [ ] Feature request document reviewed and approved -- [ ] Child work items created in OpenBrain: PlaywrightExtractor, CI test harness, telemetry -- [ ] Configuration flag name agreed (e.g. `ingestion.playwrightFallback`) -- [ ] Extractor interface reviewed; PlaywrightExtractor signature confirmed -- [ ] Record/playback fixture strategy confirmed or alternative agreed -- [ ] Self-hosted runner label and CI gate condition documented -- [ ] Optional-dependency approach confirmed (peer dep vs. dynamic import guard) -- [ ] Telemetry field list reviewed; privacy sign-off obtained diff --git a/docs/openbrain.md b/docs/openbrain.md deleted file mode 100644 index 26dc33c2f..000000000 --- a/docs/openbrain.md +++ /dev/null @@ -1,87 +0,0 @@ -# OpenBrain Integration - -This project can optionally submit concise markdown summaries of completed work items to an external knowledge base called "OpenBrain". The integration is intentionally conservative: submissions are fired-and-forget so they never block or fail the user CLI flow, and failures are recorded to a local retry queue for later inspection/resubmission. - -Overview --------- -- When a work item transitions to `completed` (via `wl close` or `wl update --status completed`) the CLI will attempt to submit a short markdown summary to OpenBrain. -- Submission is done by invoking the OpenBrain CLI `ob add --stdin --title ` and passing the generated markdown on stdin. -- The submission is non-blocking: the CLI spawns the child process and returns; submission errors are logged and the entry is appended to a local JSONL retry queue. - -Where to find the integration ------------------------------ -- Implementation: `src/openbrain.ts` -- CLI hooks: `src/commands/close.ts` and `src/commands/update.ts` call `submitToOpenBrain(...)` when status transitions to `completed`. -- Unit tests: `tests/unit/openbrain.test.ts` -- Integration tests: `tests/cli/openbrain-close.test.ts` - -Configuration -------------- -Enable the feature in your project configuration (`.worklog/config.yaml`): - -```yaml -openBrainEnabled: true -``` - -By default the integration expects the `ob` CLI to be available on PATH. You can override the binary path with the `WL_OB_BIN` environment variable (or set it for a single invocation): - -```bash -WL_OB_BIN=/usr/local/bin/ob wl close WL-0001 -r "done" -``` - -Behavior & fallbacks --------------------- -- If spawning the `ob` binary fails (for example `ENOENT`) the failure is logged and a JSON object describing the submission is appended to the retry queue file: `.worklog/openbrain-queue.jsonl`. -- If the child process exits with a non-zero status the stderr text (if any) is captured and recorded together with the queued entry. -- Queued entries are written as one JSON object per line with the following fields: - - `workItemId` — the originating Worklog id - - `title` — the work item title - - `summary` — the markdown summary that would have been sent to OpenBrain - - `enqueuedAt` — ISO timestamp when the entry was queued - - `reason` — optional human-readable reason for queuing (spawn error or stderr) - -Summary format --------------- -Summaries are produced by `buildOpenBrainSummary(item)` (see `src/openbrain.ts`). The produced markdown contains: - -- A top-level heading with the work item title -- A short metadata block containing the work item id, type, assignee and completed timestamp -- An `## Objective` section containing the item's description (if present) -- An `## What was done` section containing the (redacted) audit text if present - -Security & Redaction ---------------------- -Audit text is redacted before it's persisted or submitted. See `src/audit.ts` for the redaction rules (email-like local parts are obfuscated). OpenBrain submissions therefore use the same redacted audit text shown in human output. Always review your redaction policy before enabling OpenBrain on repositories containing sensitive data. - -Developer notes ---------------- -- Module entrypoints: - - `submitToOpenBrain(item, options?)` — spawn the `ob` process and write the markdown summary to stdin. Options allow overriding the `ob` binary, the spawn implementation (useful for tests), the queue directory, and whether to wait for completion. - - `appendToQueue(entry, queueDir?)` — append a JSONL retry entry to the configured worklog directory. - - `resolveObBinary()` — resolves the `ob` binary with respect for `WL_OB_BIN`. -- Queue file name constant: `OPENBRAIN_QUEUE_FILE` (`openbrain-queue.jsonl`) - -Resubmitting queued entries ---------------------------- -The integration does not currently provide an automated resubmission command. To retry queued entries manually you can inspect and resubmit them with a short script. Example (POSIX shell + `jq`): - -```bash -QUEUE=.worklog/openbrain-queue.jsonl -if [ -f "$QUEUE" ]; then - while IFS= read -r line; do - title=$(echo "$line" | jq -r '.title') - echo "$line" | jq -r '.summary' | ob add --stdin --title "$title" || echo "Resubmit failed for $title" - done < "$QUEUE" -fi -``` - -Replace `ob` with the path in `WL_OB_BIN` if necessary. Be careful when resubmitting to avoid duplicate entries in OpenBrain. - -Testing -------- -- Unit tests for the integration are in `tests/unit/openbrain.test.ts` and validate summary construction, queue writing and spawn/error handling. -- CLI-level integration tests that exercise `wl close` / `wl update` behaviours are in `tests/cli/openbrain-close.test.ts`. - -Questions / TODO ----------------- -- A future enhancement could provide a `wl openbrain resubmit` command that safely retries queued entries and records outcomes in worklog comments. diff --git a/docs/pane-triage-skill.md b/docs/pane-triage-skill.md new file mode 100644 index 000000000..08d14dd5d --- /dev/null +++ b/docs/pane-triage-skill.md @@ -0,0 +1,114 @@ +# Pane-Triage Skill and the Pane-Close Bridge + +> Documents the approval-gated manual pane cleanup skill +> (`WL-0MUJMXVPO0016DZM`) and the cross-language bridge that exposes the +> shared pane-close classifier to it. + +## Background + +Two mechanisms decide whether a herdr pane may be closed: + +1. **Automatic reaper** (`WL-0MUJW9EEP009Q0V3`) — runs unattended in the + downtime worker and closes settled panes on a schedule. +2. **Pane-triage skill** (this work) — the manual, workspace-scoped, + approval-gated complement. A producer reviews idle panes (with work-item + context and a log tail) and closes a chosen subset. + +Both mechanisms must agree on what "idle" means. They do so by sharing one +classifier: `classifySession` / `extractFinalAssistantText` in +`packages/herdr/src/pane-close.ts`. Neither mechanism reimplements the logic. + +## The bridge: `packages/herdr/src/pane-close-cli.ts` + +The classifier lives in TypeScript; skills are Python. The bridge closes that +gap without duplicating logic: + +- `pane-close-cli.ts` wires the production `ReaperDeps` + (`createHerdrReaperDeps`) and invokes the shared `runReaperCli`. +- `--json` emits a single machine-parseable document for every evaluated + pane (classification, reason snapshot, work-item id, workspace, tab). +- `--workspace <id>` scopes evaluation to one workspace. +- `--invoking-pane <id>` echoes the invoking pane id (auto-populated from + `HERDR_PANE_ID`) so the skill can exclude it. +- `--fixture <path>` classifies offline from a raw `herdr pane list` document + — no herdr or `wl` subprocesses — so the skill's fixture tests exercise the + shared classifier end-to-end. + +Build it with: + +```bash +cd packages/herdr && npm run build # emits dist/pane-close-cli.js +``` + +The `bin` entry `worklog-pane-close` is registered in `packages/herdr/package.json`. + +### JSON shape + +```json +{ + "panes": [ + { + "paneId": "w2V:p7B", + "paneTitle": "Downtime triggered implement Foo - WL-0ABC123", + "itemId": "WL-0ABC123", + "workspaceId": "w2V", + "tabId": "w2V:t1X", + "kind": "implement", + "close": false, + "reasonCode": "implement", + "reasonSnapshot": { "needsProducerReview": false, "isInvokingPane": false }, + "sessionTail": ["...last 20 session lines..."], + "success": true + } + ], + "invokingPaneId": "w2V:p7H", + "timestamp": "2026-10-01T11:04:05.000Z", + "evaluated": 1, + "closeCount": 0, + "failureCount": 0, + "dryRun": true +} +``` + +## The skill + +`skill/pane-triage/` (tracked in ContextHub; deployed to the global skills +directory as `/skill:pane-triage`) contains: + +- `SKILL.md` — the agent-facing specification. +- `scripts/pane_triage.py` — core logic: workspace resolution, bridge + parsing, report rendering, recommendation rules, approval parsing, and + re-verify-before-close. +- `scripts/pane-triage.py` — CLI wrapper (argv, prompts, log tails, herdr + close calls). +- `tests/test_pane_triage.py` — offline fixture tests (30 tests). + +Idle classification is never reimplemented: `classify_via_bridge()` shells +out to the compiled bridge with `--json --dry-run`. + +## Never-close guards (universal) + +Applies to **both** the manual skill and the automatic reaper: + +- `implement` panes, +- items with `needsProducerReview=true`, +- the invoking pane (`HERDR_PANE_ID`), +- panes with live child processes. + +These are enforced in `classifySession` and surface as non-`close` reason +codes; the skill additionally excludes the invoking pane and review-pending +panes from its "Recommended to close" table. + +## Approval gate + +The skill never closes anything unattended. After rendering the report it +prompts `yes` / `select N,M` / `no`. `no` or ambiguous input closes nothing. +Immediately before each close it re-reads the pane list via the bridge and +drops any pane that is no longer idle (re-verification, AC7). + +## Testing + +```bash +cd skill/pane-triage && python3 -m pytest tests/ -q # skill logic +npx vitest run packages/herdr/src/pane-close-*.test.ts # bridge + classifier +``` diff --git a/package.json b/package.json index fbfd214c8..7919f1656 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "1.0.17", + "version": "1.0.18", "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 79699b4c8..87e1ef60d 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -5,7 +5,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b ## Features - **Browse work items** — Lists work items from `wl next` in a scrollable, keyboard-navigable list. The top-level list is root-only: child work items are hidden and appear only under their parent via expand — **at any depth** (epic → feature → task and deeper): any item with children (its `childCount > 0`) can be expanded with Tab/Enter, its children fetched on demand via `wl list --parent` and shown indented at their hierarchy depth (WL-0MSQ3FH1K000MMJW). Expanded parents **stay expanded across refreshes**: each auto/manual refresh re-fetches their children in parallel with the top-level list and swaps both in atomically, so the hierarchy never momentarily collapses or flickers (WL-0MSBVBNGH002RDP5). -- **Filter by stage, priority, or recent dispatches** — Press `f` then an axis key (`s`=stage, `p`=priority, `d`=dispatches), then a value key, or type `/wl <stage>` / `/wl --priority <priority>` / `/wl dispatches`. Stage axis: `f s i`=idea, `f s n`=intake, `f s p`=plan, `f s r`=review, `f s s`=sprint back to the default view. Priority axis: `f p l`=low, `f p m`=medium, `f p h`=high, `f p c`=critical, `f p s`=clear the priority filter. Dispatches axis: `f d` shows the most recent downtime dispatches from the local rolling log (see [Recent dispatches view](#recent-dispatches-view)). Stage, priority, and dispatches filters are **mutually exclusive** (replace semantics — applying one clears the others); sprint (`f s s`, `f p s`, or bare `/wl`) clears all three. Filtered views show every root item matching the filter's rule (open items for most stages; `completed`/`in-progress`/`open` for the in_review stage; log-derived rows for the dispatches view) — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW, WL-0MSKC8T46006999S, WL-0MUGLL9SS002E1D2) +- **Filter by stage, priority, or recent dispatches** — Press `f` then an axis key (`s`=stage, `p`=priority, `d`=dispatches), then a value key, or type `/wl <stage>` / `/wl --priority <priority>` / `/wl dispatches`. Stage axis: `f s i`=idea, `f s n`=intake, `f s p`=plan, `f s r`=review, `f s s`=sprint back to the default view. Priority axis: `f p l`=low, `f p m`=medium, `f p h`=high, `f p c`=critical, `f p s`=clear the priority filter. Dispatches axis: `f d` shows the downtime dispatches from the local rolling log grouped into 4-hour time blocks (see [Recent dispatches view](#recent-dispatches-view)). Stage, priority, and dispatches filters are **mutually exclusive** (replace semantics — applying one clears the others); sprint (`f s s`, `f p s`, or bare `/wl`) clears all three. Filtered views show every root item matching the filter's rule (open items for most stages; `completed`/`in-progress`/`open` for the in_review stage; log-derived rows for the dispatches view) — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW, WL-0MSKC8T46006999S, WL-0MUGLL9SS002E1D2) - **View details** — Press Enter on any item to see its full details (description, acceptance criteria, metadata, tags, priority, GitHub issue number, and audit status information such as audit result, review status, and last audit timestamp) - **Audit indicators** — The list view shows audit icons next to `in_review` items (✅ audited, ❌ failed, ❓ unaudited). The metadata section (list-mode panel and detail view) mirrors the list's icons with text labels — the selected item's Stage row uses the same audit-aware `in_review` icon (✅/❌/❓ fresh, ⏳ stale-passed, 🔍 otherwise), and the Audit/Reviewed rows pair their icons with text (e.g. `✅ ready to close`, `❌ needs review`). The detail view additionally shows the last audit timestamp. **Child coverage (WL-0MUBVH8QG0020H9L):** a `completed`/`in_review` child whose direct parent has a fresh audit is *covered* — its list row and metadata Stage row show the parent's audit-result symbol in a dimmed/grey style (visually distinct from a bright own-audit icon) and the metadata panel adds a `Covered by <parent-id>` row. Coverage is **derived at read time** (`isCoveredByParent` over the parent's audit freshness inputs via the shared `isAuditFresh` predicate) — nothing is persisted and there is no schema migration. A child with its own audit always shows its own verdict; an uncovered child (no own audit, parent demoted/stale) keeps the plain `🔍` stage icon and is never dispatched (see [audit-tier selection](#audit-tier-dispatch)). In text-only (`noIcons`) mode the covered indicator renders as `[COVERED]`. - **Chord shortcuts** — Multi-key chord sequences provide quick actions like updating priorities, stage/status, title, closing/deleting items, running workflows, and toggling review status (configurable via `shortcuts.json`) @@ -109,7 +109,7 @@ The plugin pane will then be available via the Herdr plugin system. - `f`, `p`, `h` — high priority items - `f`, `p`, `c` — critical priority items - `f`, `p`, `s` — Clear the priority filter (return to the unfiltered browse list) - - Press `f`, `d` — Show the most recent downtime dispatches (see [Recent dispatches view](#recent-dispatches-view)) + - Press `f`, `d` — Show the downtime dispatches grouped into 4-hour time blocks (see [Recent dispatches view](#recent-dispatches-view)) - Stage, priority, and dispatches filters are **mutually exclusive**: applying one replaces whichever other axis was active (single filter slot, replace semantics). Sprint (`f s s`, `f p s`, or `/wl` with no arguments) clears **all** of them. - Type `/wl dispatches` directly to activate the dispatches view (equivalent to `f d`). - `/wl <stage>` accepts shorthand aliases (`idea`, `intake`, `plan`, `progress`, `review`) and canonical stage names (`intake_complete`, `plan_complete`, `in_progress`, `in_review`) @@ -169,13 +169,16 @@ Semantics: since been **closed or deleted** still appears. The view never mutates the log or any work item; it is display-only and spawns no `wl` processes beyond the existing refresh (and the detail fetch below). -- **Up to 20 rows, main-list order** — rows are deduplicated by work item id - (one row per id) and the 20 most recently active items are selected; the - visible list is then ordered exactly like the main selection list - (Critical → plan/intake → Idea → In Review, using the same within-group - comparator), but without group headings. The log itself is bounded to the - most recent 100 entries (`DOWNTIME_LOG_MAX_ENTRIES`), so the 20 most recent - items normally fall inside the retained window. +- **4-hour time blocks, oldest first** — rows are deduplicated by work item id + (one row per id), then grouped into 4-hour **UTC** time blocks by each item's + most recent log timestamp (e.g. `00:00–04:00`, `04:00–08:00`, …, + `20:00–24:00`). Each block renders a heading (`29 Sep 2026, 00:00–04:00`) + through the existing group-heading path; blocks are ordered **oldest first** + while items inside a block stay **newest-first**. There is no result cap — + every item still retained in the log is shown. A row whose timestamp is + missing or unparseable is collected into a trailing **`Unknown time`** block. + The log itself is bounded to the most recent 100 entries + (`DOWNTIME_LOG_MAX_ENTRIES`), which is the effective upper bound on rows. - **Kind and outcome annotations** — where the log carries them, each row also shows the dispatch `kind` (`plan`/`intake`/`audit`/`risk-effort`/ `implement`) and the latest pane-close **outcome** (e.g. @@ -353,21 +356,28 @@ dispatches; the other herdr instances coordinate instead of polling: election degrades to the pre-refactor behavior for that instance (no dispatch from it); the existing dispatched-marker exclusion and CAS claim guards are preserved unchanged. -- **Pane placement (WL-0MU321YK70035AYT)** — automated downtime panes for a - work item spawn in the **project's own herdr workspace** — the workspace - hosting the `Work Items` plugin pane whose `HERDR_RESOLVED_CWD` equals the - item's worklog root — inside a **tab labelled with the exact work-item id**, - created on first use and reused thereafter. The pane is split from that - tab's root pane (`send-to-pi.sh --anchor <tabRootPaneId>`), so a second - dispatch for the same item adds a pane to the same tab. When no plugin pane - resolves for the root, the dispatcher falls back to the retained - machine-wide `Dispatcher` anchor - (`~/.herdr/downtime/downtime-dispatch-anchor.json`); scheduled prompts (no - work-item id) always use that anchor. Resolution is fail-closed (never - another project's workspace, never the leader's pane); the rejected - per-prefix tab routing (`downtime-dispatch-tab-anchors.json`) is retired. +- **Pane placement (WL-0MUR5FUWD00024XN)** — automated downtime panes + spawn in the **owning project's herdr workspace** inside a **tab labelled + with the exact work-item id**: `resolveProjectWorkspace` finds the `Work + Items` plugin pane whose logical root (`HERDR_RESOLVED_CWD`) equals the + item's worklog root, `getItemTabAnchor` ensures/reuses the item-ID tab, and + the pane is split from that tab's root pane (`send-to-pi.sh --anchor + <itemTabRootPaneId>`). The machine-wide `Dispatcher` workspace is the + **fallback only**: when no project plugin pane resolves for the item's root, + the pane anchors to a **per-prefix tab** (`WL` for ContextHub items, + `TCE`/`CG` for Tableau Card Engine items, …), created on first use and + reused thereafter (persisted map + `~/.herdr/downtime/downtime-dispatch-tab-anchors.json`, shape + `{ workspaceId, byPrefix: { "<PREFIX>": { tabId, paneId } } }`; tab + provisioning runs under the coordination lock with a double-check, and a + dead persisted pane is re-provisioned); when neither yields an anchor the + retained single `Dispatcher` anchor is used + (`~/.herdr/downtime/downtime-dispatch-anchor.json`). Resolution is + fail-closed (never another project's tab, never the leader's pane): a + null/failed resolution reports `anchor-unavailable` (no dispatch this + cycle). Scheduled prompts (no work-item id) keep the `Dispatcher` anchor. See - [docs/dev/downtime-dispatcher.md](../../docs/dev/downtime-dispatcher.md#project-workspace-placement--item-id-tabs-wl-0mu321yk70035ayt). + [docs/dev/downtime-dispatcher.md](../../docs/dev/downtime-dispatcher.md#pane-placement-project-workspace-first-dispatcher-fallback-wl-0mur5fuwd00024xn). Coordination operations (check-ins, elections/takeovers, eligibility drops) are recorded in `.worklog/downtime-coordination.log` — a separate rolling log from the dispatch log, so the dispatch-marker readers never see @@ -446,6 +456,13 @@ without per-slot data it fails closed to all-slots-free for `0 < N < total` tracking, no dispatch) after the worker finds no candidate in either stage — a genuine empty backlog (default: `3600000` = 60 minutes, floor 60s so the pause cannot be disabled or set trivially small) +- `downtimeNonTerminalCooldownMs` — Minimum cooldown between successive + downtime dispatches of the same item/kind after its pane closes without + reaching a terminal stage (`agent-ended-no-terminal`, + `audit-ended-no-result`) — a neutral skip (never a strike, never a + no-candidate) released early when the item advances past its + dispatched-at stage (default: `1800000` = 30 minutes, clamped to 1 min – + 24 h) The worker polls `GET {proxyUrl}/llama/local/status` on the poll interval. Idle means: llama-server running, no active **local** query (when the proxy @@ -969,6 +986,18 @@ instead of reaching the backlog tiers, so it never triggers the no-candidate cooldown; when none are due and the backlog is genuinely empty, the existing no-candidate cooldown applies unchanged. +**Non-terminal cooldown (WL-0MUKYERLZ006ELL5)** — an item whose previous +pane of the same kind (plan/intake/audit/risk-effort/implement) closed +without reaching a terminal stage (`agent-ended-no-terminal`, +`audit-ended-no-result`, …) is held out of re-dispatch for +`downtimeNonTerminalCooldownMs` (default 30 min, clamped to 1 min – 24 h) so +a repeatedly failing session cannot consume another local-LLM slot +immediately. Stage advancement releases the hold at once. The skip is +neutral — reason `non-terminal-cooldown`, never a strike and never +`no-candidate`, so it does not enter the empty-backlog pause. The same +filter runs on the direct Herdr-head path, the coordination offer +computation, and leader dispatch of a remote offer. + **Empty-backlog cooldown** — when the implement, plan, and intake `wl next` lookups and the critical-tier lookup genuinely return no candidate (the tab's project has nothing to dispatch), the worker enters a full **pause** @@ -1214,16 +1243,26 @@ keypresses mid-keystroke (WL-0MTV67MZU003H7SH). There is no user value in syncing or refreshing the list while a form is active, so the work is deferred until typing finishes. -- **Shared predicate** — `isInputActive(formState, shipItDialog)` in - `worklist.ts` returns `true` when `formState !== null || shipItDialog !== null`. +- **Shared predicate** — `isInputActive(formState, shipItDialog, + blockedNoticeActive)` in `worklist.ts` returns `true` when + `formState !== null || shipItDialog !== null || blockedNoticeActive`. Both scheduler ticks (the 30s `refresh` and the 60s `sync`) check it alongside the existing `paneGate.visible()` check and return early when it is true. The guard is defined once and shared — future text-input overlays are covered by extending the single predicate, not per-screen copies. - **All text-input sites covered** — the command-parameter form - (`FormState`), the Ship It confirmation dialog (`ShipItDialogState`), and - `md-note-edit` (which opens a `FormState`) are all covered. There is no - separate note-edit state to gate. + (`FormState`), the Ship It confirmation dialog (`ShipItDialogState`), the + Ship-mode blocked notice, and `md-note-edit` (which opens a `FormState`) + are all covered. There is no separate note-edit state to gate. +- **Fast typing never drops characters** — a raw stdin `data` event may + carry **several coalesced keystrokes** when the user types quickly (the + PTY batches bytes). Both text-input handlers run every chunk through the + shared `splitKeypresses()` tokeniser (`key-input.ts`) so each key is + applied in order; escape sequences (arrows, Ctrl+Enter, bracketed paste) + stay intact as one token. Previously a multi-character chunk was silently + discarded because only `key.length === 1` was accepted — the residual + "missing keystrokes when typing fast" bug that the tick gate alone could + not fix. - **Resume contract: skip, don't coalesce** — ticks are silently dropped while typing; the next regular tick after the overlay closes fires normally. No queued or immediate post-close refresh is emitted (this @@ -1295,11 +1334,12 @@ only one axis is ever active; the header shows which one (`(filtered: stage <stage>)` or `(filtered: priority <priority>)`). The **dispatches** view (press `f` + `d`, or `/wl dispatches`) is the third -filter axis. It uses the local rolling dispatch log to choose up to 20 -read-only rows (deduplicated by work item id) and renders each surviving item -from the live work item so its icons match the other views, ordered like the -main selection list; an item no longer in `wl` falls back to the log-derived -id/title so closed/deleted items still appear (WL-0MUGLL9SS002E1D2). It is +filter axis. It uses the local rolling dispatch log to choose read-only rows +(deduplicated by work item id, grouped into 4-hour UTC time blocks ordered +oldest-first) and renders each surviving item from the live work item so its +icons match the other views; an item no longer in `wl` falls back to the +log-derived id/title so closed/deleted items still appear +(WL-0MUGLL9SS002E1D2, WL-0MUMM9NED009TLL3). It is mutually exclusive with the stage and priority filters (the header shows `(filtered: dispatches)`), and a missing/malformed log renders an empty list rather than an error. See [Recent dispatches view](#recent-dispatches-view). diff --git a/packages/herdr/package.json b/packages/herdr/package.json index 4824f63ac..bff43ed5d 100644 --- a/packages/herdr/package.json +++ b/packages/herdr/package.json @@ -4,8 +4,11 @@ "private": true, "description": "Worklog work item selection list for Herdr", "type": "module", + "bin": { + "worklog-pane-close": "dist/pane-close-cli.js" + }, "scripts": { - "build": "tsc && cp src/shortcuts.json dist/shortcuts.json", + "build": "tsc && cp src/shortcuts.json dist/shortcuts.json && chmod +x dist/pane-close-cli.js", "dev": "tsx src/index.ts" }, "dependencies": { diff --git a/packages/herdr/src/coordination-dispatch.test.ts b/packages/herdr/src/coordination-dispatch.test.ts index 7a5d31b4f..5bfbabc94 100644 --- a/packages/herdr/src/coordination-dispatch.test.ts +++ b/packages/herdr/src/coordination-dispatch.test.ts @@ -866,6 +866,89 @@ describe('dispatchFromCoordination eligibility re-check (WL-0MTOC170J001QMIT)', // ── runCoordinationCheckIn ──────────────────────────────────────────── +// ── Non-terminal pane-close cooldown on the coordination path ───────── +// (WL-0MUKYERLZ006ELL5) + +describe('dispatchFromCoordination non-terminal cooldown (WL-0MUKYERLZ006ELL5)', () => { + const COOLDOWN_MS = 30 * 60 * 1000; + + /** A temp worklog root carrying one non-terminal plan pane-close entry. */ + function rootWithClose(timestamp: string): string { + const root = mkdtempSync(join(tmpdir(), 'herdr-cooldown-root-')); + mkdirSync(join(root, '.worklog'), { recursive: true }); + writeFileSync( + join(root, '.worklog', 'downtime-dispatches.log'), + JSON.stringify({ + entryType: 'pane-close', + itemId: 'WL-COOL', + kind: 'plan', + stage: 'intake_complete', + reasonCode: 'agent-ended-no-terminal', + outcome: 'requires-attention', + timestamp, + closed: true, + }) + '\n', + 'utf8', + ); + return root; + } + + it('keeps the offer but skips it with the neutral cooldown reason', async () => { + const root = rootWithClose(new Date().toISOString()); + try { + const entry = makeEntry('inst-cool', 'WL-COOL', root); + writeCoordinationFile(testDir, { version: 1, entries: [entry] }); + const deps = makeCoordinationDeps({ + fetchItem: vi.fn().mockResolvedValue({ + ok: true, + info: itemInfo({ id: 'WL-COOL', status: 'open', stage: 'intake_complete' }), + }), + }); + + const outcome = await dispatchFromCoordination(deps, [entry], { + model: 'plan', + cwd: '/repo', + coordinationDir: testDir, + nonTerminalCooldownMs: COOLDOWN_MS, + }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('non-terminal-cooldown'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + // The offer is KEPT (still valid) — it dispatches once the cooldown ends. + expect(getEntry(testDir, 'inst-cool')).not.toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('dispatches a cooldown-elapsed offer normally', async () => { + const root = rootWithClose(new Date(Date.now() - COOLDOWN_MS - 1_000).toISOString()); + try { + const entry = makeEntry('inst-cool2', 'WL-COOL', root); + writeCoordinationFile(testDir, { version: 1, entries: [entry] }); + const deps = makeCoordinationDeps({ + fetchItem: vi.fn().mockResolvedValue({ + ok: true, + info: itemInfo({ id: 'WL-COOL', status: 'open', stage: 'intake_complete' }), + }), + }); + + const outcome = await dispatchFromCoordination(deps, [entry], { + model: 'plan', + cwd: '/repo', + coordinationDir: testDir, + nonTerminalCooldownMs: COOLDOWN_MS, + }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('runCoordinationCheckIn', () => { it('upserts the instance most-important item on first check-in (Herdr head offer)', async () => { const deps = makeCoordinationDeps({ diff --git a/packages/herdr/src/dispatch-view.test.ts b/packages/herdr/src/dispatch-view.test.ts index 7c4149eb9..158696205 100644 --- a/packages/herdr/src/dispatch-view.test.ts +++ b/packages/herdr/src/dispatch-view.test.ts @@ -233,7 +233,10 @@ describe('mergeDispatchRow — surviving items render exactly like live rows', ( expect(mergedLine).toContain('[plan]'); }); - it('strips the live browse grouping so the view stays a flat newest-first list', () => { + it('strips the live browse grouping so the caller can stamp time-block groups', () => { + // The dispatches view assigns its own 4-hour time-block group/label in + // fetchItemsForView (WL-0MUMM9NED009TLL3); a stale live browse group must + // not leak through and emit a spurious heading. const grouped: WorkItem = { ...live, group: 2, groupLabel: 'In Review' }; const merged = mergeDispatchRow(grouped, baseRow); expect(merged.group).toBeUndefined(); diff --git a/packages/herdr/src/dispatch-view.ts b/packages/herdr/src/dispatch-view.ts index a6b1e1337..c360cea5d 100644 --- a/packages/herdr/src/dispatch-view.ts +++ b/packages/herdr/src/dispatch-view.ts @@ -92,10 +92,10 @@ export function buildDispatchWorkItem(row: RecentDispatchRow): WorkItem { */ export function mergeDispatchRow(live: WorkItem, row: RecentDispatchRow): WorkItem { const merged: WorkItem = { ...live }; - // Strip the live browse list's grouping so the dispatches view stays a flat - // list; ordering is applied by the caller (fetchItemsForView) using the - // canonical main-list comparator. Interleaving live groups here would emit - // repeated headings (getDisplayRows emits one whenever `group` changes). + // Strip the live browse list's grouping; the caller (fetchItemsForView) + // assigns the 4-hour time-block group/label for the dispatches view + // (WL-0MUMM9NED009TLL3) so getDisplayRows emits one heading per time block + // rather than repeated browse-group headings. delete merged.group; delete merged.groupLabel; merged.isLogDerived = true; diff --git a/packages/herdr/src/dispatcher-anchor.test.ts b/packages/herdr/src/dispatcher-anchor.test.ts index dcf7107b0..76f3a5914 100644 --- a/packages/herdr/src/dispatcher-anchor.test.ts +++ b/packages/herdr/src/dispatcher-anchor.test.ts @@ -1,10 +1,11 @@ /** - * packages/herdr/src/dispatcher-anchor.test.ts — Dispatcher anchors and - * project-workspace + item-ID tab resolution - * (WL-0MU321YK70035AYT, superseding the retired C1 per-prefix routing). + * packages/herdr/src/dispatcher-anchor.test.ts — Dispatcher anchors, per-prefix + * tab routing and project-workspace + item-ID tab resolution. * * Coverage: * - C0 `getDispatcherAnchor` provisioning (retained as the AC4 fallback). + * - C1 `getDispatcherTabAnchor` — per-prefix tab routing inside the + * Dispatcher workspace (WL-0MTRQT482001SNXC). * - machine-wide / process-info / proc-environ parsers. * - `resolveProjectWorkspace` — project root → plugin workspace (AC1/AC3). * - `getItemTabAnchor` — create-or-reuse the exact item-ID tab (AC2/AC5). @@ -16,15 +17,18 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { getDispatcherAnchor, + getDispatcherTabAnchor, getItemTabAnchor, resolveProjectWorkspace, createDispatcherAnchorDeps, DISPATCHER_ANCHOR_FILE, + DISPATCHER_TAB_ANCHORS_FILE, DISPATCHER_WORKSPACE_LABEL, DISPATCHER_ROOT_TAB_LABEL, PLUGIN_PANE_LABEL, type DispatcherAnchor, type DispatcherAnchorDeps, + type DispatcherTabAnchors, type DispatcherTabInfo, type DispatcherPaneInfo, type MachinePaneInfo, @@ -70,6 +74,13 @@ function writePersisted(a: DispatcherAnchor): void { fs.writeFileSync(path.join(tmpDir, DISPATCHER_ANCHOR_FILE), JSON.stringify(a), 'utf-8'); } +function readTabAnchorsPersisted(): DispatcherTabAnchors | null { + try { + const raw = fs.readFileSync(path.join(tmpDir, DISPATCHER_TAB_ANCHORS_FILE), 'utf-8'); + return JSON.parse(raw) as DispatcherTabAnchors; + } catch { return null; } +} + describe('getDispatcherAnchor ACs (C0 fallback, retained)', () => { it('AC2 idempotent provisioning: first call creates workspace and persists', async () => { const deps: DispatcherAnchorDeps = { @@ -571,6 +582,237 @@ describe('getItemTabAnchor ACs (AC2/AC5)', () => { }); }); +// ── getDispatcherTabAnchor (C1, WL-0MTRQT482001SNXC) ───────────────────── + +describe('getDispatcherTabAnchor ACs (per-prefix tabs in Dispatcher workspace)', () => { + it('AC1: first dispatch for a prefix creates the labelled tab and persists the mapping', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const createTab = vi.fn(async (ws: string, label: string) => ({ + tabId: `${ws}:t${label}`, + paneId: `${ws}:t${label}:p1`, + })); + const deps = makeTabDeps({ createTab }); + + const got = await getDispatcherTabAnchor('/repo', deps, 'WL'); + + expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + expect(createTab).toHaveBeenCalledWith('wD', 'WL'); + expect(readTabAnchorsPersisted()).toEqual({ + workspaceId: 'wD', + byPrefix: { WL: { tabId: 'wD:tWL', paneId: 'wD:tWL:p1' } }, + }); + }); + + it('AC1: second dispatch for the same prefix REUSES the tab (no duplicate create)', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const createTab = vi.fn(async () => null); + const deps = makeTabDeps({ + createTab, + listTabs: async () => [{ tabId: 'wD:tWL', label: 'WL' }], + listPanes: async () => [{ paneId: 'wD:tWL:p1', tabId: 'wD:tWL' }], + isPaneAlive: async () => true, + }); + + const got = await getDispatcherTabAnchor('/repo', deps, 'WL'); + + expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + expect(createTab).not.toHaveBeenCalled(); + expect(readTabAnchorsPersisted()).toEqual({ + workspaceId: 'wD', + byPrefix: { WL: { tabId: 'wD:tWL', paneId: 'wD:tWL:p1' } }, + }); + }); + + it('AC1: distinct prefixes get distinct tabs (WL and TCE do not collapse)', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const createTab = vi.fn(async (ws: string, label: string) => ({ + tabId: `${ws}:t${label}`, + paneId: `${ws}:t${label}:p1`, + })); + const deps = makeTabDeps({ createTab }); + + const wl = await getDispatcherTabAnchor('/repo', deps, 'WL'); + const tce = await getDispatcherTabAnchor('/repo', deps, 'TCE'); + + expect(wl?.tabId).toBe('wD:tWL'); + expect(tce?.tabId).toBe('wD:tTCE'); + expect(createTab).toHaveBeenNthCalledWith(1, 'wD', 'WL'); + expect(createTab).toHaveBeenNthCalledWith(2, 'wD', 'TCE'); + const persisted = readTabAnchorsPersisted(); + expect(persisted?.byPrefix.WL).toEqual({ tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + expect(persisted?.byPrefix.TCE).toEqual({ tabId: 'wD:tTCE', paneId: 'wD:tTCE:p1' }); + }); + + it('AC1: prefix is case-preserved (raw prefix, no normalisation)', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const createTab = vi.fn(async (ws: string, label: string) => ({ + tabId: `${ws}:t${label}`, + paneId: `${ws}:t${label}:p1`, + })); + const deps = makeTabDeps({ createTab }); + + await getDispatcherTabAnchor('/repo', deps, 'wl'); + await getDispatcherTabAnchor('/repo', deps, 'WL'); + + expect(createTab).toHaveBeenNthCalledWith(1, 'wD', 'wl'); + expect(createTab).toHaveBeenNthCalledWith(2, 'wD', 'WL'); + }); + + it('AC2: a persisted anchor with a live pane is adopted without listing tabs', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + fs.writeFileSync( + path.join(tmpDir, DISPATCHER_TAB_ANCHORS_FILE), + JSON.stringify({ workspaceId: 'wD', byPrefix: { WL: { tabId: 'wD:tWL', paneId: 'wD:tWL:p1' } } }), + 'utf-8', + ); + const listTabs = vi.fn(async () => [] as DispatcherTabInfo[]); + const createTab = vi.fn(async () => null); + const deps = makeTabDeps({ listTabs, createTab, isPaneAlive: async () => true }); + + const got = await getDispatcherTabAnchor('/repo', deps, 'WL'); + + expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + expect(listTabs).not.toHaveBeenCalled(); + expect(createTab).not.toHaveBeenCalled(); + }); + + it('AC2: a persisted anchor with a DEAD pane is re-provisioned', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + fs.writeFileSync( + path.join(tmpDir, DISPATCHER_TAB_ANCHORS_FILE), + JSON.stringify({ workspaceId: 'wD', byPrefix: { WL: { tabId: 'wD:tOLD', paneId: 'wD:tOLD:p1' } } }), + 'utf-8', + ); + const createTab = vi.fn(async (ws: string, label: string) => ({ + tabId: `${ws}:t${label}NEW`, + paneId: `${ws}:t${label}NEW:p1`, + })); + const deps = makeTabDeps({ createTab, isPaneAlive: async () => false }); + + const got = await getDispatcherTabAnchor('/repo', deps, 'WL'); + + expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWLNEW', paneId: 'wD:tWLNEW:p1' }); + expect(createTab).toHaveBeenCalledWith('wD', 'WL'); + expect(readTabAnchorsPersisted()?.byPrefix.WL).toEqual({ + tabId: 'wD:tWLNEW', paneId: 'wD:tWLNEW:p1', + }); + }); + + it('AC2: a tab existing in herdr but not in the map is adopted and persisted', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const createTab = vi.fn(async () => null); + const deps = makeTabDeps({ + createTab, + listTabs: async () => [{ tabId: 'wD:tWL', label: 'WL' }], + listPanes: async () => [{ paneId: 'wD:tWL:p1', tabId: 'wD:tWL' }], + isPaneAlive: async () => true, + }); + + const got = await getDispatcherTabAnchor('/repo', deps, 'WL'); + + expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + expect(createTab).not.toHaveBeenCalled(); + expect(readTabAnchorsPersisted()?.byPrefix.WL).toEqual({ + tabId: 'wD:tWL', paneId: 'wD:tWL:p1', + }); + }); + + it('AC2: a matching tab with a dead pane is replaced (stale-tab re-provision)', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const createTab = vi.fn(async (ws: string) => ({ tabId: `${ws}:tNEW`, paneId: `${ws}:tNEW:p1` })); + const deps = makeTabDeps({ + createTab, + listTabs: async () => [{ tabId: 'wD:tDEAD', label: 'WL' }], + listPanes: async () => [{ paneId: 'wD:tDEAD:p1', tabId: 'wD:tDEAD' }], + isPaneAlive: async () => false, + }); + + const got = await getDispatcherTabAnchor('/repo', deps, 'WL'); + + expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tNEW', paneId: 'wD:tNEW:p1' }); + expect(createTab).toHaveBeenCalledWith('wD', 'WL'); + }); + + it('AC2 fail-closed: unparseable tab list → null, never creates (no duplicate)', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const createTab = vi.fn(async () => null); + const deps = makeTabDeps({ listTabs: async () => null, createTab }); + + expect(await getDispatcherTabAnchor('/repo', deps, 'WL')).toBeNull(); + expect(createTab).not.toHaveBeenCalled(); + }); + + it('AC2 fail-closed: unreadable pane list (unknown) → null, never creates', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const createTab = vi.fn(async () => null); + const deps = makeTabDeps({ + listTabs: async () => [{ tabId: 'wD:tWL', label: 'WL' }], + listPanes: async () => null, + createTab, + }); + + expect(await getDispatcherTabAnchor('/repo', deps, 'WL')).toBeNull(); + expect(createTab).not.toHaveBeenCalled(); + }); + + it('AC2 fail-closed: tab create returns null (malformed CLI output) → null', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const deps = makeTabDeps({ createTab: async () => null }); + + expect(await getDispatcherTabAnchor('/repo', deps, 'WL')).toBeNull(); + expect(readTabAnchorsPersisted()).toBeNull(); + }); + + it('AC2 guard: empty prefix → null without touching the CLI', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const listTabs = vi.fn(async () => [] as DispatcherTabInfo[]); + const deps = makeTabDeps({ listTabs }); + + expect(await getDispatcherTabAnchor('/repo', deps, '')).toBeNull(); + expect(listTabs).not.toHaveBeenCalled(); + }); + + it('AC2 concurrent: lock held with no existing tab → fail closed (no duplicate)', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const lockPath = path.join(tmpDir, 'downtime-coordination.lock'); + fs.writeFileSync(lockPath, ''); + const createTab = vi.fn(async () => null); + const deps = makeTabDeps({ createTab }); + + expect(await getDispatcherTabAnchor('/repo', deps, 'WL')).toBeNull(); + expect(createTab).not.toHaveBeenCalled(); + try { fs.unlinkSync(lockPath); } catch { /* ignore */ } + }); + + it('AC2 concurrent: lock held but the winner already created the tab → adopt it', async () => { + writePersisted({ paneId: 'wD:pRoot', workspaceId: 'wD' }); + const lockPath = path.join(tmpDir, 'downtime-coordination.lock'); + fs.writeFileSync(lockPath, ''); + const createTab = vi.fn(async () => null); + const deps = makeTabDeps({ + createTab, + listTabs: async () => [{ tabId: 'wD:tWL', label: 'WL' }], + listPanes: async () => [{ paneId: 'wD:tWL:p1', tabId: 'wD:tWL' }], + isPaneAlive: async () => true, + }); + + const got = await getDispatcherTabAnchor('/repo', deps, 'WL'); + + expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + expect(createTab).not.toHaveBeenCalled(); + try { fs.unlinkSync(lockPath); } catch { /* ignore */ } + }); + + it('machine dir unresolvable → null', async () => { + const fileAsDir = path.join(tmpDir, 'not-a-dir'); + fs.writeFileSync(fileAsDir, 'x'); + process.env.HERDR_COORDINATION_DIR = fileAsDir; + const deps = makeTabDeps(); + + expect(await getDispatcherTabAnchor('/repo', deps, 'WL')).toBeNull(); + }); +}); + // ── createDispatcherAnchorDeps CLI wrappers ──────────────────────────── describe('createDispatcherAnchorDeps.movePaneToNewTab', () => { diff --git a/packages/herdr/src/dispatcher-anchor.ts b/packages/herdr/src/dispatcher-anchor.ts index fceb35c55..7ea6860ab 100644 --- a/packages/herdr/src/dispatcher-anchor.ts +++ b/packages/herdr/src/dispatcher-anchor.ts @@ -1,10 +1,8 @@ /** * packages/herdr/src/dispatcher-anchor.ts — Dispatcher anchors and project-workspace resolution * - * Parent: WL-0MU321YK70035AYT — Downtime dispatcher should dispatch into the - * project workspace. - * - * Two placement strategies live here: + * Three placement strategies live here, listed in `dispatchClaimedTier` + * precedence order (WL-0MUR5FUWD00024XN): * * 1. **Project workspace + item-ID tab (primary path).** * `resolveProjectWorkspace` maps a worklog root `R` to the herdr plugin @@ -14,15 +12,19 @@ * the exact work-item id inside that workspace and returns its root pane, * so automated panes are co-located with the project and grouped per item. * - * 2. **Machine-wide Dispatcher fallback (retained).** + * 2. **Per-prefix tab routing inside the Dispatcher workspace (C1).** + * `getDispatcherTabAnchor(prefix)` resolves or creates a tab labelled with + * the work-item id prefix (e.g. `WL`, `TCE`) inside the machine-wide + * Dispatcher workspace and returns its root pane. Consulted only when no + * project workspace resolves for the item's root (AC3 fallback). Automated + * downtime dispatches for items sharing the same prefix land in the same + * tab (WL-0MTRQT482001SNXC). + * + * 3. **Machine-wide Dispatcher fallback (retained).** * `getDispatcherAnchor` provisions one dedicated "Dispatcher" workspace * with a persisted anchor pane (`downtime-dispatch-anchor.json`), used only - * when no project plugin pane can be resolved (AC4) and by scheduled - * prompts (which have no work-item id). - * - * The retired per-prefix routing (`getDispatcherTabAnchor`, `<PREFIX>` tab - * labels and `downtime-dispatch-tab-anchors.json`) is gone: tabs are keyed by - * the full work-item id in the resolved project workspace. + * when neither a project plugin pane nor a per-prefix anchor can be + * resolved, and by scheduled prompts (which have no work-item id). * * Provisioning: workspace create --label Dispatcher (no-focus); the workspace * root pane is then adopted into a "Downtime" tab @@ -62,6 +64,13 @@ export const PLUGIN_PANE_LABEL = 'Work Items'; */ export const DISPATCHER_ROOT_TAB_LABEL = 'Downtime'; +/** + * Filename for the per-prefix tab-anchors persistence map + * (C1, parent WL-0MTRQT482001SNXC). Stored in the machine coordination dir + * alongside the legacy single-anchor file. + */ +export const DISPATCHER_TAB_ANCHORS_FILE = 'downtime-dispatch-tab-anchors.json'; + // ── Types ────────────────────────────────────────────────────────────── export interface DispatcherAnchor { @@ -69,6 +78,28 @@ export interface DispatcherAnchor { workspaceId: string; } +/** + * Per-prefix tab-anchors map — keyed by work-item id prefix (e.g. `WL`, `TCE`) + * so every dispatch for the same prefix reuses the same tab in the Dispatcher + * workspace (C1, parent WL-0MTRQT482001SNXC). + */ +export interface DispatcherTabAnchors { + /** Machine-wide Dispatcher workspace id (shared with the legacy single anchor). */ + workspaceId: string; + /** Prefix → { tabId, paneId } map — one tab per project prefix. */ + byPrefix: Record<string, { tabId: string; paneId: string }>; +} + +/** + * A prefix-aware tab anchor: the Dispatcher workspace id plus the tab and + * pane for a specific project prefix (e.g. `WL` → `w13:t5` → `w13:p23`). + */ +export interface DispatcherPrefixTabAnchor { + workspaceId: string; + tabId: string; + paneId: string; +} + export interface DispatcherAnchorDeps { /** Create a workspace. Resolves to { workspaceId, paneId } or throws. */ createWorkspace(label: string): Promise<{ workspaceId: string; paneId: string }>; @@ -633,6 +664,102 @@ function writeAnchor(dir: string, anchor: DispatcherAnchor): boolean { } } +// ── Per-prefix tab-anchor persistence (C1) ───────────────────────────── + +/** Path to the per-prefix tab-anchors persistence file. */ +function tabAnchorsFilePath(dir: string): string { + return path.join(dir, DISPATCHER_TAB_ANCHORS_FILE); +} + +/** + * Read the per-prefix tab-anchors map. Returns `null` when absent, empty, or + * structurally invalid (caller treats as "no anchors yet"). + */ +function readTabAnchors(dir: string): DispatcherTabAnchors | null { + try { + const raw = fs.readFileSync(tabAnchorsFilePath(dir), 'utf-8'); + if (!raw.trim()) return null; + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) return null; + const o = parsed as Record<string, unknown>; + const workspaceId = o.workspaceId ?? o.workspace_id; + const byPrefix = o.byPrefix; + if (typeof workspaceId !== 'string' || !workspaceId) return null; + if ( + byPrefix === undefined || + byPrefix === null || + typeof byPrefix !== 'object' || + Array.isArray(byPrefix) + ) { + return null; + } + const out: DispatcherTabAnchors = { workspaceId, byPrefix: {} }; + for (const [prefix, entry] of Object.entries(byPrefix as Record<string, unknown>)) { + if (entry === null || typeof entry !== 'object') continue; + const rec = entry as Record<string, unknown>; + const tabId = rec.tabId ?? rec.tab_id; + const paneId = rec.paneId ?? rec.pane_id; + if (typeof tabId === 'string' && tabId !== '' && typeof paneId === 'string' && paneId !== '') { + out.byPrefix[prefix] = { tabId, paneId }; + } + } + return out; + } catch { + return null; + } +} + +/** Atomically persist the per-prefix tab-anchors map (tmp + rename). */ +function writeTabAnchors(dir: string, anchors: DispatcherTabAnchors): boolean { + try { + fs.mkdirSync(dir, { recursive: true }); + const fp = tabAnchorsFilePath(dir); + const tmp = `${fp}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(anchors), 'utf-8'); + fs.renameSync(tmp, fp); + return true; + } catch { + return false; + } +} + +/** + * Synchronously read the legacy single-anchor file to obtain the Dispatcher + * workspace id without launching the herdr CLI. Returns `null` when absent. + */ +function readDispatcherWorkspaceId(): string | null { + const dir = getMachineCoordinationDir(); + if (dir === null) return null; + const existing = readAnchor(dir); + return existing === null ? null : existing.workspaceId; +} + +/** + * Ensure the Dispatcher workspace exists and return its workspace id: reuse + * the id persisted by the legacy single anchor, else provision a fresh + * workspace. Returns `null` on failure (caller fails closed). + */ +async function ensureDispatcherWorkspace( + deps: DispatcherAnchorDeps, +): Promise<string | null> { + const existing = readDispatcherWorkspaceId(); + if (existing !== null) return existing; + try { + const created = await deps.createWorkspace(DISPATCHER_WORKSPACE_LABEL); + return created.workspaceId; + } catch { + return null; + } +} + +/** Find the tab labelled exactly `label` (never a substring/prefix match). */ +function findTabByLabel( + tabs: DispatcherTabInfo[], + label: string, +): DispatcherTabInfo | null { + return tabs.find((tab) => tab.label === label) ?? null; +} + /** * Adopt the workspace's initial root pane into a labelled tab so it is never * left blank/unused (WL-0MU2EOHK900425VU). `herdr workspace create` always @@ -779,6 +906,200 @@ async function resolveLivePaneInTab( return { status: 'none' }; } +// ── Per-prefix tab anchor (C1, parent WL-0MTRQT482001SNXC) ───────────── + +/** + * Result of resolving a per-prefix tab anchor. + * + * - `{ status: 'found', tabId, paneId }` — an existing tab with the prefix + * label, whose root pane is alive. + * - `{ status: 'none' }` — no matching tab exists (or the existing one is + * stale) → the caller provisions a new tab. + * - `{ status: 'unknown' }` — `tab list` / `pane list` could not be read + * → fail closed (never guess, never duplicate). + */ +type PrefixTabResolution = + | { status: 'found'; tabId: string; paneId: string } + | { status: 'none' } + | { status: 'unknown' }; + +/** + * Resolve the live anchor pane for the tab labelled exactly `prefix` in + * `workspaceId`. + * + * - `found` — the tab exists and hosts a live pane (adopt it). + * - `none` — `tab list` was read but no matching tab exists, or the matching + * tab has no live pane → the caller provisions a replacement. + * - `unknown` — `tab list`/`pane list` was unreadable → fail closed. + */ +async function resolvePrefixTab( + cwd: string, + deps: DispatcherAnchorDeps, + workspaceId: string, + prefix: string, + listTabs: (workspaceId: string) => Promise<DispatcherTabInfo[] | null>, +): Promise<PrefixTabResolution> { + let tabs: DispatcherTabInfo[] | null; + try { + tabs = await listTabs(workspaceId); + } catch { + return { status: 'unknown' }; + } + if (tabs === null) return { status: 'unknown' }; + const match = findTabByLabel(tabs, prefix); + if (match === null) return { status: 'none' }; + const resolution = await resolveLivePaneInTab(cwd, deps, workspaceId, match.tabId); + if (resolution.status === 'unknown') return { status: 'unknown' }; + if (resolution.status === 'found') { + return { status: 'found', tabId: match.tabId, paneId: resolution.paneId }; + } + return { status: 'none' }; +} + +/** + * Resolve or create the tab-anchored pane for a given work-item id prefix + * inside the Dispatcher workspace (C1, parent WL-0MTRQT482001SNXC). + * + * FALLBACK placement (AC3, WL-0MUR5FUWD00024XN): `dispatchClaimedTier` + * consults this resolver only when `resolveProjectWorkspace` does not resolve + * a project workspace for the item's root. + * + * Algorithm: + * 1. Ensure the Dispatcher workspace exists (re-use the legacy single + * anchor's workspace id when available, or provision it). + * 2. List tabs in the Dispatcher workspace and look for one labelled with + * the prefix. + * 3. If found, validate the root pane is alive (`pane get`); if alive, + * return it. + * 4. If absent or stale, provision under the coordination lock: + * a. Double-check `tab list` inside the lock (a racing caller may have + * created the same tab). + * b. If still absent, `herdr tab create --workspace <id> --label <prefix> + * --no-focus`. + * c. Persist the new mapping in `downtime-dispatch-tab-anchors.json`. + * 5. Return `{ workspaceId, tabId, paneId }` or `null` on any failure. + * + * Idempotency: the coordination lock serialises tab creation and the + * double-check inside the lock prevents duplicate tabs from concurrent + * first-dispatches. Stale panes trigger re-provisioning. + * + * Fail-safe: a provisioning failure degrades to `null` (caller treats as + * 'anchor-unavailable', never falls back to a wrong workspace). + */ +export async function getDispatcherTabAnchor( + cwd: string, + deps: DispatcherAnchorDeps, + prefix: string, +): Promise<DispatcherPrefixTabAnchor | null> { + if (prefix === '') return null; + const dir = getMachineCoordinationDir(); + if (dir === null) return null; + if (!ensureMachineCoordinationDir(dir)) return null; + + const listTabs = deps.listTabs ?? ((ws: string) => listTabsInWorkspace(cwd, ws)); + + // 1. Ensure the Dispatcher workspace exists. + let workspaceId: string | null = null; + try { + workspaceId = await ensureDispatcherWorkspace(deps); + } catch { + workspaceId = null; + } + if (workspaceId === null || workspaceId === '') return null; + + // 2. Fast path: a persisted anchor for this prefix whose pane is still + // alive is reused directly (no `tab list` round-trip). + const persisted = readTabAnchors(dir); + const cached = persisted?.byPrefix[prefix]; + if (cached !== undefined) { + let alive = false; + try { + alive = await deps.isPaneAlive(cached.paneId); + } catch { + alive = false; // fail-closed — fall through to re-provision + } + if (alive) { + return { workspaceId, tabId: cached.tabId, paneId: cached.paneId }; + } + // Stale cache (dead pane / closed tab) → fall through to re-provision. + } + + // 3. Look for an existing tab labelled with the prefix (authoritative, + // handles an anchor file lost or written out-of-band). + const fast = await resolvePrefixTab(cwd, deps, workspaceId, prefix, listTabs); + if (fast.status === 'unknown') return null; // fail-closed (never duplicate) + if (fast.status === 'found') { + persistPrefixTab(dir, workspaceId, prefix, fast.tabId, fast.paneId); + return { workspaceId, tabId: fast.tabId, paneId: fast.paneId }; + } + + // 4. Provision under the coordination lock with a double-check so + // concurrent first-dispatches for a NEW prefix cannot duplicate tabs. + const release = tryAcquireCoordLock(dir); + if (release === null) { + // Another holder is provisioning — re-check once, else fail closed. + const raced = await resolvePrefixTab(cwd, deps, workspaceId, prefix, listTabs); + if (raced.status === 'found') { + persistPrefixTab(dir, workspaceId, prefix, raced.tabId, raced.paneId); + return { workspaceId, tabId: raced.tabId, paneId: raced.paneId }; + } + return null; + } + + try { + // Double-check inside the lock: a racing caller may have created the tab + // between our step-3 read and acquiring the lock. + const inside = await resolvePrefixTab(cwd, deps, workspaceId, prefix, listTabs); + if (inside.status === 'unknown') return null; + if (inside.status === 'found') { + persistPrefixTab(dir, workspaceId, prefix, inside.tabId, inside.paneId); + return { workspaceId, tabId: inside.tabId, paneId: inside.paneId }; + } + + // inside.status === 'none' → create the tab (never focus-stealing). + const createTabFn = + deps.createTab ?? ((ws: string, label: string) => createTabInWorkspace(cwd, ws, label)); + let created: ItemTabAnchor | null; + try { + created = await createTabFn(workspaceId, prefix); + } catch { + return null; + } + if (created === null) return null; + + // Persist the new mapping (authority for subsequent reuse). + if (!persistPrefixTab(dir, workspaceId, prefix, created.tabId, created.paneId)) return null; + return { workspaceId, tabId: created.tabId, paneId: created.paneId }; + } finally { + release(); + } +} + +/** + * Upsert one prefix → { tabId, paneId } entry in the persisted per-prefix + * map, keeping the Dispatcher `workspaceId` current. Returns `true` when the + * map was written. + * + * Always writes (even when the entry is unchanged) so a stale entry whose + * pane was superseded in herdr is corrected rather than left to be + * re-resolved on every dispatch. + */ +function persistPrefixTab( + dir: string, + workspaceId: string, + prefix: string, + tabId: string, + paneId: string, +): boolean { + const current = readTabAnchors(dir); + const anchors: DispatcherTabAnchors = { + workspaceId, + byPrefix: { ...(current?.byPrefix ?? {}) }, + }; + anchors.byPrefix[prefix] = { tabId, paneId }; + return writeTabAnchors(dir, anchors); +} + // ── Project workspace resolution (AC1/AC3) ───────────────────────────── /** diff --git a/packages/herdr/src/downtime-log.test.ts b/packages/herdr/src/downtime-log.test.ts index 26647b2df..6d8754fc2 100644 --- a/packages/herdr/src/downtime-log.test.ts +++ b/packages/herdr/src/downtime-log.test.ts @@ -34,11 +34,16 @@ import { markerStillExcludes, readDowntimeLogEntries, recentDispatchedItems, + groupRecentDispatchesByTimeBlock, + dispatchTimeBlockLabel, + UNKNOWN_TIME_BLOCK_LABEL, DOWNTIME_LOG_FILE, COORDINATION_LOG_FILE, DOWNTIME_LOG_MAX_ENTRIES, recentAuditDispatchedItemIds, + isNonTerminalCooldownActive, } from './downtime-log.js'; +import type { DowntimeLogEntry, RecentDispatchRow } from './downtime-log.js'; const tempDirs: string[] = []; @@ -852,12 +857,12 @@ describe('pane-close lifecycle entries (WL-0MU308WSF0002JWN)', () => { // ── Recent dispatched items projection (WL-0MUL2IX6H001YHDO) ─────────── // -// `recentDispatchedItems(cwd, limit)` projects the rolling dispatch log into -// up to `limit` synthetic rows (default 20), one per work item id, ordered by -// that item's most recent log entry (newest first). It reads only the local -// fail-safe `readDowntimeLogEntries` reader, ignores pane-close lifecycle -// entries, and never throws — a missing/unreadable/empty/malformed log yields -// `[]`. +// `recentDispatchedItems(cwd)` projects the rolling dispatch log into one +// synthetic row per work item id (no result cap; the log itself is bounded by +// `DOWNTIME_LOG_MAX_ENTRIES`), ordered by that item's most recent log entry +// (newest first). It reads only the local fail-safe `readDowntimeLogEntries` +// reader, ignores pane-close lifecycle entries, and never throws — a +// missing/unreadable/empty/malformed log yields `[]`. describe('recentDispatchedItems (log projection, WL-0MUL2IX6H001YHDO)', () => { it('returns [] for a missing log (fail-safe)', async () => { const cwd = makeTempCwd(); @@ -1014,7 +1019,7 @@ describe('recentDispatchedItems (log projection, WL-0MUL2IX6H001YHDO)', () => { expect(rows.find((r) => r.itemId === 'WL-EMPTY')?.title).toBe('[unknown]'); }); - it('caps the result at 20 rows by default (newest first)', async () => { + it('shows every dispatched item with no cap (newest first)', async () => { const cwd = makeTempCwd(); for (let i = 0; i < 25; i++) { await appendDowntimeLogEntry( @@ -1028,23 +1033,11 @@ describe('recentDispatchedItems (log projection, WL-0MUL2IX6H001YHDO)', () => { ); } const rows = await recentDispatchedItems(cwd); - expect(rows).toHaveLength(20); - // Newest is the last dispatched (i = 24), oldest retained is i = 5. + // The 20-item cap is removed (WL-0MUMM9NED009TLL3) — all 25 distinct ids + // retained in the log are projected, still newest-first. + expect(rows).toHaveLength(25); expect(rows[0].itemId).toBe('WL-24'); - expect(rows[19].itemId).toBe('WL-05'); - }); - - it('honours an explicit limit argument', async () => { - const cwd = makeTempCwd(); - for (let i = 0; i < 5; i++) { - await appendDowntimeLogEntry( - cwd, - JSON.stringify({ itemId: `WL-${i}`, kind: 'plan', title: `Item ${i}`, dispatchedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)).toISOString() }), - ); - } - const rows = await recentDispatchedItems(cwd, 2); - expect(rows).toHaveLength(2); - expect(rows.map((r) => r.itemId)).toEqual(['WL-4', 'WL-3']); + expect(rows[24].itemId).toBe('WL-00'); }); it('backfills a placeholder title from a pane-close itemTitle', async () => { @@ -1181,3 +1174,258 @@ describe('recentDispatchedItems (log projection, WL-0MUL2IX6H001YHDO)', () => { expect(rows.find((r) => r.itemId === 'WL-BAD')?.stage).toBeUndefined(); }); }); + +// ── 4-hour time-block grouping (WL-0MUMM9NED009TLL3) ─────────────────── +// +// The recent-dispatches view groups its semantically newest-first rows into +// 4-hour UTC blocks per day, ordered chronologically (oldest block first) with +// a trailing "Unknown time" block for rows with no parseable timestamp. These +// helpers are pure so the boundary/wrap/ordering rules are unit-testable +// without touching the filesystem or the renderer. +describe('groupRecentDispatchesByTimeBlock (4-hour UTC blocks, WL-0MUMM9NED009TLL3)', () => { + function row(id: string, latestTimestamp?: string): RecentDispatchRow { + return { itemId: id, title: id, latestTimestamp }; + } + + it('formats the heading label with a UTC date and HH:00–HH:00 boundaries', () => { + expect(dispatchTimeBlockLabel('2026-09-29T13:45:00.000Z')).toBe('29 Sep 2026, 12:00–16:00'); + expect(dispatchTimeBlockLabel('2026-01-02T00:00:00.000Z')).toBe('02 Jan 2026, 00:00–04:00'); + }); + + it('classifies boundary hours into the correct block', () => { + expect(dispatchTimeBlockLabel('2026-09-29T03:59:59.999Z')).toBe('29 Sep 2026, 00:00–04:00'); + expect(dispatchTimeBlockLabel('2026-09-29T04:00:00.000Z')).toBe('29 Sep 2026, 04:00–08:00'); + expect(dispatchTimeBlockLabel('2026-09-29T07:59:59.000Z')).toBe('29 Sep 2026, 04:00–08:00'); + expect(dispatchTimeBlockLabel('2026-09-29T08:00:00.000Z')).toBe('29 Sep 2026, 08:00–12:00'); + expect(dispatchTimeBlockLabel('2026-09-29T23:59:59.000Z')).toBe('29 Sep 2026, 20:00–24:00'); + }); + + it('labels a missing or unparseable timestamp as Unknown time', () => { + expect(dispatchTimeBlockLabel(undefined)).toBe(UNKNOWN_TIME_BLOCK_LABEL); + expect(dispatchTimeBlockLabel('not-a-date')).toBe(UNKNOWN_TIME_BLOCK_LABEL); + expect(dispatchTimeBlockLabel('')).toBe(UNKNOWN_TIME_BLOCK_LABEL); + }); + + it('returns [] for an empty input', () => { + expect(groupRecentDispatchesByTimeBlock([])).toEqual([]); + }); + + it('groups rows sharing a 4-hour window into one block', () => { + const blocks = groupRecentDispatchesByTimeBlock([ + row('WL-D', '2026-09-29T03:00:00.000Z'), + row('WL-C', '2026-09-29T00:30:00.000Z'), + row('WL-B', '2026-09-29T05:00:00.000Z'), + row('WL-A', '2026-09-29T04:15:00.000Z'), + ]); + expect(blocks).toHaveLength(2); + expect(blocks[0].label).toBe('29 Sep 2026, 00:00–04:00'); + expect(blocks[0].rows.map((r) => r.itemId)).toEqual(['WL-D', 'WL-C']); + expect(blocks[1].label).toBe('29 Sep 2026, 04:00–08:00'); + expect(blocks[1].rows.map((r) => r.itemId)).toEqual(['WL-B', 'WL-A']); + }); + + it('orders blocks chronologically oldest-first regardless of input order', () => { + // Input is newest-first (as the projection returns it). + const blocks = groupRecentDispatchesByTimeBlock([ + row('WL-NEW', '2026-09-29T18:00:00.000Z'), + row('WL-MID', '2026-09-29T09:00:00.000Z'), + row('WL-OLD', '2026-09-29T01:00:00.000Z'), + ]); + expect(blocks.map((b) => b.label)).toEqual([ + '29 Sep 2026, 00:00–04:00', + '29 Sep 2026, 08:00–12:00', + '29 Sep 2026, 16:00–20:00', + ]); + expect(blocks[0].startMs).toBeLessThan(blocks[1].startMs); + expect(blocks[1].startMs).toBeLessThan(blocks[2].startMs); + }); + + it('preserves the per-block newest-first item order', () => { + const blocks = groupRecentDispatchesByTimeBlock([ + row('WL-LATE', '2026-09-29T03:30:00.000Z'), + row('WL-MID', '2026-09-29T02:00:00.000Z'), + row('WL-EARLY', '2026-09-29T00:10:00.000Z'), + ]); + expect(blocks).toHaveLength(1); + expect(blocks[0].rows.map((r) => r.itemId)).toEqual(['WL-LATE', 'WL-MID', 'WL-EARLY']); + }); + + it('keeps blocks on either side of midnight separate (cross-day wrap)', () => { + const blocks = groupRecentDispatchesByTimeBlock([ + row('WL-NEXT-DAY', '2026-09-30T00:30:00.000Z'), + row('WL-LATE', '2026-09-29T21:00:00.000Z'), + ]); + expect(blocks).toHaveLength(2); + // Oldest first: the previous day's 20:00 block precedes the new day's 00:00. + expect(blocks[0].label).toBe('29 Sep 2026, 20:00–24:00'); + expect(blocks[1].label).toBe('30 Sep 2026, 00:00–04:00'); + }); + + it('does not merge the same hour on different days', () => { + const blocks = groupRecentDispatchesByTimeBlock([ + row('WL-D2', '2026-09-30T08:30:00.000Z'), + row('WL-D1', '2026-09-29T08:30:00.000Z'), + ]); + expect(blocks.map((b) => b.label)).toEqual([ + '29 Sep 2026, 08:00–12:00', + '30 Sep 2026, 08:00–12:00', + ]); + }); + + it('collects rows without a parseable timestamp into one trailing Unknown time block', () => { + const blocks = groupRecentDispatchesByTimeBlock([ + row('WL-DATED', '2026-09-29T05:00:00.000Z'), + row('WL-NO-TS'), + row('WL-BAD', 'garbage'), + ]); + expect(blocks).toHaveLength(2); + expect(blocks[0].label).toBe('29 Sep 2026, 04:00–08:00'); + const unknown = blocks[blocks.length - 1]; + expect(unknown.unknownTime).toBe(true); + expect(unknown.label).toBe(UNKNOWN_TIME_BLOCK_LABEL); + expect(unknown.startMs).toBe(Number.NEGATIVE_INFINITY); + expect(unknown.rows.map((r) => r.itemId)).toEqual(['WL-NO-TS', 'WL-BAD']); + }); + + it('keeps the Unknown time block last even when only unknown rows exist', () => { + const blocks = groupRecentDispatchesByTimeBlock([row('WL-A'), row('WL-B', 'nope')]); + expect(blocks).toHaveLength(1); + expect(blocks[0].label).toBe(UNKNOWN_TIME_BLOCK_LABEL); + }); + + it('round-trips a real projection: groups all rows without dropping any', async () => { + const cwd = makeTempCwd(); + await appendDowntimeLogEntry(cwd, JSON.stringify({ itemId: 'WL-A', kind: 'plan', title: 'A', dispatchedAt: '2026-09-29T01:00:00.000Z' })); + await appendDowntimeLogEntry(cwd, JSON.stringify({ itemId: 'WL-B', kind: 'plan', title: 'B', dispatchedAt: '2026-09-29T02:00:00.000Z' })); + await appendDowntimeLogEntry(cwd, JSON.stringify({ itemId: 'WL-C', kind: 'plan', title: 'C', dispatchedAt: '2026-09-29T09:00:00.000Z' })); + const rows = await recentDispatchedItems(cwd); + const blocks = groupRecentDispatchesByTimeBlock(rows); + expect(blocks).toHaveLength(2); + const flat = blocks.flatMap((b) => b.rows.map((r) => r.itemId)).sort(); + expect(flat).toEqual(['WL-A', 'WL-B', 'WL-C']); + }); +}); + +// ── Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5) ──────────── + +describe('isNonTerminalCooldownActive (WL-0MUKYERLZ006ELL5)', () => { + const NOW = Date.parse('2026-09-28T08:00:00.000Z'); + const COOLDOWN_MS = 30 * 60 * 1000; + + /** Build a pane-close entry, defaulting to a recent non-terminal close. */ + function close(overrides: Partial<DowntimeLogEntry> = {}): DowntimeLogEntry { + return { + entryType: 'pane-close', + itemId: 'WL-A', + kind: 'intake', + timestamp: new Date(NOW - 60_000).toISOString(), + reasonCode: 'agent-ended-no-terminal', + outcome: 'requires-attention', + closed: true, + ...overrides, + }; + } + + function active( + entries: DowntimeLogEntry[], + itemStage = 'idea', + kind = 'intake', + ): boolean { + return isNonTerminalCooldownActive(entries, 'WL-A', kind, itemStage, COOLDOWN_MS, NOW); + } + + it('(a) skips an item after a recent non-terminal close', () => { + expect(active([close({ stage: 'idea' })])).toBe(true); + }); + + it('(b) releases once the close is older than cooldownMs', () => { + const stale = new Date(NOW - COOLDOWN_MS - 1).toISOString(); + expect(active([close({ timestamp: stale, stage: 'idea' })])).toBe(false); + }); + + it('releases at exactly cooldownMs (the cooldown has elapsed)', () => { + const atEdge = new Date(NOW - COOLDOWN_MS).toISOString(); + expect(active([close({ timestamp: atEdge, stage: 'idea' })])).toBe(false); + }); + + it('(c) releases immediately when the item advanced past its dispatched-at stage', () => { + // Dispatched at plan_complete, now in_review → genuinely progressed. + expect(active([close({ stage: 'plan_complete' })], 'in_review')).toBe(false); + }); + + it('does not release when the item is still at the dispatched-at stage', () => { + expect(active([close({ stage: 'plan_complete' })], 'plan_complete')).toBe(true); + }); + + it('(d) does not apply to a terminal close (reasonCode none)', () => { + expect( + active([close({ reasonCode: 'none', outcome: 'closed-as-intake-complete' })]), + ).toBe(false); + }); + + it('does not apply to a reached-in-review close (terminal success)', () => { + // `reached-in-review` is a successful terminal close (parent AC5): the + // implement pane advanced the item, so no cooldown is applied. + expect( + active( + [close({ kind: 'implement', reasonCode: 'reached-in-review', stage: 'plan_complete' })], + 'in_review', + 'implement', + ), + ).toBe(false); + }); + + it('(e) fails closed (skip) on a missing close timestamp', () => { + expect(active([close({ timestamp: undefined, stage: 'idea' })])).toBe(true); + }); + + it('(e) fails closed (skip) on an unparseable close timestamp', () => { + expect(active([close({ timestamp: 'not-a-date', stage: 'idea' })])).toBe(true); + }); + + it('returns false when there is no pane-close record for the item', () => { + expect(active([])).toBe(false); + expect(active([close({ itemId: 'WL-OTHER' })])).toBe(false); + }); + + it('scopes the cooldown to the dispatched kind', () => { + // A non-terminal intake close must not hold implement re-dispatch. + expect(active([close({ kind: 'intake' })], 'idea', 'implement')).toBe(false); + }); + + it('keeps kinds independent when the same item closes non-terminally in one kind', () => { + const entries = [close({ kind: 'plan' }), close({ kind: 'intake' })]; + expect(isNonTerminalCooldownActive(entries, 'WL-A', 'intake', 'idea', COOLDOWN_MS, NOW)).toBe(true); + expect(isNonTerminalCooldownActive(entries, 'WL-A', 'plan', 'idea', COOLDOWN_MS, NOW)).toBe(true); + expect(isNonTerminalCooldownActive(entries, 'WL-A', 'implement', 'idea', COOLDOWN_MS, NOW)).toBe(false); + }); + + it('uses the most recent close: a later terminal close supersedes an earlier failure', () => { + const entries = [ + close({ timestamp: new Date(NOW - 120_000).toISOString(), reasonCode: 'agent-ended-no-terminal' }), + close({ timestamp: new Date(NOW - 30_000).toISOString(), reasonCode: 'none', outcome: 'closed-as-intake-complete' }), + ]; + expect(active(entries)).toBe(false); + }); + + it('uses the most recent close: a later failure re-arms the cooldown', () => { + const entries = [ + close({ timestamp: new Date(NOW - 30_000).toISOString(), reasonCode: 'none', outcome: 'closed-as-intake-complete' }), + close({ timestamp: new Date(NOW - 10_000).toISOString(), reasonCode: 'agent-ended-no-terminal' }), + ]; + expect(active(entries)).toBe(true); + }); + + it('applies to any non-terminal code (producer-review, risk-effort-incomplete)', () => { + expect(active([close({ reasonCode: 'producer-review', stage: 'idea' })])).toBe(true); + expect(active([close({ reasonCode: 'risk-effort-incomplete', stage: 'plan_complete' })])).toBe(true); + expect(active([close({ reasonCode: 'audit-ended-no-result', stage: 'plan_complete' })])).toBe(true); + }); + + it('ignores non-pane-close log entries (dispatch markers)', () => { + const markers: DowntimeLogEntry[] = [ + { itemId: 'WL-A', kind: 'intake', dispatchedAt: new Date(NOW - 60_000).toISOString(), stage: 'idea' }, + ]; + expect(active(markers)).toBe(false); + }); +}); diff --git a/packages/herdr/src/downtime-log.ts b/packages/herdr/src/downtime-log.ts index df3ae7623..684c4f00e 100644 --- a/packages/herdr/src/downtime-log.ts +++ b/packages/herdr/src/downtime-log.ts @@ -91,8 +91,15 @@ export async function appendCoordinationLogEntry( /** Rolling bound: keep at most this many entries in the log file. */ export const DOWNTIME_LOG_MAX_ENTRIES = 100; -/** Default cap for `recentDispatchedItems` (WL-0MUL2IX6H001YHDO). */ -export const RECENT_DISPATCH_LIMIT = 20; +/** + * Hours per recent-dispatch time block (WL-0MUMM9NED009TLL3). The recent + * dispatches view groups items into 4-hour UTC blocks per day, labelled + * `HH:00–HH:00` (00:00–04:00, 04:00–08:00, …, 20:00–24:00). + */ +export const DISPATCH_TIME_BLOCK_HOURS = 4; + +/** Heading label for recent-dispatch rows with no parseable timestamp. */ +export const UNKNOWN_TIME_BLOCK_LABEL = 'Unknown time'; /** Placeholder title for a log-derived row whose entry carried no title. */ export const UNKNOWN_DISPATCH_TITLE = '[unknown]'; @@ -149,6 +156,13 @@ export interface PaneCloseLogEntry { * key — see `pane-lifecycle.ts`). `'none'` for clean terminal outcomes. */ reasonCode?: string; + /** + * Worklog stage the item was at when the pane was closed + * (WL-0MUKYERLZ006ELL5), recorded from the dispatched-at marker stage. Lets + * the non-terminal cooldown be released when the item has since advanced + * past that stage (parent AC4). Optional for legacy entries. + */ + stage?: string; /** Whether the pane was actually closed (false for informational log-only). */ closed: boolean; /** @@ -397,8 +411,8 @@ function dispatchTimestampValue(ts: string | undefined): number { } /** - * Project the rolling dispatch log into up to `limit` recent dispatched - * work items (WL-0MUL2IX6H001YHDO). Reads the LOCAL log via the fail-safe + * Project the rolling dispatch log into recent dispatched work items + * (WL-0MUL2IX6H001YHDO). Reads the LOCAL log via the fail-safe * `readDowntimeLogEntries(cwd)` — a missing/unreadable/empty/malformed log * yields `[]` and this function never throws. * @@ -406,13 +420,12 @@ function dispatchTimestampValue(ts: string | undefined): number { * most recent log entry for ordering and metadata; pane-close lifecycle * entries annotate an existing row's `latestOutcome`/timestamp (a pane-close * for an item that was never dispatched does not create a row). Rows are - * ordered newest-first by their latest timestamp and capped at `limit` - * (default `RECENT_DISPATCH_LIMIT`). The projection is read-only. + * ordered newest-first by their latest timestamp. There is no result cap + * (WL-0MUMM9NED009TLL3): the log itself is bounded to + * `DOWNTIME_LOG_MAX_ENTRIES`, so every item retained in the log is shown. + * The projection is read-only. */ -export async function recentDispatchedItems( - cwd: string, - limit: number = RECENT_DISPATCH_LIMIT, -): Promise<RecentDispatchRow[]> { +export async function recentDispatchedItems(cwd: string): Promise<RecentDispatchRow[]> { const entries = await readDowntimeLogEntries(cwd); const rows = new Map<string, RecentDispatchRow>(); @@ -481,7 +494,108 @@ export async function recentDispatchedItems( list.sort( (a, b) => dispatchTimestampValue(b.latestTimestamp) - dispatchTimestampValue(a.latestTimestamp), ); - return list.slice(0, Math.max(0, limit)); + return list; +} + +/** + * One 4-hour UTC time block of recent-dispatch rows + * (WL-0MUMM9NED009TLL3). Blocks are produced by + * `groupRecentDispatchesByTimeBlock`, ordered chronologically (oldest first) + * with the synthetic "Unknown time" block always last. + */ +export interface DispatchTimeBlock { + /** + * UTC start of the block as ms since epoch (e.g. 00:00/04:00/…/20:00 UTC), + * used for chronological ordering. `Number.NEGATIVE_INFINITY` for the + * unknown-time block so it never sorts ahead of a real block. + */ + startMs: number; + /** True for the trailing block holding rows without a parseable timestamp. */ + unknownTime: boolean; + /** Heading label, e.g. `29 Sep 2026, 00:00–04:00` or `Unknown time`. */ + label: string; + /** Rows in the block, preserving the projection's newest-first ordering. */ + rows: RecentDispatchRow[]; +} + +/** UTC month abbreviations for the time-block heading (day month year). */ +const TIME_BLOCK_MONTHS = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +/** Two-digit zero-pad for a block boundary hour. */ +function padHour(hour: number): string { + return String(hour).padStart(2, '0'); +} + +/** + * Heading label for the 4-hour UTC block a timestamp belongs to + * (WL-0MUMM9NED009TLL3), e.g. `29 Sep 2026, 00:00–04:00`. The end of the + * day's last block renders as `20:00–24:00`. A missing or unparseable + * timestamp maps to {@link UNKNOWN_TIME_BLOCK_LABEL}. + */ +export function dispatchTimeBlockLabel(ts: string | undefined): string { + if (ts === undefined) return UNKNOWN_TIME_BLOCK_LABEL; + const t = Date.parse(ts); + if (Number.isNaN(t)) return UNKNOWN_TIME_BLOCK_LABEL; + const d = new Date(t); + const startHour = + Math.floor(d.getUTCHours() / DISPATCH_TIME_BLOCK_HOURS) * DISPATCH_TIME_BLOCK_HOURS; + const endHour = startHour + DISPATCH_TIME_BLOCK_HOURS; + const day = String(d.getUTCDate()).padStart(2, '0'); + const month = TIME_BLOCK_MONTHS[d.getUTCMonth()]; + const year = d.getUTCFullYear(); + return `${day} ${month} ${year}, ${padHour(startHour)}:00–${padHour(endHour)}:00`; +} + +/** + * Group projected recent-dispatch rows into 4-hour UTC time blocks + * (WL-0MUMM9NED009TLL3). Each row is classified by its `latestTimestamp` + * into the block that starts on the hour boundary (`00:00`, `04:00`, …, + * `20:00` UTC) on the row's UTC calendar day, so blocks never merge across + * midnight. Blocks are returned oldest-first; rows within a block keep the + * projection's newest-first order. Rows with a missing or unparseable + * timestamp are collected into a single trailing `Unknown time` block. + * + * Pure and total: an empty input yields `[]` and the function never throws. + */ +export function groupRecentDispatchesByTimeBlock(rows: RecentDispatchRow[]): DispatchTimeBlock[] { + const known = new Map<number, DispatchTimeBlock>(); + const unknown: DispatchTimeBlock = { + startMs: Number.NEGATIVE_INFINITY, + unknownTime: true, + label: UNKNOWN_TIME_BLOCK_LABEL, + rows: [], + }; + + for (const row of rows) { + const ts = row.latestTimestamp; + const t = ts === undefined ? NaN : Date.parse(ts); + if (Number.isNaN(t)) { + unknown.rows.push(row); + continue; + } + const d = new Date(t); + const startHour = + Math.floor(d.getUTCHours() / DISPATCH_TIME_BLOCK_HOURS) * DISPATCH_TIME_BLOCK_HOURS; + const startMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), startHour); + let block = known.get(startMs); + if (block === undefined) { + block = { + startMs, + unknownTime: false, + label: dispatchTimeBlockLabel(ts), + rows: [], + }; + known.set(startMs, block); + } + block.rows.push(row); + } + + const blocks = [...known.values()].sort((a, b) => a.startMs - b.startMs); + if (unknown.rows.length > 0) blocks.push(unknown); + return blocks; } /** @@ -674,6 +788,95 @@ export function markerStillExcludes( return now - t <= stalenessWindowMs; } +// ── Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5) ──────────── + +/** + * Worklog stage rank used by the non-terminal pane-close cooldown to detect + * stage advancement (WL-0MUKYERLZ006ELL5). Higher means further through the + * lifecycle; an unknown stage ranks -1 so it never looks "advanced". + */ +const NON_TERMINAL_COOLDOWN_STAGE_RANK: Record<string, number> = { + idea: 0, + intake_complete: 1, + plan_complete: 2, + in_review: 3, +}; + +/** + * Reason codes on a pane-close entry that represent a TERMINAL close — the + * item genuinely reached a terminal stage or the pane finished cleanly. + * `'none'` covers `closed-as-intake-complete` / `closed-as-plan-complete` / + * `audit-passed` / `audit-failed`; `'reached-in-review'` is the implement-pane + * success close (parent AC5). Any OTHER code — including future/unknown or + * absent ones — is treated as NON-terminal (fail-closed). + */ +const TERMINAL_PANE_CLOSE_REASON_CODES: ReadonlySet<string> = new Set([ + 'none', + 'reached-in-review', +]); + +function cooldownStageRank(stage: string | undefined): number { + if (typeof stage !== 'string') return -1; + return NON_TERMINAL_COOLDOWN_STAGE_RANK[stage] ?? -1; +} + +/** + * Decide whether a MINIMUM COOLDOWN still excludes an item from re-dispatch + * after a NON-TERMINAL pane close (WL-0MUKYERLZ006ELL5). Neutral sequential + * filter (never a strike, never a `no-candidate`): it consults the most recent + * `entryType: 'pane-close'` entry for the `(itemId, kind)` pair and returns + * true (skip) while that close: + * + * 1. is NON-terminal — its `reasonCode` is not in + * {@link TERMINAL_PANE_CLOSE_REASON_CODES} (fail-closed: unknown/absent + * codes are non-terminal), AND + * 2. the item has NOT advanced past the close's dispatched-at `stage` + * (parent AC4; a genuinely progressed item is released immediately), AND + * 3. is younger than `cooldownMs` (`now - closeTime < cooldownMs`). + * + * Returns false when there is no close record, the close was terminal, the item + * advanced, or the cooldown has elapsed. FAIL-CLOSED: a missing or unparseable + * close timestamp returns true (safe no-dispatch), bounded by the + * stage-advancement release. The most recent close wins — a later terminal + * close supersedes an earlier failure, and a later failure re-arms. + */ +export function isNonTerminalCooldownActive( + entries: DowntimeLogEntry[], + itemId: string, + kind: string, + itemStage: string | undefined, + cooldownMs: number, + now: number = Date.now(), +): boolean { + // Most-recent close for this (itemId, kind) wins (append order). + let latest: DowntimeLogEntry | undefined; + for (const e of entries) { + if (e.entryType !== 'pane-close') continue; + if (e.itemId !== itemId || e.kind !== kind) continue; + latest = e; + } + if (latest === undefined) return false; + + // Terminal close → no cooldown (parent AC5). Fail-closed: unknown/absent + // codes are non-terminal. + const reasonCode = typeof latest.reasonCode === 'string' ? latest.reasonCode : ''; + if (TERMINAL_PANE_CLOSE_REASON_CODES.has(reasonCode)) return false; + + // Stage advancement releases (parent AC4). Only release when the + // dispatched-at stage is KNOWN and the current stage is strictly higher — + // a close without a recorded stage cannot prove progress, so it stays held + // (fail-closed; the age check below still bounds it). + const dispatchedRank = cooldownStageRank(latest.stage); + const currentRank = cooldownStageRank(itemStage); + if (dispatchedRank >= 0 && currentRank > dispatchedRank) return false; + + // Age check. Missing/unparseable timestamp → fail-closed skip. + if (typeof latest.timestamp !== 'string') return true; + const t = Date.parse(latest.timestamp); + if (Number.isNaN(t)) return true; + return now - t < cooldownMs; +} + /** * Build the set of itemIds the downtime worker has already dispatched for * `/skill:implement` (`kind === 'implement'` entries only). Audit/plan/ diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 980575e08..b4d0e1932 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -101,6 +101,7 @@ import { clampDowntimeIdleThresholdMs, clampDowntimeRequiredFreeSlots, clampDowntimeNoCandidateCooldownMs, + clampDowntimeNonTerminalCooldownMs, clampDowntimeMarkerStaleWindowMs, countFreeUnownedSlots, isSlotOwned, @@ -117,6 +118,9 @@ import { 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, @@ -641,9 +645,170 @@ describe('dispatcher anchor wiring (C0 dispatcher workspace)', () => { }); }); +// ── 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) ── -describe('project workspace + item-ID tab dispatch wiring', () => { +// 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', @@ -781,7 +946,7 @@ describe('project workspace + item-ID tab dispatch wiring', () => { expect(deps.claimItem).not.toHaveBeenCalled(); }); - it('AC5 retirement: the full item id (not a prefix) is forwarded; no retired dep key', async () => { + 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 @@ -3713,6 +3878,25 @@ describe('downtime settings clamps', () => { 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) ─────────────────────────────────────────────── @@ -4303,6 +4487,7 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { deps?: Partial<DowntimeWorkerDeps>; mode?: 'cheap' | 'fast'; concurrentDispatchCap?: number; + drainPaused?: boolean; } = {}) { const cfg = { enabled: overrides.enabled ?? true, @@ -4316,6 +4501,8 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { ...(overrides.concurrentDispatchCap !== undefined ? { concurrentDispatchCap: overrides.concurrentDispatchCap } : {}), + // Drain pause signal (parent WL-0MUL0KO7Q003O7YJ, F2). + drainPaused: overrides.drainPaused, }; const fetcher = vi .fn() @@ -4429,6 +4616,37 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { expect(call[1]).not.toHaveProperty('spawnConfig'); }); + it('F2: a drainPaused config pauses new dispatches and reports the draining block token', async () => { + // While the mode-switch worker drains active sessions down to the cheap + // pool budget, the dispatcher must not spawn NEW panes. The poll still + // runs (so the mode-switch worker keeps observing free slots) and the + // refusal is neutral — no candidate was even selected. + const { worker, deps } = makeWorker({ thresholdMs: 0, drainPaused: true }); + vi.setSystemTime(1_000_000); + + const result = await worker.tick(); + + expect(result).toEqual({ polled: true, dispatched: false, idle: true }); + expect(worker.blockReason).toBe('draining'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('F2: dispatch resumes on the next idle tick once the drain signal clears', async () => { + const { worker, deps, cfg } = makeWorker({ thresholdMs: 0, drainPaused: true }); + vi.setSystemTime(1_000_000); + const paused = await worker.tick(); + expect(paused.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + // The drain completed (the proxy switched to cheap) → the very next + // idle tick dispatches normally. + cfg.drainPaused = false; + vi.setSystemTime(1_000_001); + const resumed = await worker.tick(); + expect(resumed.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + it('requires a fresh full idle period after a dispatch (AC5)', async () => { const { worker, deps, cfg } = makeWorker(); const start = 1_000_000; @@ -10034,6 +10252,165 @@ describe('dispatched success-marker staleness on the live path (WL-0MU6UL0RJ008I }); }); +// ── 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<Record<string, unknown>>): 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<string, unknown> = {}): Record<string, unknown> { + 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', () => { diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 0c330c12e..5b5f3e415 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -39,7 +39,18 @@ * additionally excludes items with `needsProducerReview === true` * (parent WL-0MTIAL65N004T22F): items flagged for producer review are * never auto-dispatched, preventing the worker from consuming local - * slots on items awaiting a human decision. A tier-2 CLI error does + * slots on items awaiting a human decision. + * A NON-TERMINAL pane-close cooldown (WL-0MUKYERLZ006ELL5) is applied as + * a sequential filter after the dispatched-marker exclusion and before + * the code-freeze gate: an item whose previous pane of the SAME kind + * closed without reaching a terminal stage (`agent-ended-no-terminal`, + * `audit-ended-no-result`, ...) is skipped until + * `downtimeNonTerminalCooldownMs` has elapsed, or the item advances past + * its dispatched-at stage. It is neutral — never a strike, never + * `no-candidate` (distinct reason `non-terminal-cooldown`) — so it never + * enters the no-candidate cooldown; the same filter is applied on the + * coordination offer path (`computeMostImportantItem` / + * `dispatchFromCoordination`). A tier-2 CLI error does * NOT short-circuit: the idea tier is still attempted so a tier-3 * candidate can still dispatch. * `wl next` failures are reported as `{ok:false}` (fail closed to busy) @@ -72,8 +83,10 @@ * work-item id (`deps.getItemTabAnchor`), and forwards that tab's root * pane as `--anchor <id>` so send-to-pi.sh splits from THAT pane. Each * project's automated panes therefore land in the project's own workspace - * grouped per item. When no plugin pane resolves, the path falls back to - * the retained machine-wide Dispatcher anchor (`deps.getDispatcherAnchor`, + * grouped per item — the PRIMARY path. When no plugin pane resolves, the + * path falls back to the per-prefix tab in the Dispatcher workspace + * (`deps.getDispatcherTabAnchor`, C1 WL-0MTRQT482001SNXC), then to the + * retained machine-wide Dispatcher anchor (`deps.getDispatcherAnchor`, * C0 WL-0MTR01EU7005SYZG). Anchor provisioning failure degrades to reason * 'anchor-unavailable' (neutral "no dispatch this cycle", never a fallback * to the leader's pane). Scheduled-prompt spawns keep the Dispatcher @@ -146,8 +159,10 @@ import { readDowntimeLogEntries as _readDowntimeEntries, dispatchedItemMarkers as _dispatchedMarkers, markerStillExcludes as _markerStillExcludes, + isNonTerminalCooldownActive as _isNonTerminalCooldownActive, appendPaneCloseLogEntry as _appendPaneCloseLogEntry, type DispatchMarker, + type DowntimeLogEntry, type PaneCloseLogEntry, type PaneLifecycleKind, } from './downtime-log.js'; @@ -163,6 +178,7 @@ import { paneCloseReaperDue } from './pane-close-scheduler.js'; import { buildDowntimePaneTitle, MAX_PANE_TITLE_LENGTH } from './pane-title.js'; import type { DispatcherAnchor, + DispatcherPrefixTabAnchor, ItemTabAnchor, ProjectWorkspaceTarget, } from './dispatcher-anchor.js'; @@ -251,6 +267,27 @@ export const DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS = 60_000; /** Default pause after a genuine empty backlog: 60 minutes. */ export const DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS = 3_600_000; +/** + * Default minimum cooldown (WL-0MUKYERLZ006ELL5) between successive downtime + * dispatches of the same `(item, kind)` after a pane closed WITHOUT reaching a + * terminal stage (`agent-ended-no-terminal`, `audit-ended-no-result`, or any + * future non-terminal code). Prevents a repeatedly failing session from being + * re-selected immediately and wasting local-LLM slots. Default 30 minutes. + */ +export const DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS = 30 * 60 * 1000; + +/** + * Hard floor for the non-terminal cooldown (1 minute): a defensive minimum so + * the control cannot be disabled or set trivially small (no immediate retry). + */ +export const DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS = 60 * 1000; + +/** + * Hard ceiling for the non-terminal cooldown (24 hours): above this a + * repeatedly-failing item could be stranded indefinitely. + */ +export const DOWNTIME_NON_TERMINAL_COOLDOWN_MAX_MS = 24 * 60 * 60 * 1000; + /** * Per-process dispatch-PIPELINE single-flight bound (WL-0MT50LKAK001EF5Q). * @@ -1418,6 +1455,8 @@ export type MostImportantItemResult = | { ok: true; reviewQueueHold: true } /** Every remaining offerable candidate is a critical item already in flight (live working pane): nothing offerable NOW, but the backlog is NOT empty — the caller must not pause or drop the entry (WL-0MUBVKYH5009CGBI / F4). */ | { ok: true; inFlightHold: true } + /** The only remaining offerable candidates are held by the non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): nothing offerable NOW, but the backlog is NOT empty — the caller must not pause or drop the entry. */ + | { ok: true; nonTerminalCooldownHold: true } | { ok: false; error?: string }; @@ -1993,8 +2032,10 @@ export interface DowntimeWorkerDeps { * worklog root `cwd` (WL-0MU321YK70035AYT, AC1/AC3). When it resolves, the * worklog dispatch path places the pane in that workspace (via * {@link getItemTabAnchor}) instead of the machine-wide Dispatcher anchor. - * A `null` result (no plugin pane, or an unreadable/ambiguous one) falls - * back to {@link getDispatcherAnchor} (AC4). Optional for backward + * This is the PRIMARY placement path (AC1, WL-0MUR5FUWD00024XN): a `null` + * result (no plugin pane, or an unreadable/ambiguous one) falls back to + * {@link getDispatcherTabAnchor} (per-prefix Dispatcher tab) when wired, + * else to {@link getDispatcherAnchor} (AC4). Optional for backward * compatibility with pre-change callers/tests; production wiring * (`createDowntimeDeps`) always provides it. */ @@ -2013,6 +2054,22 @@ export interface DowntimeWorkerDeps { workspaceId: string, itemId: string, ): Promise<ItemTabAnchor | null>; + /** + * Resolve or create the per-prefix tab anchor inside the Dispatcher + * workspace (C1, parent WL-0MTRQT482001SNXC). Given a work-item id + * prefix (e.g. `WL`, `TCE`), returns the tab+pane for routing the dispatch + * into a per-project tab. This is a FALLBACK path (AC3, WL-0MUR5FUWD00024XN): + * it is only consulted when {@link resolveProjectWorkspace} does not + * resolve a project workspace for the item's root. A `null` result then + * degrades to 'anchor-unavailable' — the caller NEVER falls back to the + * legacy Dispatcher anchor or the leader's pane when this dep is wired. + * Optional for test callers that inject mocks; production wiring provides + * it. + */ + getDispatcherTabAnchor?( + cwd: string, + prefix: string, + ): Promise<DispatcherPrefixTabAnchor | null>; /** * Audit trail for a successful dispatch: comment on the item + rolling * log entry under `.worklog`. Resolves TRUE only when the rolling-log @@ -2507,8 +2564,8 @@ export function selectCriticalFirstCandidates( async function dispatchFromHerdrList( deps: DowntimeWorkerDeps, items: DowntimeHerdrItem[], - ctx: { cwd: string; model: string; freeSlots?: number; frozen: boolean; panesEligible: boolean; auditEligible: boolean; reviewGate: ReviewQueueGateResult | null; markerStaleWindowMs: number; inFlight: InFlightPanes; spawnConfig?: DowntimeSpawnConfig }, - flags: { auditInFlight: boolean; auditCheckFailed: boolean; auditCheckError?: string; freshnessSkip: boolean; reviewHeld: boolean; auditHostSaturated: boolean }, + ctx: { cwd: string; model: string; freeSlots?: number; frozen: boolean; panesEligible: boolean; auditEligible: boolean; reviewGate: ReviewQueueGateResult | null; markerStaleWindowMs: number; nonTerminalCooldownMs: number; inFlight: InFlightPanes; spawnConfig?: DowntimeSpawnConfig }, + flags: { auditInFlight: boolean; auditCheckFailed: boolean; auditCheckError?: string; freshnessSkip: boolean; reviewHeld: boolean; cooldownHeld: boolean; auditHostSaturated: boolean }, ): Promise<DowntimeDispatchOutcome | null> { if (items.length === 0) return null; const entries = await _readDowntimeEntries(ctx.cwd); @@ -2635,6 +2692,15 @@ async function dispatchFromHerdrList( ) { continue; } + // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): a neutral + // sequential filter — hold an item whose previous same-kind pane closed + // without reaching a terminal stage until the cooldown elapses (or the + // item advances past its dispatched-at stage). Never a strike, never + // 'no-candidate'. + if (_isNonTerminalCooldownActive(entries, item.id, k, item.stage, ctx.nonTerminalCooldownMs, now)) { + flags.cooldownHeld = true; + continue; + } // Code-freeze split-by-skill: audit+implement dispatch pauses during // a freeze/ambiguous marker (plan/intake still dispatch). if (ctx.frozen && (k === 'audit' || k === 'implement')) continue; @@ -2715,10 +2781,13 @@ async function rollbackClaimForFailure( * `deps.resolveProjectWorkspace`, ensure/reuse the tab labelled with the * exact work-item id via `deps.getItemTabAnchor`, and forward that tab's * root pane as `anchorId` so the pane lands in the project workspace under - * the item's tab. When no project workspace resolves, fall back to the - * retained machine-wide `deps.getDispatcherAnchor` (AC4). A null/failed - * resolution aborts with reason 'anchor-unavailable' — never the leader's - * pane and never another project's workspace. + * the item's tab. This is the PRIMARY placement path. When no project + * workspace resolves, placement falls back to the per-prefix tab in the + * `Dispatcher` workspace (C1, WL-0MTRQT482001SNXC), then to the retained + * machine-wide `deps.getDispatcherAnchor` (AC4). A null/failed + * project-workspace resolution does not abort when a fallback is wired; + * a failed item-tab provision aborts with reason 'anchor-unavailable' — + * never the leader's pane and never another project's workspace. * - Claim (compare-and-swap): exactly one concurrent pane wins; a loser * (or a wl claim failure) ABORTS the dispatch — no pane, no marker, no * success record. A lost race resolves reason 'claim-failed' (neutral, @@ -2755,56 +2824,90 @@ async function dispatchClaimedTier( }, ): Promise<DowntimeDispatchOutcome> { const expected = TIER_EXPECTED[kind]; - // Dispatcher anchor (C0 WL-0MTR01EU7005SYZG): resolve the dedicated - // Dispatcher anchor pane BEFORE the claim so a provisioning failure - // degrades to "no dispatch this cycle" (F1 AC6 — caller fail-safe) without - // claiming/marking an item that can never spawn a pane. Absent dep - // (legacy/test callers) → no anchor, legacy current-pane behavior. - // - // Per-prefix tab anchor (C1, parent WL-0MTRQT482001SNXC): the primary path - // routes the candidate's work-item prefix (`<PREFIX>` before the first `-`) - // to its own tab inside the single Dispatcher workspace. When wired, the - // per-prefix resolver REPLACES the legacy single anchor — a null result is - // 'anchor-unavailable' and NEVER falls back to the legacy anchor or the - // leader's pane. + // Placement precedence (AC1, WL-0MUR5FUWD00024XN): resolve the anchor + // BEFORE the claim so a provisioning failure degrades to "no dispatch this + // cycle" (F1 AC6 — caller fail-safe) without claiming/marking an item that + // can never spawn a pane. The precedence is: + // (a) project workspace + item-ID tab (primary); + // (b) per-prefix Dispatcher tab (fallback when no project workspace + // resolves); + // (c) legacy single Dispatcher anchor. + // Absent deps (legacy/test callers) → no anchor, legacy current-pane + // behavior. let anchorId: string | undefined; - // True when `anchorId` is a PROJECT-WORKSPACE item-tab anchor (the primary - // path). Those tabs are provisioned with herdr's initial root pane (an - // empty bash pane) which is only needed as the split anchor for the FIRST - // dispatch — after the dispatch pane spawns it is closed so the tab shows - // only the productive pane (WL-0MU2EOHK900425VU). The Dispatcher fallback - // anchor is deliberately exempt: its root pane is the persisted dispatch - // anchor and closing it would trigger a blank re-provision loop. + // True when `anchorId` is a tab root pane (a project item tab or a + // per-prefix Dispatcher tab). Tab roots are provisioned with herdr's + // initial root pane (an empty bash pane) which is only needed as the split + // anchor for the FIRST dispatch — after the dispatch pane spawns it is + // closed so the tab shows only the productive pane. The legacy Dispatcher + // fallback anchor is deliberately exempt: its root pane is the persisted + // dispatch anchor and closing it would trigger a blank re-provision loop. let anchorIsTabRoot = false; - // Primary path (AC1/AC3): resolve the project workspace that hosts the - // worklog plugin pane for this item's root, then ensure/reuse the tab - // labelled with the exact work-item id and anchor the pane there (AC2). - let projectTarget: ProjectWorkspaceTarget | null = null; + // Extract the work-item id prefix (e.g. `WL-0MTO…` → `WL`, `TCE-…` → `TCE`). + const prefix = candidate.id.split('-', 1)[0]; + + // ── Primary path: project workspace + item-ID tab (WL-0MU321YK70035AYT) ─ + // Resolve the candidate's project workspace from its worklog root, ensure/ + // reuse a tab labelled with the exact work-item id, and anchor the pane to + // that tab's root pane. This is the PRIMARY placement path: when a project + // plugin pane resolves for the root, the Dispatcher workspace is NOT used + // (AC2/AC3). A null resolution falls through to the per-prefix fallback. if (typeof deps.resolveProjectWorkspace === 'function') { + let projectTarget: ProjectWorkspaceTarget | null = null; try { projectTarget = await deps.resolveProjectWorkspace(opts.cwd); } catch { projectTarget = null; // fail-closed on any resolver error } + if (projectTarget !== null && typeof deps.getItemTabAnchor === 'function') { + let tabAnchor: ItemTabAnchor | null = null; + try { + tabAnchor = await deps.getItemTabAnchor(opts.cwd, projectTarget.workspaceId, candidate.id); + } catch { + tabAnchor = null; // fail-closed + } + if (tabAnchor === null) { + // The workspace resolved but its item tab could not be provisioned: + // fail closed rather than place a project pane in the Dispatcher + // workspace (never a wrong placement). + return { dispatched: false, reason: 'anchor-unavailable' }; + } + anchorId = tabAnchor.paneId; + anchorIsTabRoot = true; + } } - if (projectTarget !== null && typeof deps.getItemTabAnchor === 'function') { - let tabAnchor: ItemTabAnchor | null = null; + + // ── Fallback: per-prefix tab in Dispatcher workspace (AC3) ──────────── + // Reached only when no project workspace resolved for the item's root. + // When wired, the per-prefix resolver REPLACES the legacy single anchor — + // a null/failed resolution is 'anchor-unavailable' (no dispatch this + // cycle) and NEVER falls back to the legacy Dispatcher anchor or the + // leader's pane (constraint: fail-safe, never a wrong-tab placement). + if (anchorId === undefined && typeof deps.getDispatcherTabAnchor === 'function') { + if (!prefix) { + // No prefix extractable (empty/invalid id) → cannot route by prefix. + return { dispatched: false, reason: 'anchor-unavailable' }; + } + let prefixAnchor: DispatcherPrefixTabAnchor | null = null; try { - tabAnchor = await deps.getItemTabAnchor(opts.cwd, projectTarget.workspaceId, candidate.id); + prefixAnchor = await deps.getDispatcherTabAnchor(opts.cwd, prefix); } catch { - tabAnchor = null; // fail-closed + prefixAnchor = null; // fail-closed } - if (tabAnchor === null) { - // The workspace resolved but its item tab could not be provisioned: - // fail closed rather than place a project pane in the Dispatcher - // workspace (never a wrong placement). + if (prefixAnchor === null) { return { dispatched: false, reason: 'anchor-unavailable' }; } - anchorId = tabAnchor.paneId; + anchorId = prefixAnchor.paneId; + // Per-prefix Dispatcher tabs use herdr's initial root pane as anchor. anchorIsTabRoot = true; - } else if (typeof deps.getDispatcherAnchor === 'function') { - // Fallback (AC4): no project plugin pane resolved → the retained - // machine-wide Dispatcher anchor. + } + + // ── Fallback: legacy Dispatcher single anchor (AC4) ─────────────────── + // When this dep IS wired, a null/failed resolution fails closed + // ('anchor-unavailable') — never a current-pane placement. When it is NOT + // wired (pre-C0 / legacy/test callers), no anchor is set and the spawn + // uses the legacy current-pane behaviour. + if (anchorId === undefined && typeof deps.getDispatcherAnchor === 'function') { let anchor: DispatcherAnchor | null = null; try { anchor = await deps.getDispatcherAnchor(opts.cwd); @@ -2815,7 +2918,11 @@ async function dispatchClaimedTier( return { dispatched: false, reason: 'anchor-unavailable' }; } anchorId = anchor.paneId; + // The legacy Dispatcher anchor is NOT a tab root — it is the persisted + // dispatch pane and must never be closed. + anchorIsTabRoot = false; } + // Cross-root claim (WL-0MTQ14W7L003II5A): opts.cwd is the item's worklog // root — the coordination leader passes the OFFER's root so the CAS claim // lands in the item's own database (never the leader's module override). @@ -3329,6 +3436,12 @@ export function toCoordinationCandidate(info: DowntimeItemInfo): DowntimeCandida * implements remain the result is `{ok:true, reviewQueueHold:true}` * (never `noCandidate`), so the no-candidate cooldown is not entered * while the queue drains. + * - the non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5) holds an + * item whose previous same-kind pane closed without reaching a terminal + * stage until `downtimeNonTerminalCooldownMs` elapses (or the item + * advances past its dispatched-at stage); when ONLY cooldown-held items + * remain the result is `{ok:true, nonTerminalCooldownHold:true}` (never + * `noCandidate`), so the no-candidate cooldown is not entered. * * Active-audit single-flight and the free-slot minimums are * dispatch-time gates, so they are NOT applied to an offer. @@ -3345,6 +3458,7 @@ export async function computeMostImportantItem( now: number = Date.now(), browseItemCount: number = DEFAULT_BROWSE_ITEM_COUNT, markerStaleWindowMs: number = DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, + nonTerminalCooldownMs: number = DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, ): Promise<MostImportantItemResult> { // The check-in (and the worker's no-candidate probe) requires the Herdr // head lookup: without it there is no canonical ranking to offer from — @@ -3390,6 +3504,9 @@ export async function computeMostImportantItem( return reviewGate; }; let heldByReviewQueue = false; + // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): neutral hold — + // the backlog is not empty, so the caller must not pause. + let heldByCooldown = false; // Item-scoped in-flight guard (WL-0MUBEZ6PE002WLP4 / F4 // WL-0MUBVKYH5009CGBI): resolved for THIS instance's OWN root (`cwd`) — @@ -3468,6 +3585,12 @@ export async function computeMostImportantItem( ) { continue; } + // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): neutral + // sequential filter (same placement as the dispatch path). + if (_isNonTerminalCooldownActive(entries, item.id, k, item.stage, nonTerminalCooldownMs, now)) { + heldByCooldown = true; + continue; + } // Code-freeze split-by-skill: audit+implement+risk-effort offers pause // during a freeze/ambiguous marker (plan/intake still offer). if (frozen && (k === 'audit' || k === 'implement' || k === 'risk-effort')) continue; @@ -3529,9 +3652,11 @@ export async function computeMostImportantItem( // resumes immediately when the queue drains below the threshold). return heldByReviewQueue ? { ok: true, reviewQueueHold: true } - : heldByInFlight - ? { ok: true, inFlightHold: true } - : { ok: true, noCandidate: true }; + : heldByCooldown + ? { ok: true, nonTerminalCooldownHold: true } + : heldByInFlight + ? { ok: true, inFlightHold: true } + : { ok: true, noCandidate: true }; } /** @@ -3563,7 +3688,13 @@ export async function computeMostImportantItem( * implements flow unconditionally; when every surviving offer is held, * the outcome reason is the neutral 'review-queue-hold' (never * 'no-candidate' / cooldown); - * - per-tier free-slot minimums (audit ≥ 2, single-pane ≥ 1). + * - per-tier free-slot minimums (audit ≥ 2, single-pane ≥ 1); + * - non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): an offer whose + * previous same-kind pane closed without reaching a terminal stage is + * KEPT but skipped this cycle until `downtimeNonTerminalCooldownMs` + * elapses (or the item advances past its dispatched-at stage); when every + * surviving offer is cooldown-held the outcome is the neutral + * 'non-terminal-cooldown' (never 'no-candidate'). * * The first offer that passes every filter dispatches via the existing * `dispatchClaimedTier` pipeline (CAS claim → marker write → spawn); its @@ -3579,7 +3710,7 @@ export async function computeMostImportantItem( export async function dispatchFromCoordination( deps: DowntimeWorkerDeps, entries: CoordinationEntry[], - opts: { model: string; cwd: string; coordinationDir: string; freeSlots?: number; leaseTtlMs?: number; browseItemCount?: number; spawnConfig?: DowntimeSpawnConfig }, + opts: { model: string; cwd: string; coordinationDir: string; freeSlots?: number; leaseTtlMs?: number; browseItemCount?: number; nonTerminalCooldownMs?: number; spawnConfig?: DowntimeSpawnConfig }, now: number = Date.now(), ): Promise<DowntimeDispatchOutcome> { // The leader path REQUIRES the item-fetch dep: without it the leader can @@ -3637,6 +3768,19 @@ export async function dispatchFromCoordination( }; let inFlightHold = false; + // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): read the rolling + // dispatch log ONCE per OFFER root (cached) — the log is per-worklog-root, + // like the review gate and in-flight caches above. + const cooldownEntriesCache = new Map<string, DowntimeLogEntry[]>(); + const cooldownEntriesForRoot = async (root: string): Promise<DowntimeLogEntry[]> => { + if (!cooldownEntriesCache.has(root)) { + cooldownEntriesCache.set(root, await _readDowntimeEntries(root)); + } + return cooldownEntriesCache.get(root) ?? []; + }; + const cooldownMs = opts.nonTerminalCooldownMs ?? DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS; + let cooldownHold = false; + // Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX): FIRST dispatch stage, // gated by the SAME fresh-read code-freeze marker as the audit/implement // tiers (AC5) — while frozen OR ambiguous (fail-closed) scheduled prompts @@ -3711,6 +3855,23 @@ export async function dispatchFromCoordination( removeEntry(opts.coordinationDir, entry.instanceId); continue; } + // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): neutral + // sequential filter — keep the entry (it is still a valid offer) but + // skip it this cycle while the cooldown holds. Never a strike, never + // 'no-candidate'. + if ( + _isNonTerminalCooldownActive( + await cooldownEntriesForRoot(worklogRoot), + result.info.id, + kind, + result.info.stage, + cooldownMs, + now, + ) + ) { + cooldownHold = true; + continue; + } // Code-freeze split-by-skill: audit/implement/risk-effort offers pause // during a freeze/ambiguous marker (plan/intake still dispatch). A frozen // offer is stale for the dispatch window — drop it so the owner re-offers @@ -3819,9 +3980,11 @@ export async function dispatchFromCoordination( ? { dispatched: false, reason: 'code-freeze' } : reviewHold ? { dispatched: false, reason: REVIEW_QUEUE_HOLD_REASON } - : inFlightHold - ? { dispatched: false, reason: 'in-flight-pane' } - : { dispatched: false, reason: 'no-candidate' }; + : cooldownHold + ? { dispatched: false, reason: 'non-terminal-cooldown' } + : inFlightHold + ? { dispatched: false, reason: 'in-flight-pane' } + : { dispatched: false, reason: 'no-candidate' }; } /** @@ -3845,11 +4008,13 @@ export async function runCoordinationCheckIn( browseItemCount?: number; /** Dispatched success-marker staleness window (WL-0MU6UL0RJ008IHGT); defaults to DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS. */ markerStaleWindowMs?: number; + /** Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5); defaults to DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS. */ + nonTerminalCooldownMs?: number; }, now: number = Date.now(), ): Promise<{ offered: string | null; updated: boolean }> { const current = getEntry(coordinator.coordinationDir, coordinator.instanceId); - const result = await computeMostImportantItem(deps, coordinator.cwd, now, coordinator.browseItemCount, coordinator.markerStaleWindowMs); + const result = await computeMostImportantItem(deps, coordinator.cwd, now, coordinator.browseItemCount, coordinator.markerStaleWindowMs, coordinator.nonTerminalCooldownMs); if (!result.ok) { // wl/CLI errors — keep the existing entry (fail-open), retry next check-in. return { offered: current?.workItemId ?? null, updated: false }; @@ -4047,6 +4212,13 @@ export async function dispatchDowntimeWork( * `DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS`. */ markerStaleWindowMs?: number; + /** + * Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): an item whose + * previous same-kind pane closed without reaching a terminal stage is + * skipped until this many ms elapse. Optional — absent falls back to + * `DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS`. + */ + nonTerminalCooldownMs?: number; /** Mode-aware Phase 2 parallelism inputs (WL-0MT50S9JW001DHME). */ spawnConfig?: DowntimeSpawnConfig; }, @@ -4144,7 +4316,7 @@ export async function dispatchDowntimeWork( // work exists, so the fallback is unreachable there and will be removed // once the suite is fully on Herdr-head stubs. { - const flags = { auditInFlight, auditCheckFailed, auditCheckError: undefined, freshnessSkip, reviewHeld: false, auditHostSaturated }; + const flags = { auditInFlight, auditCheckFailed, auditCheckError: undefined, freshnessSkip, reviewHeld: false, cooldownHeld: false, auditHostSaturated }; const head = await deps.getHerdrListHead(opts.cwd); if (!head.ok) return { dispatched: false, reason: 'wl-error', error: (head as { error?: string }).error }; if (head.items.length > 0) { @@ -4154,7 +4326,7 @@ export async function dispatchDowntimeWork( // spawn. `resolveInFlightPanes` never throws — an unavailable query // degrades to the marker-TTL fallback inside the decision table. const inFlight = await resolveInFlightPanes(deps, opts.cwd); - const ctx = { cwd: opts.cwd, model: opts.model, freeSlots, frozen, panesEligible, auditEligible, reviewGate, markerStaleWindowMs: opts.markerStaleWindowMs ?? DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, inFlight, spawnConfig: opts.spawnConfig }; + const ctx = { cwd: opts.cwd, model: opts.model, freeSlots, frozen, panesEligible, auditEligible, reviewGate, markerStaleWindowMs: opts.markerStaleWindowMs ?? DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, nonTerminalCooldownMs: opts.nonTerminalCooldownMs ?? DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, inFlight, spawnConfig: opts.spawnConfig }; const herdrOutcome = await dispatchFromHerdrList(deps, head.items, ctx, flags); if (herdrOutcome !== null) return herdrOutcome; @@ -4181,6 +4353,7 @@ export async function dispatchDowntimeWork( const auditCheckError = flags.auditCheckError; freshnessSkip = flags.freshnessSkip; const reviewHeld = flags.reviewHeld; + const cooldownHeld = flags.cooldownHeld; // Herdr list had items but every one was filtered by a safety gate: // compose the terminal reason and DO NOT fall through to the legacy // chain (AC2 — gates are filters, not a fallback ranking). @@ -4204,6 +4377,11 @@ export async function dispatchDowntimeWork( // candidates: neutral 'review-queue-hold', NEVER 'no-candidate' — no // cooldown while audits drain the queue (WL-0MTTSWC1X005P4VD AC2). if (reviewHeld) return { dispatched: false, reason: REVIEW_QUEUE_HOLD_REASON }; + // Non-terminal cooldown held the only remaining candidates: neutral + // 'non-terminal-cooldown', NEVER 'no-candidate' — the backlog is not + // empty, so the no-candidate cooldown must not fire + // (WL-0MUKYERLZ006ELL5 AC3/AC6). + if (cooldownHeld) return { dispatched: false, reason: 'non-terminal-cooldown' }; return { dispatched: false, reason: 'no-candidate' }; } // Genuinely empty Herdr list → keep flags and fall through to the @@ -4866,6 +5044,9 @@ export async function monitorDispatchedPanes( outcome: decision.outcome, reason: decision.reason, reasonCode: decision.reasonCode, + // Dispatched-at stage, so the non-terminal cooldown can be released + // when the item advances (WL-0MUKYERLZ006ELL5 parent AC4). + stage: pane.stage, closed, }; try { @@ -4923,6 +5104,17 @@ export interface DowntimeWorkerConfig { * at `'1'`. */ mode?: 'cheap' | 'fast'; + /** + * Drain-pause signal from the mode-switch worker (parent + * WL-0MUL0KO7Q003O7YJ, F2 WL-0MUNMCZ2K003DZ50): `true` while the worker + * is draining active sessions down to the cheap-pool budget. When set, + * `tick()` polls and tracks idle as usual but never dispatches a NEW + * work item — in-flight work is untouched. Re-read every tick, so once + * the drain ends and the proxy switches to cheap, dispatch resumes on + * the next idle tick. Defaults to `undefined` → no pause (backward + * compatible fail-open: an unwired worker dispatches as before). + */ + drainPaused?: boolean; /** * Maximum concurrent dispatch pipelines (WL-0MT50S9JW001DHME). Defaults * to `DISPATCH_PIPELINE_SINGLE_FLIGHT` (1). Only a single-flight budget @@ -4931,6 +5123,11 @@ export interface DowntimeWorkerConfig { concurrentDispatchCap?: number; /** Pause duration after a genuine empty backlog (no-candidate), ms. */ noCandidateCooldownMs: number; + /** + * Non-terminal pane-close cooldown, ms (WL-0MUKYERLZ006ELL5). Optional + * for backward compat — defaults to DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS. + */ + nonTerminalCooldownMs?: number; /** Sprint-complete threshold (parent WL-0MTHSHN5V008R5L0). Optional for backward compat — defaults to 20. */ browseItemCount?: number; /** @@ -5483,6 +5680,7 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker instanceId, browseItemCount: cfg.browseItemCount, markerStaleWindowMs: cfg.markerStaleWindowMs, + nonTerminalCooldownMs: cfg.nonTerminalCooldownMs, }, tickNow); lastOfferEmpty = checkIn.offered === null; // WL-0MTEZ4XZJ006Y9U7 (AC2): a successful re-offer proves the @@ -5786,6 +5984,28 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker const contentionQueueDepth = typeof status.contention_queue_depth === 'number' ? status.contention_queue_depth : 0; + // ── Drain pause (parent WL-0MUL0KO7Q003O7YJ, F2 WL-0MUNMCZ2K003DZ50) ── + // While the mode-switch worker drains active sessions down to the + // cheap-pool budget, pause NEW dispatches. The gate sits AFTER the + // poll + `onProxyIdle` hook above, so the mode-switch worker keeps + // receiving a fresh status every tick and can observe the drain + // complete; in-flight panes are never touched. `drainPaused` is + // re-read from config() each tick, so once the drain ends and the + // proxy switches to cheap, dispatch resumes on the next idle tick. + // Neutral refusal (no strike, no cooldown) like the slot-owned / + // contention gates below. Fail-open: `undefined` (unwired) never + // pauses. + if (cfg.drainPaused === true) { + void recordDecision(cfg.cwd, 'draining', { + freeSlots, + totalSlots: status.total_slots, + ownerPresent: ownerLeaseHeld, + runningPanes, + contentionDepth: contentionQueueDepth, + }); + return { polled: true, dispatched: false, idle: true }; + } + // Single machine-wide dispatch gate applied to BOTH coordination and // legacy modes: a new pane must never be dispatched while the slot is // owned or the proxy reports live contention. Each refusal is neutral @@ -5844,6 +6064,7 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker coordinationDir: opts.coordinationDir, freeSlots, browseItemCount: cfg.browseItemCount, + nonTerminalCooldownMs: cfg.nonTerminalCooldownMs, leaseTtlMs: opts.leaseTtlSeconds ? opts.leaseTtlSeconds * 1000 : DEFAULT_LEASE_TTL_SECONDS * 1000, @@ -5862,6 +6083,8 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker contentionQueueDepth, // Success-marker staleness window (WL-0MU6UL0RJ008IHGT). markerStaleWindowMs: cfg.markerStaleWindowMs, + // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5). + nonTerminalCooldownMs: cfg.nonTerminalCooldownMs, spawnConfig, }); // Record the refusal reason (WL-0MU8808ZY0091JIA): actionable @@ -5906,7 +6129,7 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker // and reset the idle tracker (fresh full idle period required // after the pause). if (opts.coordinationDir && typeof opts.deps.fetchItem === 'function') { - const probe = await computeMostImportantItem(opts.deps, cfg.cwd, Date.now(), cfg.browseItemCount, cfg.markerStaleWindowMs); + const probe = await computeMostImportantItem(opts.deps, cfg.cwd, Date.now(), cfg.browseItemCount, cfg.markerStaleWindowMs, cfg.nonTerminalCooldownMs); if (probe.ok && 'noCandidate' in probe && probe.noCandidate) { errorStrikes = 0; // the CLI answered — it is healthy cooldownUntil = Date.now() + cfg.noCandidateCooldownMs; @@ -6961,6 +7184,22 @@ export function clampDowntimeNoCandidateCooldownMs(value: number): number { return Math.max(Math.round(value), DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS); } +/** + * Clamp the non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): reject + * negative/non-finite (fall back to the 30-minute default) and clamp to + * [DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS, + * DOWNTIME_NON_TERMINAL_COOLDOWN_MAX_MS] (1 minute – 24 hours) so the control + * can neither be disabled/trivially small (immediate retry) nor set so large + * that a repeatedly-failing item is stranded. + */ +export function clampDowntimeNonTerminalCooldownMs(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS; + return Math.min( + Math.max(Math.round(value), DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS), + DOWNTIME_NON_TERMINAL_COOLDOWN_MAX_MS, + ); +} + /** * Clamp the dispatched success-marker staleness window (WL-0MU6UL0RJ008IHGT): * reject negative/non-finite (fall back to the 24 h default) and clamp to diff --git a/packages/herdr/src/form-dialog.test.ts b/packages/herdr/src/form-dialog.test.ts index f9322a7a9..5ca0b8bd7 100644 --- a/packages/herdr/src/form-dialog.test.ts +++ b/packages/herdr/src/form-dialog.test.ts @@ -305,6 +305,66 @@ describe('FormState interactions', () => { }); }); +// ── Coalesced chunk handling — fast typing (WL-0MTV67MZU003H7SH) ────── +// Fast typing delivers several keystrokes in one stdin chunk; every +// character must be preserved (previously the whole chunk was dropped). + +describe('FormState coalesced chunk handling (fast typing)', () => { + it('appends every character from a single multi-character chunk', () => { + const state = makeForm({ fields: [{ name: 'status' }] }); + state.handleInput('hello'); + expect(state.fields[0].value).toBe('hello'); + }); + + it('preserves characters across many coalesced chunks (no drops)', () => { + const state = makeForm({ fields: [{ name: 'status' }] }); + for (const chunk of ['the ', 'quick ', 'brown ', 'fox']) state.handleInput(chunk); + expect(state.fields[0].value).toBe('the quick brown fox'); + }); + + it('applies printable characters before an escape sequence in the same chunk', () => { + const state = makeForm({ fields: [{ name: 'a' }, { name: 'b' }] }); + state.handleInput('ab\x1b[B'); + expect(state.fields[0].value).toBe('ab'); + expect(state.activeFieldIndex).toBe(1); + }); + + it('submits when a candidate chunk ends with Enter, keeping preceding characters', () => { + let submitted: string | null = null; + const state = new FormState('cmd <x>', 'd', [{ name: 'x', default: '' }], + (r) => { submitted = r; }, () => {}); + state.handleInput('value\r'); + expect(submitted).toBe('cmd value'); + }); + + it('applies Backspace inside a coalesced chunk', () => { + const state = makeForm({ fields: [{ name: 'status' }] }); + state.handleInput('abc\x7f'); + expect(state.fields[0].value).toBe('ab'); + }); + + it('navigates fields with Tab inside a coalesced chunk', () => { + const state = makeForm({ fields: [{ name: 'a' }, { name: 'b' }] }); + state.handleInput('a\tb'); + expect(state.fields[0].value).toBe('a'); + expect(state.fields[1].value).toBe('b'); + }); + + it('cancels when a chunk contains Esc (terminal result)', () => { + let cancelled = false; + const state = new FormState('cmd <x>', 'd', [{ name: 'x', default: '' }], + () => {}, () => { cancelled = true; }); + state.handleInput('\x1b'); + expect(cancelled).toBe(true); + }); + + it('still inserts a full self-contained bracketed paste verbatim', () => { + const state = makeForm({ fields: [{ name: 'status' }] }); + state.handleInput(`${BRACKETED_PASTE_START}multi\nline${BRACKETED_PASTE_END}`); + expect(state.fields[0].value).toBe('multi\nline'); + }); +}); + // ── Paste / cut / newline / bracketed-paste (WL-0MSW6KCTA0092DCV) ──── describe('FormState paste & cut', () => { diff --git a/packages/herdr/src/form-dialog.ts b/packages/herdr/src/form-dialog.ts index 56c9fce2b..0ea3e6cc0 100644 --- a/packages/herdr/src/form-dialog.ts +++ b/packages/herdr/src/form-dialog.ts @@ -7,6 +7,8 @@ * trigger an interactive full-pane form page in the TUI. */ +import { splitKeypresses } from './key-input.js'; + // ── Identifier extraction ───────────────────────────────────────────── /** @@ -458,15 +460,36 @@ export class FormState { } /** - * Process a single keypress in form mode. + * Process a raw stdin chunk in form mode. + * + * A single `data` event may carry SEVERAL coalesced keystrokes when the + * user types quickly (the PTY batches bytes) — each token is processed in + * order so no character is dropped (WL-0MTV67MZU003H7SH). Single-key + * callers are unaffected: the tokeniser returns their key unchanged. * - * @param key - The raw keypress string + * @param chunk - The raw keypress chunk (one or more keys) * @returns A {@link FormInputResult} describing the outcome. `submitted` / * `cancelled` are terminal; `paste`/`cut` signal the caller to * perform an async OS-clipboard operation; `none` means the - * key was consumed by editing/navigation. + * key was consumed by editing/navigation. When a chunk contains a + * terminal key, the preceding keys are applied first and the + * terminal result is returned. */ - handleInput(key: string): FormInputResult { + handleInput(chunk: string): FormInputResult { + // Split a coalesced chunk into individual keys and process each in + // order. A single-token chunk (the common case) falls straight through + // to the existing single-key logic below. + const keys = splitKeypresses(chunk); + if (keys.length > 1) { + let last: FormInputResult = { type: 'none' }; + for (const token of keys) { + last = this.handleInput(token); + if (last.type !== 'none') return last; + } + return last; + } + const key = keys[0] ?? ''; + // ── Bracketed-paste unwrapping (WL-0MSW6KCTA0092DCV) ────────── // When a chunk arrives inside an open bracketed-paste region — or the // whole chunk is a self-contained bracketed paste — insert the inner diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 2bad7c12e..7b39d5514 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -1718,20 +1718,24 @@ describe('createDowntimeDeps', () => { }); setExecFileAsync(mockExec as never); const spawnFn = vi.fn(() => ({ unref: vi.fn(), once: vi.fn() })); - // The real deps resolve the project workspace / Dispatcher anchor via the - // herdr CLI, which is absent in tests — inject stub resolvers so the - // anchored spawn path is exercised without a live herdr session. Project - // workspace + item-ID tab (WL-0MU321YK70035AYT): the worklog dispatch path - // resolves the project workspace then the exact item-ID tab; the legacy - // single anchor is retained only for the AC4 fallback and scheduled - // prompts. + // The real deps resolve the project workspace / item-ID tab / per-prefix + // tab via the herdr CLI, which is absent in tests — inject stub resolvers + // so the anchored spawn path is exercised without a live herdr session. + // Project workspace + item-ID tab (WL-0MU321YK70035AYT): the PRIMARY + // placement path — the worklog dispatch resolves the item's own project + // workspace and anchors to the tab labelled with the exact work-item id. + // The per-prefix Dispatcher tab and the legacy single anchor are fallbacks + // (only when no project workspace resolves) — see WL-0MUR5FUWD00024XN. const anchorResolver = vi.fn().mockResolvedValue({ paneId: 'wD:FALLBACK', workspaceId: 'wD' }); const projectWorkspaceResolver = vi .fn() .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }); const itemTabAnchorResolver = vi .fn() - .mockResolvedValue({ tabId: 'wC:tWL-ONCE', paneId: 'wC:pTEST' }); + .mockResolvedValue({ tabId: 'wC:tWL-ONCE', paneId: 'wC:tWL-ONCE:pTEST' }); + const dispatcherTabAnchorResolver = vi + .fn() + .mockResolvedValue({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:pTEST' }); const deps = createDowntimeDeps( '/path/to/send-to-pi.sh', 'Map', @@ -1740,6 +1744,7 @@ describe('createDowntimeDeps', () => { undefined, projectWorkspaceResolver as never, itemTabAnchorResolver as never, + dispatcherTabAnchorResolver as never, ); const cwd = makeTempDir(); @@ -1748,15 +1753,17 @@ describe('createDowntimeDeps', () => { expect(first.dispatched).toBe(true); expect(first.kind).toBe('audit'); expect(first.candidate?.id).toBe('WL-ONCE'); - // The resolved item-ID tab anchor pane id is forwarded to send-to-pi.sh as - // --anchor: the pane lands in the project workspace's WL-ONCE tab. The - // legacy single anchor is NOT used on this path. + // The resolved project-workspace item-ID tab anchor pane id is forwarded + // to send-to-pi.sh as --anchor: the pane lands in the project workspace's + // `WL-ONCE` tab. The per-prefix Dispatcher tab and the legacy single anchor + // are NOT used (WL-0MUR5FUWD00024XN AC2/AC4). expect(projectWorkspaceResolver).toHaveBeenCalledWith(cwd); expect(itemTabAnchorResolver).toHaveBeenCalledWith(cwd, 'wC', 'WL-ONCE'); + expect(dispatcherTabAnchorResolver).not.toHaveBeenCalled(); expect(anchorResolver).not.toHaveBeenCalled(); const spawnArgs = (spawnFn as ReturnType<typeof vi.fn>).mock.calls[0]?.[1] as string[] | undefined; expect(spawnArgs).toContain('--anchor'); - expect(spawnArgs).toContain('wC:pTEST'); + expect(spawnArgs).toContain('wC:tWL-ONCE:pTEST'); // The durable marker landed in the shared log (kind:audit). const entries = await readDowntimeLogEntries(cwd); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index c19fd10b1..48182836f 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -136,10 +136,12 @@ import { } from './scheduled-prompts.js'; import { getDispatcherAnchor as resolveDispatcherAnchor, + getDispatcherTabAnchor as resolveDispatcherTabAnchor, getItemTabAnchor as resolveItemTabAnchor, resolveProjectWorkspace as resolveProjectWorkspaceForRoot, createDispatcherAnchorDeps, type DispatcherAnchor, + type DispatcherPrefixTabAnchor, type ItemTabAnchor, type ProjectWorkspaceTarget, } from './dispatcher-anchor.js'; @@ -695,6 +697,33 @@ async function defaultProjectWorkspaceResolver( } } +/** + * Default per-prefix Dispatcher tab-anchor resolver used by + * {@link createDowntimeDeps} (C1, parent WL-0MTRQT482001SNXC): resolves or + * creates the tab labelled with the work-item id prefix (e.g. `WL`, `TCE`) + * inside the machine-wide Dispatcher workspace and returns its root pane. + * This is the FALLBACK placement path (AC3, WL-0MUR5FUWD00024XN) — it is only + * consulted when no project workspace resolves for the item's root. Null on + * any failure — the dispatch fails closed ('anchor-unavailable'), never a + * wrong-tab/workspace placement. Injectable for tests that build real deps + * without a live herdr session. + */ +async function defaultDispatcherTabAnchorResolver( + cwd: string, + prefix: string, +): Promise<DispatcherPrefixTabAnchor | null> { + try { + const herdrBin = process.env.HERDR_BIN_PATH ?? 'herdr'; + return await resolveDispatcherTabAnchor( + cwd, + createDispatcherAnchorDeps(cwd, herdrBin), + prefix, + ); + } catch { + return null; + } +} + /** * Default item-ID tab-anchor resolver used by {@link createDowntimeDeps} * (WL-0MU321YK70035AYT): ensures/reuses the tab labelled with the exact @@ -801,6 +830,11 @@ async function readClosedPaneIds(cwd: string): Promise<Set<string>> { * @param itemTabAnchorResolver Item-ID tab-anchor resolver * (WL-0MU321YK70035AYT). Defaults to the real herdr-CLI-backed resolver; * injectable for tests. + * @param dispatcherTabAnchorResolver Per-prefix Dispatcher tab-anchor + * resolver (C1, WL-0MTRQT482001SNXC). Defaults to the real herdr-CLI-backed + * resolver; injectable for tests. This is the FALLBACK placement path, used + * only when no project workspace resolves for the item's root + * (WL-0MUR5FUWD00024XN). */ export function createDowntimeDeps( scriptPath: string, @@ -813,6 +847,8 @@ export function createDowntimeDeps( defaultProjectWorkspaceResolver, itemTabAnchorResolver: DowntimeWorkerDeps['getItemTabAnchor'] = defaultItemTabAnchorResolver, + dispatcherTabAnchorResolver: DowntimeWorkerDeps['getDispatcherTabAnchor'] = + defaultDispatcherTabAnchorResolver, ): DowntimeWorkerDeps { // Shared round-robin registry (WL-0MSSRED76008LGB6): one per worklog root // (`<cwd>/.worklog/downtime-round-robin.json`), created lazily so each @@ -879,12 +915,20 @@ export function createDowntimeDeps( // Null → dispatch degrades to "no dispatch this cycle". Injected // (default = real herdr CLI) so tests can stub it. getDispatcherAnchor: anchorResolver, - // Project workspace + item-ID tab (WL-0MU321YK70035AYT): the worklog - // dispatch path resolves the project workspace hosting the item's root, - // then ensures/reuses the tab labelled with the exact work-item id and - // anchors the pane to that tab's root pane. A null workspace falls back to - // `getDispatcherAnchor`; a null item tab fails closed ('anchor-unavailable'). - // Injected (default = real herdr CLI). + // Per-prefix Dispatcher tab (C1, WL-0MTRQT482001SNXC): a FALLBACK worklog + // placement path, consulted only when `resolveProjectWorkspace` does not + // resolve a project workspace for the item's root (AC3, + // WL-0MUR5FUWD00024XN). Resolves/creates the tab labelled with the + // work-item id prefix (`WL`, `TCE`) inside the machine-wide Dispatcher + // workspace and anchors the pane to that tab's root pane. A null result + // fails closed ('anchor-unavailable') — never a legacy fallback. Injected + // (default = real herdr CLI). + getDispatcherTabAnchor: dispatcherTabAnchorResolver, + // Project workspace + item-ID tab (WL-0MU321YK70035AYT): the PRIMARY + // worklog placement path (AC2, WL-0MUR5FUWD00024XN). When a project + // plugin pane resolves for the item's root, the pane lands in that + // project's workspace under the exact work-item id tab; the Dispatcher + // workspace is not used. Injected (default = real herdr CLI). resolveProjectWorkspace: projectWorkspaceResolver, getItemTabAnchor: itemTabAnchorResolver, // Running-downtime-panes liveness (AC1/AC3, WL-0MTYZXSLN008HZOW): counts @@ -1827,7 +1871,16 @@ async function main(): Promise<void> { // (conservative `undefined` → PARALLELISM=1) and populated immediately // after, so later ticks read the real mode. mode: modeSwitchHolder.worker?.getLastKnownMode() ?? undefined, + // Drain-pause signal (parent WL-0MUL0KO7Q003O7YJ, F2 + // WL-0MUNMCZ2K003DZ50): while the mode-switch worker drains sessions + // down to the cheap-pool budget, the dispatcher pauses NEW panes. The + // holder is empty during synchronous construction (conservative + // `false` → dispatch unaffected), populated immediately after, so + // later ticks read the live drain state. + drainPaused: modeSwitchHolder.worker?.getIsDraining() ?? false, noCandidateCooldownMs: s.downtimeNoCandidateCooldownMs, + // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5). + nonTerminalCooldownMs: s.downtimeNonTerminalCooldownMs, // Dispatched success-marker staleness window (WL-0MU6UL0RJ008IHGT). markerStaleWindowMs: s.downtimeMarkerStaleWindowMs, browseItemCount: s.browseItemCount, diff --git a/packages/herdr/src/key-input.test.ts b/packages/herdr/src/key-input.test.ts new file mode 100644 index 000000000..7b0987c8f --- /dev/null +++ b/packages/herdr/src/key-input.test.ts @@ -0,0 +1,82 @@ +/** + * packages/herdr/src/key-input.test.ts — Tests for raw stdin chunk + * tokenisation (WL-0MTV67MZU003H7SH). + * + * Verifies that a chunk carrying several coalesced keystrokes (fast typing / + * paste) is split into every key, while multi-byte escape sequences are kept + * whole. This is the root fix for "missing keypresses when typing fast". + * + * Run: npx vitest run packages/herdr/src/key-input.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { splitKeypresses } from './key-input.js'; + +describe('splitKeypresses', () => { + it('returns an empty list for an empty chunk', () => { + expect(splitKeypresses('')).toEqual([]); + }); + + it('splits coalesced printable keystrokes into one token each', () => { + expect(splitKeypresses('hello')).toEqual(['h', 'e', 'l', 'l', 'o']); + }); + + it('preserves spaces and punctuation inside a coalesced chunk', () => { + expect(splitKeypresses('a b!c')).toEqual(['a', ' ', 'b', '!', 'c']); + }); + + it('treats a lone printable character as a single token', () => { + expect(splitKeypresses('x')).toEqual(['x']); + }); + + it('keeps a CSI arrow sequence as one token', () => { + expect(splitKeypresses('\x1b[A')).toEqual(['\x1b[A']); + expect(splitKeypresses('\x1b[B')).toEqual(['\x1b[B']); + }); + + it('keeps a CSI Ctrl+Enter sequence as one token', () => { + expect(splitKeypresses('\x1b[13;5u')).toEqual(['\x1b[13;5u']); + expect(splitKeypresses('\x1b[13;5~')).toEqual(['\x1b[13;5~']); + expect(splitKeypresses('\x1b[1;5A')).toEqual(['\x1b[1;5A']); + }); + + it('keeps bracketed-paste markers as one token each', () => { + expect(splitKeypresses('\x1b[200~')).toEqual(['\x1b[200~']); + expect(splitKeypresses('\x1b[201~')).toEqual(['\x1b[201~']); + }); + + it('keeps an SS3 sequence as one token', () => { + expect(splitKeypresses('\x1bOA')).toEqual(['\x1bOA']); + }); + + it('keeps an Alt/Meta two-byte sequence as one token', () => { + expect(splitKeypresses('\x1bm')).toEqual(['\x1bm']); + }); + + it('mixes printable text and escape sequences in order', () => { + expect(splitKeypresses('ab\x1b[Ac')).toEqual(['a', 'b', '\x1b[A', 'c']); + }); + + it('keeps control characters as individual tokens', () => { + expect(splitKeypresses('a\tb\x7fc')).toEqual(['a', '\t', 'b', '\x7f', 'c']); + }); + + it('keeps a trailing newline as its own token', () => { + expect(splitKeypresses('hi\r')).toEqual(['h', 'i', '\r']); + }); + + it('treats a lone trailing ESC as the Esc key token', () => { + expect(splitKeypresses('a\x1b')).toEqual(['a', '\x1b']); + }); + + it('returns an incomplete CSI remainder as one token (never drops bytes)', () => { + expect(splitKeypresses('\x1b[')).toEqual(['\x1b[']); + expect(splitKeypresses('a\x1b[3')).toEqual(['a', '\x1b[3']); + }); + + it('round-trips: re-joining the tokens reproduces the chunk', () => { + for (const chunk of ['hello', 'ab\x1b[Ac', '\x1b[13;5uok\r', '\x1b[200~x\ny\x1b[201~']) { + expect(splitKeypresses(chunk).join('')).toBe(chunk); + } + }); +}); diff --git a/packages/herdr/src/key-input.ts b/packages/herdr/src/key-input.ts new file mode 100644 index 000000000..4fa06ffd8 --- /dev/null +++ b/packages/herdr/src/key-input.ts @@ -0,0 +1,102 @@ +/** + * packages/herdr/src/key-input.ts — Raw stdin chunk tokenisation + * + * A terminal in raw mode does not deliver exactly one keypress per + * `process.stdin` `data` event. When the user types quickly (or a paste + * arrives), the PTY coalesces several bytes into a single chunk. Treating + * that whole chunk as one "key" silently drops every character after the + * first — the "missing keypresses when typing fast" bug + * (WL-0MTV67MZU003H7SH). + * + * {@link splitKeypresses} turns a raw chunk into an ordered list of + * individual key tokens. Escape sequences (CSI / SS3 / Alt) are kept intact + * as a single token so existing `key === '\x1b[A'`-style checks keep + * working; every other code point becomes its own token. + * + * This is deliberately a pure, dependency-free module so every text-input + * consumer (the command form and the Ship It dialog) shares exactly one + * tokeniser instead of each re-implementing (and mis-handling) chunk + * splitting. + */ + +/** + * Split a raw stdin chunk into individual keypress tokens. + * + * Handled escape forms (kept as one token): + * - CSI: `ESC [ parameters? intermediates? final` — arrows + * (`\x1b[A`), PageUp/Down (`\x1b[5~`), Ctrl+Enter (`\x1b[13;5u`), + * bracketed-paste markers (`\x1b[200~` / `\x1b[201~`), SGR mouse. + * - SS3: `ESC O final` — some terminals' arrow / function keys. + * - Alt/Meta: `ESC <char>` — e.g. Alt+m (`\x1bm`). + * + * Every other code point (printable text, control characters, a lone + * trailing `ESC`) is returned as its own single-character token so + * coalesced fast typing is preserved character-for-character. + * + * An escape sequence left incomplete at the end of the chunk (the PTY split + * it across reads) is returned as a single remainder token rather than + * being discarded, so no bytes are lost. + * + * @param chunk - Raw stdin chunk (`Buffer.toString()` output). + * @returns Ordered key tokens; empty only when `chunk` is empty. + */ +export function splitKeypresses(chunk: string): string[] { + const keys: string[] = []; + let i = 0; + while (i < chunk.length) { + const ch = chunk[i]; + + // Ordinary code point (including control chars and a lone ESC that is + // the final character of the chunk). + if (ch !== '\x1b') { + keys.push(ch); + i += 1; + continue; + } + + const next = chunk[i + 1]; + // Lone trailing ESC — the Esc key itself. + if (next === undefined) { + keys.push(ch); + i += 1; + continue; + } + + // CSI: ESC '[' params (0x30–0x3F) intermediates (0x20–0x2F) final (0x40–0x7E). + if (next === '[') { + let j = i + 2; + while (j < chunk.length) { + const code = chunk.charCodeAt(j); + if ((code >= 0x30 && code <= 0x3f) || (code >= 0x20 && code <= 0x2f)) { + j += 1; + continue; + } + break; + } + if (j < chunk.length) { + const final = chunk.charCodeAt(j); + if (final >= 0x40 && final <= 0x7e) { + keys.push(chunk.slice(i, j + 1)); + i = j + 1; + continue; + } + } + // Incomplete CSI (split across reads): keep the remainder whole so an + // owning parser can buffer it — never drop bytes. + keys.push(chunk.slice(i)); + break; + } + + // SS3: ESC 'O' final (e.g. some terminals' arrow / function keys). + if (next === 'O' && i + 2 < chunk.length) { + keys.push(chunk.slice(i, i + 3)); + i += 3; + continue; + } + + // Alt/Meta: ESC followed by a single character. + keys.push(chunk.slice(i, i + 2)); + i += 2; + } + return keys; +} diff --git a/packages/herdr/src/mode-switch-worker.test.ts b/packages/herdr/src/mode-switch-worker.test.ts index ef6a9c0e3..d1265f288 100644 --- a/packages/herdr/src/mode-switch-worker.test.ts +++ b/packages/herdr/src/mode-switch-worker.test.ts @@ -429,6 +429,165 @@ describe('cheap switch trigger', () => { }); }); +// ── Drain before cheap switch (parent WL-0MUL0KO7Q003O7YJ) ───────────── + +describe('drain before cheap switch', () => { + it('getIsDraining() starts false', () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + expect(worker.getIsDraining()).toBe(false); + }); + + it('idle + proxy idle but fewer than the cheap budget free → enters drain with no switch', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + // 1 free of 3: the per-slot idle-entry gate passes (≥1 free) but the + // cheap pool's drain budget (≥2 free) is not yet met. + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + expect(worker.getIsDraining()).toBe(true); + // Mode tracking is unaffected by the drain state. + expect(worker.getLastKnownMode()).toBe('fast'); + }); + + it('drain completes once the cheap budget frees → cheap switch, draining clears', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(true); + + // A slot frees: now 2 of 3 free → the drain completes and switches. + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotDrainComplete(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(false); + expect(setModePosts(api)).toHaveLength(1); + expect(setModePosts(api)[0].body).toBe(JSON.stringify({ mode: 'cheap' })); + expect(worker.getLastKnownMode()).toBe('cheap'); + }); + + it('stays draining across multiple ticks while the cheap budget is unmet', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + for (let i = 0; i < 3; i += 1) { + advance(10_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(true); + } + expect(setModePosts(api)).toHaveLength(0); + }); + + it('an operator command during drain cancels it and resets the idle clock (proxy stays fast)', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(true); + + // Operator returns: the drain is cancelled and the idle clock resets to + // "active now" (the fast-mode dedup skips a redundant fast POST). + api.reset(); + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + expect(worker.getIsDraining()).toBe(false); + + // The fresh idle window means the very next tick does not re-enter drain. + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(false); + expect(setModePosts(api)).toHaveLength(0); + }); + + it('fail-closed: a proxy status failure during drain does not exit drain and never switches', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(true); + + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: null, // endpoint failure / timeout / ambiguous + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(true); // no drain exit + expect(setModePosts(api)).toHaveLength(0); + }); + + it('a non-leader never enters drain', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ + fetcher: api.fetcher, + now, + isLeader: () => false, + }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(false); + expect(setModePosts(api)).toHaveLength(0); + }); +}); + // ── Fail-closed paths ──────────────────────────────────────────────── describe('fail-closed paths', () => { @@ -750,8 +909,30 @@ function perSlotOperatorFree(overrides: Partial<LlamaStatus> = {}): LlamaStatus }); } +/** + * Per-slot payload with 2 of 3 slots free — the cheap pool's spare capacity + * (parent WL-0MUL0KO7Q003O7YJ): one downtime pane holds a busy slot, but the + * two free slots satisfy the drain-completion budget, so the idle cheap + * switch fires. + */ +function perSlotDrainComplete(overrides: Partial<LlamaStatus> = {}): LlamaStatus { + return perSlotStatus({ + active_query: true, + local_active_query: true, + 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 }, + ], + ...overrides, + }); +} + describe('per-slot operator gate', () => { - it('AC1: per-slot payload, ≥1 free slot → posts cheap even while the other two slots are busy (downtime panes)', async () => { + it('AC1: per-slot payload, ≥2 free slots → posts cheap even while another slot is busy (downtime panes)', async () => { clock = 1_000_000; const api = mockAdminApi(); const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); @@ -760,7 +941,9 @@ describe('per-slot operator gate', () => { enabled: true, idleThresholdMs: 900_000, proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), + // Drain-complete: the cheap pool's 2 free slots are available even + // though a downtime pane holds the third slot busy. + proxyStatus: perSlotDrainComplete(), }); await flushAsync(); expect(setModePosts(api)).toHaveLength(1); @@ -789,22 +972,23 @@ describe('per-slot operator gate', () => { expect(setModePosts(api)).toHaveLength(0); }); - it('AC3: busy slots with ≥1 free slot → switch fires (spare-capacity semantics)', async () => { + it('AC3: busy slots with ≥2 free slots → switch fires (spare-capacity semantics)', async () => { clock = 1_000_000; const api = mockAdminApi(); const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); advance(900_000); - // 1 free of 3, without the global query/lease signals: the per-slot - // spare-capacity gate (≥1 free) must still fire. + // 2 free of 3, without the global query/lease signals: the per-slot + // spare-capacity gate (busy slot does not block) plus the drain-complete + // budget (≥2 free) fires the switch. await worker.tick({ enabled: true, idleThresholdMs: 900_000, proxyUrl: 'http://proxy', proxyStatus: perSlotStatus({ - available_slots: 1, + available_slots: 2, slots: [ { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, { slot_id: 'slot-3', is_processing: false }, ], }), diff --git a/packages/herdr/src/mode-switch-worker.ts b/packages/herdr/src/mode-switch-worker.ts index 6b3fc3e25..cdad7bdb8 100644 --- a/packages/herdr/src/mode-switch-worker.ts +++ b/packages/herdr/src/mode-switch-worker.ts @@ -25,12 +25,20 @@ * max of the local clock and the shared timestamp, so the proxy stays fast * while **any** pane has recent activity. A read/write failure falls back * to the local clock (AC6, fail-closed: never a premature cheap switch). - * - **Cheap switch on idle:** on each tick (scheduler task, or the downtime + * - **Cheap switch on idle (drain before switching, parent + * WL-0MUL0KO7Q003O7YJ):** on each tick (scheduler task, or the downtime * dispatcher's `onProxyIdle` callback — the leader is the only pane that * runs that dispatcher), when the operator has been idle * (no agent-route commands on any pane) for ≥ `modeSwitchIdleThresholdMs` * **AND** the proxy reports idle (reusing `evaluateIdle` from - * downtime-worker.ts), the worker POSTs `/admin/set-mode {"mode":"cheap"}`. + * downtime-worker.ts), the worker drains active sessions down to the + * cheap pool's spare capacity before POSTing + * `/admin/set-mode {"mode":"cheap"}`. The drain completes once + * `evaluateIdle(status, MODE_SWITCH_DRAIN_FREE_SLOTS)` holds (≥ 2 free + * slots); until then the worker reports `getIsDraining() === true` so the + * downtime dispatcher pauses NEW dispatches (existing in-flight work is + * never killed). An operator agent-route command cancels the drain and + * resets the idle clock (the proxy stays fast). * **Per-slot operator gate (parent WL-0MT9F67Y3008S0PR, decision 1.a):** * when the proxy serves per-slot identity (`slots[]` valid per * `parseLlamaStatus`), the idle gate requires only ≥ 1 free slot @@ -38,13 +46,14 @@ * semantics shared with the downtime dispatcher, whose autonomous * query/lease work holds the OTHER busy slots. A downtime pane is exactly * the work the operator wants to keep running while the proxy runs cheap, - * so the busy slots must **not** delay the switch (Q4b decision; - * accepted tradeoff: a fast-mode downtime request in flight during the - * mode restart is killed and retried by its client). Without per-slot - * data the all-slots-free fail-closed fallback is unchanged - * (`evaluateIdle(status, 0)` — full global checks). In every case a busy - * proxy (server down, model switch, or 0 free slots) **delays** the - * switch — it never kills in-flight work. + * so the busy slots must **not** block the idle *entry* gate (Q4b + * decision; accepted tradeoff: a fast-mode downtime request in flight + * during the mode restart is killed and retried by its client). The drain + * *completion* gate then still requires the cheap pool's 2 free slots. + * Without per-slot data the all-slots-free fail-closed entry fallback is + * unchanged (`evaluateIdle(status, 0)` — full global checks). In every + * case a busy proxy (server down, model switch, or 0 free slots) + * **delays** the switch — it never kills in-flight work. * - **No redundant switching / no hammering:** the worker skips a switch when * the persisted mode already matches the target (tracking last-known mode, * refreshed via `GET /admin/mode` on each poll). At most one switch per @@ -80,6 +89,7 @@ import { join } from 'node:path'; import { evaluateIdle, parseLlamaStatus, + DOWNTIME_AUDIT_MIN_FREE_SLOTS, DOWNTIME_PANE_MIN_FREE_SLOTS, type LlamaStatus, } from './downtime-worker.js'; @@ -96,6 +106,15 @@ export const ADMIN_SET_MODE_PATH = '/admin/set-mode'; /** Default idle window before switching to cheap mode: 60 minutes. */ export const DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS = 3_600_000; +/** + * Free slots the proxy must report before the idle cheap switch completes. + * Reuses the audit-dispatch slot budget (`DOWNTIME_AUDIT_MIN_FREE_SLOTS` = 2) + * as the cheap-mode pool size (parent WL-0MUL0KO7Q003O7YJ): the worker drains + * active sessions down to this spare-capacity level before switching, so the + * two-slot cheap pool always has room for the operator to return. + */ +export const MODE_SWITCH_DRAIN_FREE_SLOTS = DOWNTIME_AUDIT_MIN_FREE_SLOTS; + /** * Defensive floor for the idle threshold (60s): a trivially small window * would defeat the operator-presence gating (never switch to cheap right @@ -373,8 +392,9 @@ export type Now = () => number; export interface ModeSwitchWorker { /** * Record that the operator issued an agent-route command through the - * plugin. Updates the idle clock and (fire-and-forget) POSTs - * `/admin/set-mode {"mode":"fast"}` when the last known mode is not + * plugin. Updates the idle clock, cancels any in-progress drain (the + * operator is back — the cheap switch is abandoned), and (fire-and-forget) + * POSTs `/admin/set-mode {"mode":"fast"}` when the last known mode is not * already fast. Never blocks or delays the command dispatch (fail-open). * `proxyUrl` is the shared `downtimeProxyUrl`. */ @@ -394,6 +414,15 @@ export interface ModeSwitchWorker { * mode (`null`) conservatively maps to the serial `'1'` default. */ getLastKnownMode(): ProxyMode | null; + /** + * Whether the worker is currently draining active sessions before the idle + * cheap switch (parent WL-0MUL0KO7Q003O7YJ): true once the idle window is + * met and the proxy is idle but fewer than the cheap pool's free slots are + * available, cleared once the switch completes or an operator command + * arrives. The downtime dispatcher reads this to pause new dispatches while + * draining (WL-0MUNMCZ2K003DZ50). + */ + getIsDraining(): boolean; } /** @@ -455,6 +484,16 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit let lastKnownMode: ProxyMode | null = null; /** True while a mode-switch POST is in flight (per-process single-flight). */ let switchInFlight = false; + /** + * True while draining active sessions before the idle cheap switch: the + * idle window is met, the proxy is idle, but fewer than + * `MODE_SWITCH_DRAIN_FREE_SLOTS` slots are free. While draining the worker + * keeps polling (it does not kill in-flight work) and the downtime + * dispatcher pauses NEW dispatches so the slots can fall to the cheap + * pool's budget. Cleared on switch completion, operator command, or a + * resumed idle window (parent WL-0MUL0KO7Q003O7YJ). + */ + let draining = false; /** * Fire-and-forget mode switch with per-process single-flight and @@ -482,6 +521,10 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit return { onOperatorCommand(proxyUrl: string): void { + // The operator is back: an in-progress drain is abandoned (the cheap + // switch is cancelled) and the idle clock resets to "active now" + // (parent WL-0MUL0KO7Q003O7YJ — operator-command cancellation). + draining = false; // Record operator activity (agent-route command) on the local clock. lastOperatorCommandAt = now(); // Broadcast to every pane — leader and non-leader alike — so the @@ -502,6 +545,10 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit return lastKnownMode; }, + getIsDraining(): boolean { + return draining; + }, + async tick(opts: ModeSwitchTickOptions): Promise<void> { if (!opts.enabled) return; // Leader-only cheap switching (AC1): non-leader instances skip the @@ -517,7 +564,12 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit // clock can never be null — restart resets to "active now", so a fresh // full window is always required.) const elapsed = now() - effectiveLastActivityAt(); - if (elapsed < opts.idleThresholdMs) return; + if (elapsed < opts.idleThresholdMs) { + // Activity resumed (local or shared cross-pane command): any + // in-progress drain is abandoned and the cheap switch is cancelled. + draining = false; + return; + } // Fetch proxy status when not provided (the worker fetches its own // idle state from the proxy so the caller doesn't need to). null @@ -551,9 +603,25 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit lastKnownMode = currentMode; } // Skip when the persisted mode already matches the target (or is - // unknown/read-unreachable — fail-closed no-op). - if (lastKnownMode === 'cheap' || lastKnownMode === null) return; + // unknown/read-unreachable — fail-closed no-op). A no-op also clears + // any stale drain state. + if (lastKnownMode === 'cheap' || lastKnownMode === null) { + draining = false; + return; + } + + // Drain before switching (parent WL-0MUL0KO7Q003O7YJ): the cheap pool + // holds `MODE_SWITCH_DRAIN_FREE_SLOTS` (2) slots, so wait until that + // many slots are free before switching. If the budget is already + // available the switch fires on this tick; otherwise the worker enters + // (or stays in) the draining state and the downtime dispatcher pauses + // new work (WL-0MUNMCZ2K003DZ50) so the active sessions can subside. + if (!evaluateIdle(proxyStatus, MODE_SWITCH_DRAIN_FREE_SLOTS)) { + draining = true; + return; + } + draining = false; await fireSwitch(opts.proxyUrl, 'cheap'); }, }; diff --git a/packages/herdr/src/pane-close-cli.test.ts b/packages/herdr/src/pane-close-cli.test.ts new file mode 100644 index 000000000..cfc7de5e6 --- /dev/null +++ b/packages/herdr/src/pane-close-cli.test.ts @@ -0,0 +1,158 @@ +/** + * packages/herdr/src/pane-close-cli.test.ts — Tests for the standalone + * pane-close bridge CLI (WL-0MUJMXVPO0016DZM). + * + * The bridge is the cross-language seam consumed by the Python pane-triage + * skill. These tests pin the two behaviours the skill depends on: + * - JSON output mode emits a single parseable document per pane, + * - fixture mode classifies offline (no herdr / wl subprocesses). + */ +import { describe, expect, it, vi } from 'vitest'; + +import { extractFixtureArg, extractJsonValue, main } from './pane-close-cli'; + +describe('extractFixtureArg', () => { + it('separates --fixture from pass-through flags', () => { + const { fixture, rest } = extractFixtureArg([ + '--fixture', + '/tmp/fixture.json', + '--json', + '--dry-run', + ]); + expect(fixture).toBe('/tmp/fixture.json'); + expect(rest).toEqual(['--json', '--dry-run']); + }); + + it('returns undefined when no fixture flag is present', () => { + const { fixture, rest } = extractFixtureArg(['--json']); + expect(fixture).toBeUndefined(); + expect(rest).toEqual(['--json']); + }); +}); + +describe('extractJsonValue', () => { + it('parses a JSON object prefixed by log lines', () => { + expect(extractJsonValue('log line\n{"a":1}')).toEqual({ a: 1 }); + }); + + it('parses a bare array', () => { + expect(extractJsonValue('[1,2]')).toEqual([1, 2]); + }); + + it('returns null when no JSON exists', () => { + expect(extractJsonValue('nothing here')).toBeNull(); + }); +}); + +describe('main — fixture mode', () => { + it('classifies offline and emits JSON to stdout', async () => { + const fixture = JSON.stringify({ + result: { + panes: [ + { + pane_id: 'w1:p1', + label: 'Downtime triggered plan Foo - WL-0ABC123', + agent: 'pi', + agent_status: 'done', + workspace_id: 'w1', + agent_session: { value: '/tmp/nonexistent-session.jsonl' }, + }, + ], + }, + }); + const logged: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + logged.push(line); + }); + try { + const code = await main(['--fixture', '/tmp/f.json', '--json'], { + readFile: () => fixture, + env: {}, + cwd: '/tmp', + }); + expect(code).toBe(0); + } finally { + logSpy.mockRestore(); + } + const output = JSON.parse(logged.join('\n')); + expect(output.panes).toHaveLength(1); + expect(output.panes[0]).toMatchObject({ + paneId: 'w1:p1', + itemId: 'WL-0ABC123', + workspaceId: 'w1', + kind: 'plan', + }); + // The invoking pane id from the environment is surfaced in the envelope. + expect(output.invokingPaneId).toBe(null); + expect(typeof output.timestamp).toBe('string'); + }); + + it('surfaces HERDR_PANE_ID as the invoking pane in the JSON envelope', async () => { + const fixture = JSON.stringify({ + panes: [ + { + pane_id: 'w1:p1', + label: 'Downtime triggered plan Foo - WL-0ABC123', + agent: 'pi', + agent_status: 'idle', + workspace_id: 'w1', + agent_session: { value: '/tmp/nonexistent.jsonl' }, + }, + ], + }); + const logged: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + logged.push(line); + }); + try { + await main(['--fixture', '/tmp/f.json', '--json'], { + readFile: () => fixture, + env: { HERDR_PANE_ID: 'w1:pZ' }, + cwd: '/tmp', + }); + } finally { + logSpy.mockRestore(); + } + const output = JSON.parse(logged.join('\n')); + expect(output.invokingPaneId).toBe('w1:pZ'); + }); + + it('applies the --workspace filter to fixture panes', async () => { + const fixture = JSON.stringify({ + panes: [ + { + pane_id: 'w1:p1', + label: 'Downtime triggered plan A - WL-1', + agent: 'pi', + agent_status: 'idle', + workspace_id: 'w1', + agent_session: { value: '/tmp/nonexistent.jsonl' }, + }, + { + pane_id: 'w2:p2', + label: 'Downtime triggered plan B - WL-2', + agent: 'pi', + agent_status: 'idle', + workspace_id: 'w2', + agent_session: { value: '/tmp/nonexistent.jsonl' }, + }, + ], + }); + const logged: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + logged.push(line); + }); + try { + await main(['--fixture', '/tmp/f.json', '--json', '--workspace', 'w1'], { + readFile: () => fixture, + env: {}, + cwd: '/tmp', + }); + } finally { + logSpy.mockRestore(); + } + const output = JSON.parse(logged.join('\n')); + expect(output.panes).toHaveLength(1); + expect(output.panes[0].paneId).toBe('w1:p1'); + }); +}); diff --git a/packages/herdr/src/pane-close-cli.ts b/packages/herdr/src/pane-close-cli.ts new file mode 100644 index 000000000..f32a79841 --- /dev/null +++ b/packages/herdr/src/pane-close-cli.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * packages/herdr/src/pane-close-cli.ts — Standalone pane-close CLI + * + * Wires the production `ReaperDeps` (herdr pane listing + close, `wl show` + * producer-review lookup, process-group teardown) and invokes the shared + * `runReaperCli`. This is the cross-language bridge consumed by the Python + * `pane-triage` skill (`WL-0MUJMXVPO0016DZM`, intake Q-OPEN-1 Option 2): + * the skill shells out to this compiled CLI with `--json` and parses the + * classification report, so the idle-state logic is never reimplemented. + * + * Offline mode: `--fixture <path>` supplies a raw `herdr pane list` JSON + * document instead of shelling out to herdr. This lets the skill's offline + * fixture tests exercise the *shared* classifier end-to-end without a live + * herdr or wl. In fixture mode the producer-review lookup defaults to false + * and closing is a recorded no-op. + * + * Exit code mirrors `runReaperCli`: 0 on success, 1 on any close failure. + */ + +import { execFile } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +import { createHerdrReaperDeps } from './pane-close-herdr.js'; +import type { ReaperDeps } from './pane-close-reaper.js'; +import { runReaperCli } from './pane-close-reaper.js'; + +const execFileAsync = promisify(execFile); + +/** Default per-command timeout for herdr / wl calls (60 s). */ +export const CLI_COMMAND_TIMEOUT_MS = 60 * 1000; + +/** Injectable I/O, so tests can exercise the CLI without real processes. */ +export interface PaneCloseCliIo { + execFileAsync: typeof execFileAsync; + readFile(path: string): string; + env: NodeJS.ProcessEnv; + cwd: string; +} + +/** Split `--fixture <path>` out of argv; the remainder is passed through. */ +export function extractFixtureArg(argv: string[]): { fixture?: string; rest: string[] } { + const rest: string[] = []; + let fixture: string | undefined; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--fixture') { + fixture = argv[++i]; + } else { + rest.push(argv[i]); + } + } + return { fixture, rest }; +} + +/** + * Extract the first JSON value from a string that may be prefixed by log + * lines (herdr/wl occasionally emit non-JSON before the payload). + */ +export function extractJsonValue(raw: string): unknown { + const brace = raw.indexOf('{'); + const bracket = raw.indexOf('['); + let start: number; + if (brace < 0 && bracket < 0) return null; + else if (brace < 0) start = bracket; + else if (bracket < 0) start = brace; + else start = Math.min(brace, bracket); + try { + return JSON.parse(raw.slice(start)); + } catch { + return null; + } +} + +/** + * Build the production `ReaperDeps`. When `fixtureRaw` is supplied the herdr + * and wl calls are replaced by offline stubs so tests exercise the shared + * classifier without side effects. + */ +export function createPaneCloseCliDeps(io: PaneCloseCliIo, fixtureRaw?: string): ReaperDeps { + const herdrBin = io.env.HERDR_BIN_PATH ?? 'herdr'; + const invokingPaneId = io.env.HERDR_PANE_ID ?? io.env.HERDR_PANE ?? undefined; + + if (fixtureRaw !== undefined) { + return createHerdrReaperDeps({ + listPanesRaw: async () => fixtureRaw, + closePane: async () => true, // offline: recorded no-op + invokingPaneId, + }); + } + + return createHerdrReaperDeps({ + listPanesRaw: async () => { + const { stdout } = await io.execFileAsync(herdrBin, ['pane', 'list'], { + encoding: 'utf8', + timeout: CLI_COMMAND_TIMEOUT_MS, + maxBuffer: 8 * 1024 * 1024, + cwd: io.cwd, + }); + return stdout; + }, + closePane: async (paneId: string) => { + try { + await io.execFileAsync(herdrBin, ['pane', 'close', paneId], { + encoding: 'utf8', + timeout: CLI_COMMAND_TIMEOUT_MS, + cwd: io.cwd, + }); + return true; + } catch { + return false; // fail-closed: a failed close is recorded, never thrown + } + }, + invokingPaneId, + getNeedsProducerReview: async (itemId: string) => { + const { stdout } = await io.execFileAsync('wl', ['show', itemId, '--json'], { + encoding: 'utf8', + timeout: CLI_COMMAND_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + cwd: io.cwd, + }); + const parsed = extractJsonValue(stdout) as { + workItem?: { needsProducerReview?: boolean }; + }; + // Fail-closed: an unreadable item is treated as review-blocked. + return parsed?.workItem?.needsProducerReview === true; + }, + }); +} + +/** Entry point. Returns the process exit code. */ +export async function main( + argv: string[] = process.argv.slice(2), + io?: Partial<PaneCloseCliIo>, +): Promise<number> { + const { fixture, rest } = extractFixtureArg(argv); + const resolvedIo: PaneCloseCliIo = { + execFileAsync: io?.execFileAsync ?? execFileAsync, + readFile: io?.readFile ?? ((path: string) => readFileSync(path, 'utf8')), + env: io?.env ?? process.env, + cwd: io?.cwd ?? process.cwd(), + }; + const fixtureRaw = fixture !== undefined ? resolvedIo.readFile(fixture) : undefined; + // Surface the invoking pane id in the JSON envelope so the Python skill can + // exclude it even though the classifier also computes `isInvokingPane`. + const invoking = resolvedIo.env.HERDR_PANE_ID ?? resolvedIo.env.HERDR_PANE; + const args = [...rest]; + if (invoking && !args.includes('--invoking-pane')) { + args.push('--invoking-pane', invoking); + } + const deps = createPaneCloseCliDeps(resolvedIo, fixtureRaw); + return runReaperCli(deps, args); +} + +const invokedDirectly = + typeof process.argv[1] === 'string' && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (invokedDirectly) { + main() + .then((code) => { + process.exitCode = code; + }) + .catch((err) => { + process.stderr.write(`pane-close-cli: ${err instanceof Error ? err.message : String(err)}\n`); + process.exitCode = 1; + }); +} diff --git a/packages/herdr/src/pane-close-herdr.test.ts b/packages/herdr/src/pane-close-herdr.test.ts index e4bdc293f..524645acd 100644 --- a/packages/herdr/src/pane-close-herdr.test.ts +++ b/packages/herdr/src/pane-close-herdr.test.ts @@ -12,6 +12,7 @@ import { paneKindFromLabel, paneItemIdFromLabel, readFinalAssistantEntries, + readSessionTailLines, createHerdrReaperDeps, } from './pane-close-herdr'; @@ -47,6 +48,20 @@ describe('parseHerdrPaneCloseList', () => { expect(parseHerdrPaneCloseList(raw)![0].paneId).toBe('p1'); }); + it('parses workspace_id (WL-0MUJMXVPO0016DZM AC1)', () => { + const raw = JSON.stringify({ + panes: [{ pane_id: 'w2V:p1', label: 'x', workspace_id: 'w2V' }], + }); + expect(parseHerdrPaneCloseList(raw)![0].workspaceId).toBe('w2V'); + }); + + it('parses tab_id (WL-0MUJMXVPO0016DZM AC3)', () => { + const raw = JSON.stringify({ + panes: [{ pane_id: 'w2V:p1', label: 'x', tab_id: 'w2V:tT' }], + }); + expect(parseHerdrPaneCloseList(raw)![0].tabId).toBe('w2V:tT'); + }); + it('tolerates log lines before the JSON envelope', () => { const raw = 'some log line\n' + JSON.stringify({ panes: [{ pane_id: 'p1' }] }); expect(parseHerdrPaneCloseList(raw)![0].paneId).toBe('p1'); @@ -129,6 +144,41 @@ describe('readFinalAssistantEntries', () => { }); }); +describe('readSessionTailLines (WL-0MUJMXVPO0016DZM AC4)', () => { + function withTempFile(contents: string, fn: (path: string) => void): void { + const dir = mkdtempSync(join(tmpdir(), 'pane-tail-')); + const path = join(dir, 'session.jsonl'); + try { + writeFileSync(path, contents, 'utf-8'); + fn(path); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it('returns the last N assistant lines', () => { + const lines: string[] = []; + for (let i = 1; i <= 30; i++) { + lines.push( + JSON.stringify({ + type: 'message', + message: { role: 'assistant', content: [{ type: 'text', text: `line ${i}` }] }, + }), + ); + } + withTempFile(lines.join('\n') + '\n', (path) => { + const tail = readSessionTailLines(path, 20); + expect(tail).toHaveLength(20); + expect(tail[0]).toBe('line 11'); + expect(tail[19]).toBe('line 30'); + }); + }); + + it('returns [] for an unreadable file', () => { + expect(readSessionTailLines('/nonexistent/session.jsonl')).toEqual([]); + }); +}); + describe('createHerdrReaperDeps', () => { it('maps pi panes with sessions into PaneStatus and skips session-less panes', async () => { const raw = JSON.stringify({ @@ -162,6 +212,27 @@ describe('createHerdrReaperDeps', () => { }); }); + it('maps workspace_id into PaneStatus.workspaceId (WL-0MUJMXVPO0016DZM AC1)', async () => { + const raw = JSON.stringify({ + panes: [ + { + pane_id: 'w2V:p1', + label: 'Downtime triggered plan Foo - WL-0ABC123', + agent: 'pi', + agent_status: 'idle', + workspace_id: 'w2V', + agent_session: { value: '/tmp/nonexistent-but-mapped.jsonl' }, + }, + ], + }); + const deps = createHerdrReaperDeps({ + listPanesRaw: vi.fn().mockResolvedValue(raw), + closePane: vi.fn().mockResolvedValue(true), + }); + const panes = await deps.listPanes(); + expect(panes[0].workspaceId).toBe('w2V'); + }); + it('treats an unreadable producer-review lookup as review-blocked (fail-closed)', async () => { const raw = JSON.stringify({ panes: [ diff --git a/packages/herdr/src/pane-close-herdr.ts b/packages/herdr/src/pane-close-herdr.ts index 06daca943..c0a686008 100644 --- a/packages/herdr/src/pane-close-herdr.ts +++ b/packages/herdr/src/pane-close-herdr.ts @@ -32,6 +32,10 @@ export interface HerdrPaneCloseRecord { agent?: string; agent_status?: string; agentStatus?: string; + workspace_id?: string; + workspaceId?: string; + tab_id?: string; + tabId?: string; agent_session?: HerdrAgentSession; agentSession?: HerdrAgentSession; } @@ -43,6 +47,8 @@ export interface ParsedHerdrPane { agent?: string; agentStatus?: string; sessionPath?: string; + workspaceId?: string; + tabId?: string; } /** @@ -101,6 +107,18 @@ export function parseHerdrPaneCloseList(raw: string): ParsedHerdrPane[] | null { agent: typeof rec.agent === 'string' ? rec.agent : undefined, agentStatus, sessionPath, + workspaceId: + typeof rec.workspace_id === 'string' + ? rec.workspace_id + : typeof rec.workspaceId === 'string' + ? rec.workspaceId + : undefined, + tabId: + typeof rec.tab_id === 'string' + ? rec.tab_id + : typeof rec.tabId === 'string' + ? rec.tabId + : undefined, }); } return parsed; @@ -150,6 +168,9 @@ export interface SessionEntry { /** Default tail window when reading a session log (512 KiB). */ export const SESSION_TAIL_BYTES = 512 * 1024; +/** Default number of tail lines surfaced to the pane-triage skill. */ +export const DEFAULT_SESSION_TAIL_LINES = 20; + /** * Read the final assistant message from a pi session JSONL file by reading * only the tail of the file. Parses the pi entry shape: each line is a JSON @@ -220,6 +241,72 @@ export function readFinalAssistantEntries( return entries; } +/** + * Read the last *tailLines* human-readable lines from a pi session JSONL file. + * + * Populates the per-pane `sessionTail` in the JSON report so the pane-triage + * skill can show the producer the last 20 lines under the + * "<pane title> Needs Review <true|false>" heading + * (`WL-0MUJMXVPO0016DZM`). Fail-closed: an unreadable file yields `[]`. + */ +export function readSessionTailLines( + sessionPath: string, + tailLines = DEFAULT_SESSION_TAIL_LINES, + maxBytes = SESSION_TAIL_BYTES, +): string[] { + let raw: string; + try { + const size = statSync(sessionPath).size; + const start = Math.max(0, size - maxBytes); + const length = size - start; + const fd = openSync(sessionPath, 'r'); + try { + const buffer = Buffer.alloc(length); + readSync(fd, buffer, 0, length, start); + raw = buffer.toString('utf-8'); + } finally { + closeSync(fd); + } + } catch { + return []; // fail-closed + } + + const lines = raw.split('\n'); + const startIndex = raw.length >= maxBytes ? 1 : 0; + const output: string[] = []; + for (let i = startIndex; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '') continue; + let obj: unknown; + try { + obj = JSON.parse(line); + } catch { + continue; // skip partial/malformed lines + } + if (!obj || typeof obj !== 'object') continue; + const rec = obj as Record<string, unknown>; + if (typeof rec.type === 'string' && typeof rec.text === 'string') { + output.push(rec.text); + continue; + } + const message = rec.message; + if (!message || typeof message !== 'object') continue; + const msg = message as Record<string, unknown>; + const content = msg.content; + if (!Array.isArray(content)) continue; + const text = content + .filter( + (c): c is { type?: string; text?: string } => + !!c && typeof c === 'object' && typeof (c as { text?: unknown }).text === 'string', + ) + .map((c) => c.text ?? '') + .join('') + .trim(); + if (text !== '') output.push(text); + } + return output.slice(-tailLines); +} + // ── Production deps factory ─────────────────────────────────────────── /** Injectable I/O for the production deps (tests/other callers may override). */ @@ -279,6 +366,7 @@ export function createHerdrReaperDeps(io: HerdrReaperIo): ReaperDeps { if (!pane.sessionPath) continue; const entries = readFinalAssistantEntries(pane.sessionPath); + const sessionTail = readSessionTailLines(pane.sessionPath); let idleMs = 0; let ageSinceDispatchMs: number | undefined; try { @@ -314,8 +402,11 @@ export function createHerdrReaperDeps(io: HerdrReaperIo): ReaperDeps { kind: paneKindFromLabel(pane.label), itemId, title: pane.label || pane.paneId, + workspaceId: pane.workspaceId, + tabId: pane.tabId, lastAssistantText: '', sessionEntries: entries, + sessionTail, agentProcessAlive, idleMs, needsProducerReview, diff --git a/packages/herdr/src/pane-close-reaper.test.ts b/packages/herdr/src/pane-close-reaper.test.ts index 602e89879..c9536b955 100644 --- a/packages/herdr/src/pane-close-reaper.test.ts +++ b/packages/herdr/src/pane-close-reaper.test.ts @@ -507,6 +507,37 @@ describe('parseReaperArgs', () => { ledgerPath: '/tmp/l.jsonl', }); }); + + it('parses --json (WL-0MUJMXVPO0016DZM)', async () => { + const { parseReaperArgs } = await import('./pane-close-reaper'); + expect(parseReaperArgs(['--json'])).toEqual({ json: true }); + }); + + it('parses --workspace <id> (WL-0MUJMXVPO0016DZM AC1)', async () => { + const { parseReaperArgs } = await import('./pane-close-reaper'); + expect(parseReaperArgs(['--workspace', 'w2V'])).toEqual({ workspace: 'w2V' }); + }); +}); + +describe('runReaper — workspace scoping (WL-0MUJMXVPO0016DZM AC1)', () => { + it('excludes panes belonging to a different workspace', async () => { + const { runReaper } = await import('./pane-close-reaper'); + const panes = [ + pane({ id: 'w1:p1', workspaceId: 'w1', lastAssistantText: '</end_session>', agentProcessAlive: false }), + pane({ id: 'w2:p2', workspaceId: 'w2', lastAssistantText: '</end_session>', agentProcessAlive: false }), + ]; + const deps = makeDeps(panes); + const results = await runReaper(deps, { workspace: 'w1', dryRun: true }); + expect(results.map((r) => r.paneId)).toEqual(['w1:p1']); + }); + + it('retains panes with an unknown workspace (tolerant)', async () => { + const { runReaper } = await import('./pane-close-reaper'); + const panes = [pane({ id: 'w9:p9', workspaceId: undefined })]; + const deps = makeDeps(panes); + const results = await runReaper(deps, { workspace: 'w1', dryRun: true }); + expect(results.map((r) => r.paneId)).toEqual(['w9:p9']); + }); }); describe('runReaper — ledger writing', () => { @@ -623,6 +654,47 @@ describe('runReaper — detailed reason snapshot in the ledger (parent AC4.1/AC4 }); describe('runReaperCli', () => { + it('emits a JSON document on stdout in --json mode (WL-0MUJMXVPO0016DZM)', async () => { + const { runReaperCli } = await import('./pane-close-reaper'); + const panes = [ + pane({ + id: 'w1:p1', + itemId: 'WL-0ABC123', + workspaceId: 'w1', + lastAssistantText: '</end_session>', + agentProcessAlive: false, + }), + ]; + const deps = makeDeps(panes); + const logged: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((line: string) => { + logged.push(line); + }); + try { + const code = await runReaperCli(deps, ['--json']); + expect(code).toBe(0); + } finally { + logSpy.mockRestore(); + } + const output = JSON.parse(logged.join('\n')); + expect(output.evaluated).toBe(1); + expect(output.dryRun).toBe(false); + // Bridge envelope fields required by the pane-triage skill + // (WL-0MUJMXVPO0016DZM child AC2). + expect(output).toHaveProperty('invokingPaneId'); + expect(typeof output.timestamp).toBe('string'); + expect(output.panes[0]).toMatchObject({ + paneId: 'w1:p1', + itemId: 'WL-0ABC123', + workspaceId: 'w1', + kind: 'plan', + close: true, + reasonCode: 'marker', + needsProducerReview: false, + }); + expect(Array.isArray(output.panes[0].sessionTail)).toBe(true); + }); + it('returns exit code 0 when all closes succeed', async () => { const { runReaperCli } = await import('./pane-close-reaper'); const { mkdtempSync, rmSync } = await import('node:fs'); diff --git a/packages/herdr/src/pane-close-reaper.ts b/packages/herdr/src/pane-close-reaper.ts index 935489802..54547ce4f 100644 --- a/packages/herdr/src/pane-close-reaper.ts +++ b/packages/herdr/src/pane-close-reaper.ts @@ -48,6 +48,15 @@ export interface PaneStatus { /** Work-item stage at decision time, when known (logging snapshot). */ itemStage?: string; title: string; + /** + * The herdr workspace this pane belongs to, when known. Used by the + * pane-triage skill to scope its report to the invoking workspace + * (`WL-0MUJMXVPO0016DZM` AC1). Optional/tolerant: absent panes are not + * filtered out. + */ + workspaceId?: string; + /** The herdr tab this pane belongs to, when known (AC3 report). */ + tabId?: string; /** Concatenated text of the final assistant message. */ lastAssistantText: string; /** @@ -56,6 +65,11 @@ export interface PaneStatus { * (parent AC6) instead of trusting `lastAssistantText`. */ sessionEntries?: { type?: string; text?: string }[]; + /** + * Last ~20 human-readable session lines, surfaced to the pane-triage skill + * (`WL-0MUJMXVPO0016DZM`). Optional/tolerant: absent means no tail. + */ + sessionTail?: string[]; /** Whether the agent process is alive. */ agentProcessAlive: boolean; /** Idle time in milliseconds. */ @@ -92,6 +106,19 @@ export interface ReaperResult { decision: CloseDecision; success: boolean; error?: string; + /** + * Trailing work-item id parsed from the pane label, when present + * (`WL-0MUJMXVPO0016DZM` AC3). Additive: legacy callers may ignore it. + */ + itemId?: string; + /** The herdr workspace the pane belongs to, when known (AC1). */ + workspaceId?: string; + /** The herdr tab the pane belongs to, when known (AC3). */ + tabId?: string; + /** The pane kind (plan/intake/audit/implement/unknown), when known. */ + kind?: PaneStatus['kind']; + /** Last ~20 session lines for the report's log tail (AC4). */ + sessionTail?: string[]; } /** Options for the reaper run. */ @@ -118,6 +145,20 @@ export interface ReaperOptions { * historical entries and a possible future re-enable. */ alreadyClosedPaneIds?: ReadonlySet<string>; + /** + * Output results as JSON to stdout (for consumption by external tools). + */ + json?: boolean; + /** + * Filter panes to the specified workspace id only. When absent, all + * workspaces are included. + */ + workspace?: string; + /** + * The invoking pane id (`HERDR_PANE_ID`), surfaced in the JSON envelope so + * the pane-triage skill can exclude it (`WL-0MUJMXVPO0016DZM` AC5). + */ + invokingPaneId?: string; } // ── Ledger ──────────────────────────────────────────────────────────── @@ -240,6 +281,17 @@ export async function runReaper( const panes = await deps.listPanes(); for (const pane of panes) { + // Workspace scoping (`WL-0MUJMXVPO0016DZM` AC1): when a workspace filter + // is set, panes belonging to a different workspace are skipped. Panes + // with an unknown workspace are retained (tolerant). + if ( + options.workspace !== undefined && + pane.workspaceId !== undefined && + pane.workspaceId !== options.workspace + ) { + continue; + } + // Coexistence with the dispatch monitor (parent constraint): a pane the // `pane-lifecycle.ts` monitor already recorded as closed is never handled // again. The monitor is currently disabled (WL-0MUMEKDK0008LKH8); the @@ -273,6 +325,11 @@ export async function runReaper( const result: ReaperResult = { paneId: pane.id, paneTitle: pane.title, + itemId: pane.itemId, + workspaceId: pane.workspaceId, + tabId: pane.tabId, + kind: pane.kind, + sessionTail: Array.isArray(pane.sessionTail) ? pane.sessionTail : [], decision, success, error, @@ -296,6 +353,9 @@ export async function runReaper( * - `--dry-run` report only, close nothing * - `--threshold-minutes <n>` idle threshold in minutes (default 30) * - `--ledger <path>` ledger output path (default `.worklog/pane-close-ledger.jsonl`) + * - `--json` output classification results as JSON (for external tools) + * - `--workspace <id>` scope results to the given workspace id + * - `--invoking-pane <id>` invoking pane id (echoed in JSON; never closed) */ export function parseReaperArgs(argv: string[]): ReaperOptions { const options: ReaperOptions = {}; @@ -310,6 +370,12 @@ export function parseReaperArgs(argv: string[]): ReaperOptions { } } else if (arg === '--ledger') { options.ledgerPath = argv[++i]; + } else if (arg === '--json') { + options.json = true; + } else if (arg === '--workspace') { + options.workspace = argv[++i]; + } else if (arg === '--invoking-pane') { + options.invokingPaneId = argv[++i]; } } return options; @@ -320,6 +386,11 @@ export function parseReaperArgs(argv: string[]): ReaperOptions { * close, process-group teardown) and runs the reaper. Returns the process * exit code: 0 on success, 1 on any partial failure. * + * In `--json` mode a single JSON document is written to stdout describing + * every evaluated pane (decision + reason snapshot + success). This is the + * cross-language bridge consumed by the Python `pane-triage` skill; the + * human-readable output is suppressed so stdout stays machine-parseable. + * * The real `ReaperDeps` implementation lives in the scheduling/wiring item * (WL-0MUJW9FFW009008M); this entrypoint accepts deps so the caller can * inject them and keeps the module free of direct herdr imports. @@ -334,7 +405,37 @@ export async function runReaperCli( const results = await runReaper(deps, { ...options, ledgerPath }); const failures = results.filter((r) => !r.success); - if (options.dryRun) { + if (options.json) { + console.log( + JSON.stringify( + { + panes: results.map((r) => ({ + paneId: r.paneId, + paneTitle: r.paneTitle, + itemId: r.itemId ?? '', + workspaceId: r.workspaceId, + tabId: r.tabId, + kind: r.kind ?? 'unknown', + close: r.decision.close === true, + reasonCode: r.decision.reasonCode, + reasonSnapshot: r.decision.reasonSnapshot, + needsProducerReview: r.decision.reasonSnapshot?.needsProducerReview === true, + sessionTail: Array.isArray(r.sessionTail) ? r.sessionTail : [], + success: r.success, + error: r.error, + })), + invokingPaneId: options.invokingPaneId ?? null, + timestamp: new Date().toISOString(), + evaluated: results.length, + closeCount: results.filter((r) => r.decision.close).length, + failureCount: failures.length, + dryRun: options.dryRun === true, + }, + null, + 2, + ), + ); + } else if (options.dryRun) { console.log(`reaper (dry-run): ${results.length} pane(s) evaluated, ${results.filter((r) => r.decision.close).length} would close`); } else { console.log(`reaper: ${results.length} pane(s) evaluated, ${results.filter((r) => r.decision.close).length} closed, ${failures.length} failed`); diff --git a/packages/herdr/src/settings.test.ts b/packages/herdr/src/settings.test.ts index a1f83680c..b9647255f 100644 --- a/packages/herdr/src/settings.test.ts +++ b/packages/herdr/src/settings.test.ts @@ -141,6 +141,31 @@ describe('downtimeMarkerStaleWindowMs (WL-0MU6UL0RJ008IHGT)', () => { }); }); +describe('downtimeNonTerminalCooldownMs (WL-0MUKYERLZ006ELL5)', () => { + it('defaults to 1_800_000 ms (30 minutes)', () => { + expect(defaultSettings.downtimeNonTerminalCooldownMs).toBe(30 * 60 * 1000); + }); + + it('loads a persisted value and clamps into the [1m, 24h] range', () => { + const path = tempSettingsPath(); + // Below the 1m floor → clamped up. + saveSettings(path, { ...defaultSettings, downtimeNonTerminalCooldownMs: 1_000 }); + expect(loadSettings(path).downtimeNonTerminalCooldownMs).toBe(60 * 1000); + // Above the 24h ceiling → clamped down. + saveSettings(path, { ...defaultSettings, downtimeNonTerminalCooldownMs: 48 * 60 * 60 * 1000 }); + expect(loadSettings(path).downtimeNonTerminalCooldownMs).toBe(24 * 60 * 60 * 1000); + // In-range preserved. + saveSettings(path, { ...defaultSettings, downtimeNonTerminalCooldownMs: 5 * 60 * 1000 }); + expect(loadSettings(path).downtimeNonTerminalCooldownMs).toBe(5 * 60 * 1000); + }); + + it('falls back to the default when the persisted value is not a number', () => { + const path = tempSettingsPath(); + writeFileSync(path, JSON.stringify({ ...defaultSettings, downtimeNonTerminalCooldownMs: 'soon' }), 'utf-8'); + expect(loadSettings(path).downtimeNonTerminalCooldownMs).toBe(30 * 60 * 1000); + }); +}); + describe('modeSwitchEnabled', () => { it('defaults to true (activity-gated mode switching is opt-out)', () => { expect(defaultSettings.modeSwitchEnabled).toBe(true); diff --git a/packages/herdr/src/settings.ts b/packages/herdr/src/settings.ts index 9112f116f..8fddb7eb7 100644 --- a/packages/herdr/src/settings.ts +++ b/packages/herdr/src/settings.ts @@ -11,12 +11,14 @@ import { clampDowntimeIdleThresholdMs, clampDowntimeMarkerStaleWindowMs, clampDowntimeNoCandidateCooldownMs, + clampDowntimeNonTerminalCooldownMs, clampDowntimePollInterval, clampDowntimeRequiredFreeSlots, DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, DEFAULT_DOWNTIME_MODEL, DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, DEFAULT_DOWNTIME_POLL_INTERVAL_MS, DEFAULT_DOWNTIME_PROXY_URL, DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, @@ -77,6 +79,13 @@ export interface PluginSettings { * Floor 60s; default 3_600_000 ms (60 min). */ downtimeNoCandidateCooldownMs: number; + /** + * Minimum cooldown (WL-0MUKYERLZ006ELL5) between successive downtime + * dispatches of the same `(item, kind)` after a pane closed without reaching + * a terminal stage (`agent-ended-no-terminal`, `audit-ended-no-result`). + * Neutral skip, never a strike. Default 30 min; clamped to [1 min, 24 h]. + */ + downtimeNonTerminalCooldownMs: number; /** * Dispatched success-marker staleness window (WL-0MU6UL0RJ008IHGT): a * SUCCESS dispatch marker whose item is still at the marker's dispatched-at @@ -145,6 +154,7 @@ export const defaultSettings: PluginSettings = { downtimeProxyUrl: DEFAULT_DOWNTIME_PROXY_URL, downtimeModel: DEFAULT_DOWNTIME_MODEL, downtimeNoCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + downtimeNonTerminalCooldownMs: DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, downtimeMarkerStaleWindowMs: DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, modeSwitchEnabled: true, modeSwitchIdleThresholdMs: DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS, @@ -253,6 +263,9 @@ export function loadSettings(settingsPath?: string): PluginSettings { downtimeNoCandidateCooldownMs: typeof parsed.downtimeNoCandidateCooldownMs === 'number' ? clampDowntimeNoCandidateCooldownMs(parsed.downtimeNoCandidateCooldownMs) : defaultSettings.downtimeNoCandidateCooldownMs, + downtimeNonTerminalCooldownMs: typeof parsed.downtimeNonTerminalCooldownMs === 'number' + ? clampDowntimeNonTerminalCooldownMs(parsed.downtimeNonTerminalCooldownMs) + : defaultSettings.downtimeNonTerminalCooldownMs, downtimeMarkerStaleWindowMs: typeof parsed.downtimeMarkerStaleWindowMs === 'number' ? clampDowntimeMarkerStaleWindowMs(parsed.downtimeMarkerStaleWindowMs) : defaultSettings.downtimeMarkerStaleWindowMs, diff --git a/packages/herdr/src/ship-it-dialog.test.ts b/packages/herdr/src/ship-it-dialog.test.ts index 3061e6a21..898ac12f9 100644 --- a/packages/herdr/src/ship-it-dialog.test.ts +++ b/packages/herdr/src/ship-it-dialog.test.ts @@ -55,6 +55,27 @@ describe('ShipItDialogState — typed input', () => { expect(state.handleInput('\t')).toBeNull(); expect(state.buffer).toBe(''); }); + + // Fast typing delivers the whole word in one chunk — every character must + // be kept (WL-0MTV67MZU003H7SH). + it('appends every character from a coalesced multi-character chunk', () => { + const { state } = makeDialog(); + expect(state.handleInput('ship')).toBeNull(); + expect(state.buffer).toBe('ship'); + }); + + it('submits a coalesced chunk ending in Enter', () => { + const { state, onConfirm } = makeDialog(); + expect(state.handleInput('ship\r')).toBe('submitted'); + expect(state.buffer).toBe('ship'); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('applies Backspace inside a coalesced chunk', () => { + const { state } = makeDialog(); + state.handleInput('shipp\x7f'); + expect(state.buffer).toBe('ship'); + }); }); describe('ShipItDialogState — confirmation matching (case-insensitive)', () => { diff --git a/packages/herdr/src/ship-it-dialog.ts b/packages/herdr/src/ship-it-dialog.ts index 2f12d4a88..594cd2526 100644 --- a/packages/herdr/src/ship-it-dialog.ts +++ b/packages/herdr/src/ship-it-dialog.ts @@ -20,6 +20,7 @@ */ import { visibleWidth, truncateToWidth } from './form-dialog.js'; +import { splitKeypresses } from './key-input.js'; // ── ANSI helpers ────────────────────────────────────────────────────── @@ -59,14 +60,33 @@ export class ShipItDialogState { } /** - * Process a single keypress while the dialog is open. + * Process a raw stdin chunk while the dialog is open. * - * @param key - The raw keypress string + * A single `data` event may carry SEVERAL coalesced keystrokes when the + * user types quickly — each token is processed in order so no character is + * dropped (WL-0MTV67MZU003H7SH). Single-key callers are unaffected. + * + * @param chunk - The raw keypress chunk (one or more keys) * @returns 'submitted' if the dialog was confirmed (dispatch fired), * 'cancelled' if dismissed with Esc, or null while the dialog - * stays open (typed input / non-matching Enter / ignored keys) + * stays open (typed input / non-matching Enter / ignored keys). + * Preceding keys in a chunk are applied before a terminal result. */ - handleInput(key: string): 'submitted' | 'cancelled' | null { + handleInput(chunk: string): 'submitted' | 'cancelled' | null { + // Split a coalesced chunk into individual keys and process each in + // order. A single-token chunk falls straight through to the existing + // single-key logic below. + const keys = splitKeypresses(chunk); + if (keys.length > 1) { + let last: 'submitted' | 'cancelled' | null = null; + for (const token of keys) { + last = this.handleInput(token); + if (last !== null) return last; + } + return last; + } + const key = keys[0] ?? ''; + if (key === '\r' || key === '\n') { if (this.buffer.toLowerCase() === 'ship') { this.onConfirm(); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 7d9407fc5..fd940d5cf 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -36,6 +36,7 @@ import { isWlViewCommand, resolveDispatchDetail, formatChordHintsForHelp, + formatFooterShortcutHints, resolvePodcastTarget, clearDescriptionPreviewCache, isHeadingRow, @@ -914,13 +915,18 @@ describe('dispatches filter axis (WL-0MUL2IZLF002S9X5)', () => { const items = await fetchItemsForView(null, null, defaultFetcher, true, root); // The log still decides which items appear (WL-OLD/WL-NEW are absent from - // the live list, so they stay log-derived) and the rows follow the main - // selection-list ordering (WL-0MUGLL9SS002E1D2 follow-up). - expect(items.map((i) => i.id)).toEqual(['WL-NEW', 'WL-OLD']); - expect(items[0].title).toBe('Newer dispatch'); - expect(items[0].isLogDerived).toBe(true); - expect(items[0].isLogOnly).toBe(true); - expect(items[0].dispatchKind).toBe('implement'); + // the live list, so they stay log-derived). Each lands in its own 4-hour + // time block, and blocks render oldest-first (WL-0MUMM9NED009TLL3), so + // WL-OLD (1 Jan) precedes WL-NEW (2 Jan). + expect(items.map((i) => i.id)).toEqual(['WL-OLD', 'WL-NEW']); + expect(items[1].title).toBe('Newer dispatch'); + expect(items[1].isLogDerived).toBe(true); + expect(items[1].isLogOnly).toBe(true); + expect(items[1].dispatchKind).toBe('implement'); + // Every row carries its time-block group so the display model emits a + // heading before each block. + expect(items[0].groupLabel).toBe('01 Jan 2026, 00:00–04:00'); + expect(items[1].groupLabel).toBe('02 Jan 2026, 00:00–04:00'); // The live fetcher (the same one other views use) IS consulted so // surviving items can be rendered with identical icons // (WL-0MUGLL9SS002E1D2 audit fix). @@ -992,36 +998,76 @@ describe('dispatches filter axis (WL-0MUL2IZLF002S9X5)', () => { expect(dispatchLine).toContain('[audit]'); }); - it('orders rows like the main selection list (Critical → plan/intake → Idea → In Review), flat (WL-0MUGLL9SS002E1D2 follow-up)', async () => { + it('groups rows into 4-hour time blocks, oldest block first, newest item first within a block (WL-0MUMM9NED009TLL3)', async () => { const root = makeTempRoot(); - const ids: Array<[string, string, string]> = [ - ['WL-REVIEW', 'in_review', 'high'], - ['WL-IDEA', 'idea', 'medium'], - ['WL-PLAN', 'plan_complete', 'high'], - ['WL-CRIT', 'plan_complete', 'critical'], + // Two 4-hour blocks on the same day; within each block rows arrive + // newest-first. + const entries: Array<[string, string]> = [ + ['WL-LATE-2', '2026-01-01T05:30:00.000Z'], + ['WL-LATE-1', '2026-01-01T05:00:00.000Z'], + ['WL-EARLY-2', '2026-01-01T01:30:00.000Z'], + ['WL-EARLY-1', '2026-01-01T01:00:00.000Z'], ]; - for (const [id] of ids) { - await writeDispatch(root, { - itemId: id, - kind: 'implement', - title: id, - dispatchedAt: '2026-01-01T00:00:00.000Z', - }); + for (const [id, dispatchedAt] of entries) { + await writeDispatch(root, { itemId: id, kind: 'implement', title: id, dispatchedAt }); } - const live = ids.map(([id, stage, priority]) => ({ - ...makeItem(id), - id, - title: id, - stage, - priority, - })) as WorkItem[]; - const defaultFetcher = vi.fn().mockResolvedValue(live); + const items = await fetchItemsForView(null, null, vi.fn().mockResolvedValue([]), true, root); - const items = await fetchItemsForView(null, null, defaultFetcher, true, root); + // Oldest block first, and within each block newest item first. + expect(items.map((i) => i.id)).toEqual([ + 'WL-EARLY-2', 'WL-EARLY-1', + 'WL-LATE-2', 'WL-LATE-1', + ]); + expect(items.map((i) => i.groupLabel)).toEqual([ + '01 Jan 2026, 00:00–04:00', + '01 Jan 2026, 00:00–04:00', + '01 Jan 2026, 04:00–08:00', + '01 Jan 2026, 04:00–08:00', + ]); + expect(items.map((i) => i.group)).toEqual([1, 1, 2, 2]); + }); + + it('assigns a distinct group number per chronological block and an Unknown time block last (WL-0MUMM9NED009TLL3)', async () => { + const root = makeTempRoot(); + await writeDispatch(root, { itemId: 'WL-DAY2', kind: 'plan', title: 'Day 2', dispatchedAt: '2026-01-02T09:00:00.000Z' }); + await writeDispatch(root, { itemId: 'WL-DAY1', kind: 'plan', title: 'Day 1', dispatchedAt: '2026-01-01T09:00:00.000Z' }); + // Malformed timestamp → Unknown time block, which must be last. + await appendDowntimeLogEntry(root, JSON.stringify({ itemId: 'WL-NO-TS', kind: 'plan', title: 'No ts' })); + + const items = await fetchItemsForView(null, null, vi.fn().mockResolvedValue([]), true, root); + + expect(items.map((i) => i.id)).toEqual(['WL-DAY1', 'WL-DAY2', 'WL-NO-TS']); + expect(items.map((i) => i.groupLabel)).toEqual([ + '01 Jan 2026, 08:00–12:00', + '02 Jan 2026, 08:00–12:00', + 'Unknown time', + ]); + // Blocks are numbered sequentially 1..N in render order. + expect(items.map((i) => i.group)).toEqual([1, 2, 3]); + }); + + it('renders a time-block heading row before each block via the existing heading path (WL-0MUMM9NED009TLL3)', async () => { + const root = makeTempRoot(); + await writeDispatch(root, { itemId: 'WL-A', kind: 'plan', title: 'A', dispatchedAt: '2026-01-01T01:00:00.000Z' }); + await writeDispatch(root, { itemId: 'WL-B', kind: 'plan', title: 'B', dispatchedAt: '2026-01-01T05:00:00.000Z' }); + const items = await fetchItemsForView(null, null, vi.fn().mockResolvedValue([]), true, root); + + const state = new WorkItemListState(items, TERM); + const rows = state.getDisplayRows(); + const headings = rows.filter(isHeadingRow); - expect(items.map((i) => i.id)).toEqual(['WL-CRIT', 'WL-PLAN', 'WL-IDEA', 'WL-REVIEW']); - // Flat list — no group stamps remain (no headings rendered). - expect(items.every((i) => i.group === undefined && i.groupLabel === undefined)).toBe(true); + expect(headings).toHaveLength(2); + expect(headings.map((h) => h.groupLabel)).toEqual([ + '01 Jan 2026, 00:00–04:00', + '01 Jan 2026, 04:00–08:00', + ]); + // Each heading is immediately followed by its single item row. + expect(rows.map((r) => (isHeadingRow(r) ? `H:${r.groupLabel}` : (r as WorkItem).id))).toEqual([ + 'H:01 Jan 2026, 00:00–04:00', + 'WL-A', + 'H:01 Jan 2026, 04:00–08:00', + 'WL-B', + ]); }); it('keeps the log-derived fallback for an item absent from the live list (WL-0MUGLL9SS002E1D2)', async () => { @@ -1078,7 +1124,7 @@ describe('dispatches filter axis (WL-0MUL2IZLF002S9X5)', () => { expect(items).toEqual([]); }); - it('fetchItemsForView dispatches view caps rows and dedups by id via the projection', async () => { + it('fetchItemsForView dispatches view shows every row (no 20-item cap) and dedups by id via the projection', async () => { const root = makeTempRoot(); for (let i = 0; i < 25; i++) { await writeDispatch(root, { @@ -1096,8 +1142,9 @@ describe('dispatches filter axis (WL-0MUL2IZLF002S9X5)', () => { dispatchedAt: '2026-01-01T00:00:00.000Z', }); const items = await fetchItemsForView(null, null, vi.fn().mockResolvedValue([]), true, root); - expect(items).toHaveLength(20); - expect(new Set(items.map((i) => i.id)).size).toBe(20); + // The cap is removed (WL-0MUMM9NED009TLL3): all 25 distinct ids are shown. + expect(items).toHaveLength(25); + expect(new Set(items.map((i) => i.id)).size).toBe(25); }); }); @@ -2473,6 +2520,226 @@ describe('w chord leader — sub-chord hints and stage gating', () => { }); }); +// ── w chord split: full chord → dispatch path (OSL-0MUQ59IRF009ICIF) ──── +// +// Regression coverage for the reported "w-r / w-s / w-b do not fire on +// podcast items" bug. The sub-chord visibility/stage-gate and the marker +// resolution are each covered above in isolation; what was missing is a test +// that drives the FULL path — chord leader detection → prefix lookup → +// processChordInput → resolvePodcastTarget → dispatch — because that is where +// the failure actually surfaces. +// +// Root cause (OSL-0MURL1S5O000Z0MH): the chords DO resolve; the drop happens +// in `resolvePodcastTarget`, which returns an error when the selected item has +// no `.podcast.md` (or any) Key File, and the caller then shows an error toast +// and returns without dispatching. On a script-less episode at a script-bearing +// stage, `w-s` additionally reported the misleading "podcast script already +// present" message, which is false when no script exists. + +describe('w chord split — full chord to dispatch path', () => { + // Local shortcuts.json fixture matching the project-local override + // (shortcuts.json at the worklog root) so the registry carries the same + // entries the operator uses in the TUI. + const wChordEntries = [ + { chord: ['w', 'r'], command: '/skill:wiki-podcast-script --review <podcast-review>', view: 'both', label: 'write review', stages: ['plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + { chord: ['w', 's'], command: '/skill:wiki-podcast-script <podcast-target>', view: 'both', label: 'write script', stages: ['intake_complete', 'plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + { chord: ['w', 'b'], command: '/skill:wiki-podcast-script --review-rewrite <podcast-both>', view: 'both', label: 'write both', stages: ['plan_complete', 'in_review', 'done'], work_item_types: ['podcast'] }, + ]; + + let tempRoot: string | undefined; + + function registryWithLocalWChords(): ShortcutRegistry { + tempRoot = mkdtempSync(join(tmpdir(), 'herdr-w-chord-dispatch-')); + writeFileSync(join(tempRoot, 'shortcuts.json'), JSON.stringify(wChordEntries)); + return loadShortcutConfig(tempRoot); + } + + afterEach(() => { + if (tempRoot) { + try { rmSync(tempRoot, { recursive: true, force: true }); } catch { /* ignore */ } + tempRoot = undefined; + } + vi.restoreAllMocks(); + }); + + /** + * Press the leader then each sub-key through the real chord state machine + * and return the resolved command (or null when the chord was dropped or + * cancelled before completion). + */ + function pressChord( + registry: ShortcutRegistry, + leader: string, + subKey: string, + stage: string, + issueType: string, + view = 'list', + ): string | null { + expect(isChordLeader(leader, registry, false, issueType)).toBe(true); + const chordState = createChordState(); + const started = processChordInput(chordState, leader, registry, view, stage, false, issueType); + expect(started).toBeNull(); // collecting + expect(chordState.pendingKeys).toEqual([leader]); + const completed = processChordInput(chordState, subKey, registry, view, stage, false, issueType); + return completed === 'chord-complete' ? chordState.resolvedCommand : null; + } + + const scriptItem: WorkItem = { + id: 'OSL-EP-1', + title: 'Episode with script', + status: 'open', + stage: 'plan_complete', + issueType: 'podcast', + description: '## Key Files:\n- Ep10/Ep10.podcast.md\n- Ep10/Ep10.md\n', + }; + + // Mirrors the reported item OSL-0MST3EHT3000M1LR: at a script-bearing stage + // (plan_complete) but with NO Key Files section at all. + const scriptlessItem: WorkItem = { + id: 'OSL-EP-2', + title: 'Episode with no script', + status: 'open', + stage: 'plan_complete', + issueType: 'podcast', + description: '# Episode 10 — spec only\n\nSee docs/video-series-outline.md for the outline.\n', + }; + + it('w-r resolves and dispatches --review <script> on a podcast episode with a script', async () => { + const registry = registryWithLocalWChords(); + const command = pressChord(registry, 'w', 'r', 'plan_complete', 'podcast'); + expect(command).toBe('/skill:wiki-podcast-script --review <podcast-review>'); + + const resolved = await resolvePodcastTarget(command!, scriptItem); + expect(resolved.error).toBeUndefined(); + const onCommand = vi.fn(); + const state = new WorkItemListState([scriptItem], TERM_80x24); + executeResolvedCommand(resolved.command!, state, onCommand); + // The dispatch callback signature is (command, model, openPane, ...) on + // current ContextHub dev; assert the command (first arg) so the test is + // robust to additional trailing callback args. + expect(onCommand.mock.calls[0][0]).toBe('/skill:wiki-podcast-script --review Ep10/Ep10.podcast.md'); + }); + + it('w-b resolves and dispatches --review-rewrite <script> on a podcast episode with a script', async () => { + const registry = registryWithLocalWChords(); + const command = pressChord(registry, 'w', 'b', 'plan_complete', 'podcast'); + expect(command).toBe('/skill:wiki-podcast-script --review-rewrite <podcast-both>'); + + const resolved = await resolvePodcastTarget(command!, scriptItem); + expect(resolved.error).toBeUndefined(); + const onCommand = vi.fn(); + const state = new WorkItemListState([scriptItem], TERM_80x24); + executeResolvedCommand(resolved.command!, state, onCommand); + expect(onCommand.mock.calls[0][0]).toBe('/skill:wiki-podcast-script --review-rewrite Ep10/Ep10.podcast.md'); + }); + + it('w-s resolves and dispatches --rewrite <script> when open editor-note children exist', async () => { + const registry = registryWithLocalWChords(); + const command = pressChord(registry, 'w', 's', 'plan_complete', 'podcast'); + expect(command).toBe('/skill:wiki-podcast-script <podcast-target>'); + + const children = [{ id: 'OSL-EP-1-N1', title: 'Note', status: 'open' } as WorkItem]; + const resolved = await resolvePodcastTarget(command!, scriptItem, async () => children); + expect(resolved.error).toBeUndefined(); + const onCommand = vi.fn(); + const state = new WorkItemListState([scriptItem], TERM_80x24); + executeResolvedCommand(resolved.command!, state, onCommand); + expect(onCommand.mock.calls[0][0]).toBe('/skill:wiki-podcast-script --rewrite Ep10/Ep10.podcast.md'); + }); + + it('w-r does NOT dispatch on a script-less episode (belt-and-braces guard)', async () => { + const registry = registryWithLocalWChords(); + const command = pressChord(registry, 'w', 'r', 'plan_complete', 'podcast'); + const resolved = await resolvePodcastTarget(command!, scriptlessItem); + expect(resolved.error).toMatch(/no podcast script/i); + expect(resolved.command).toBeUndefined(); + }); + + it('w-s reports a MISSING-SCRIPT error (not "already present") on a script-less episode', async () => { + // This is the reported failure shape: a plan_complete podcast item with no + // script Key File and no open note children. The current code reports the + // false message "podcast script already present, review and edit that + // rather than author a new one"; it must instead name the missing script. + const registry = registryWithLocalWChords(); + const command = pressChord(registry, 'w', 's', 'plan_complete', 'podcast'); + const resolved = await resolvePodcastTarget(command!, scriptlessItem, async () => []); + expect(resolved.error).toBeDefined(); + expect(resolved.error).not.toMatch(/already present/i); + expect(resolved.error).toMatch(/no podcast script|not been drafted|Key Files/i); + expect(resolved.command).toBeUndefined(); + }); + + it('w-b does NOT dispatch on a script-less episode (belt-and-braces guard)', async () => { + const registry = registryWithLocalWChords(); + const command = pressChord(registry, 'w', 'b', 'plan_complete', 'podcast'); + const resolved = await resolvePodcastTarget(command!, scriptlessItem); + expect(resolved.error).toMatch(/no podcast script/i); + expect(resolved.command).toBeUndefined(); + }); + + it('all three sub-chords resolve on a podcast item even with a stage filter active', () => { + // state.activeFilter is passed as `stage`; a filter value must not hide + // the chords (rules out the stage-gate path). + const registry = registryWithLocalWChords(); + for (const [subKey, expected] of [ + ['r', '/skill:wiki-podcast-script --review <podcast-review>'], + ['s', '/skill:wiki-podcast-script <podcast-target>'], + ['b', '/skill:wiki-podcast-script --review-rewrite <podcast-both>'], + ] as const) { + expect(pressChord(registry, 'w', subKey, 'plan_complete', 'podcast')).toBe(expected); + } + }); + + it('all three sub-chords stay hidden on non-podcast items', () => { + const registry = registryWithLocalWChords(); + for (const subKey of ['r', 's', 'b']) { + expect(isChordLeader('w', registry, false, 'bug')).toBe(false); + const chordState = createChordState(); + const result = processChordInput(chordState, 'w', registry, 'list', 'plan_complete', false, 'bug'); + expect(result).toBe('chord-cancel'); + expect(chordState.pendingKeys).toEqual([]); + } + }); + + it('footer hints list all three w sub-chords for a podcast item at a script-bearing stage', () => { + const registry = registryWithLocalWChords(); + const nextChords = registry.getChordByPrefix(['w'], 'list', 'plan_complete', false, 'podcast'); + const hints = formatChordHintsForHelp(nextChords, ['w']); + expect(hints).toContain('r:review'); + expect(hints).toContain('s:script'); + expect(hints).toContain('b:both'); + }); + + // AC5 / fix-child AC8: the idle footer help line (before the leader is + // pressed) must advertise all three w sub-options, not just `w:write...`. + it('footer help line shows each w sub-option for a podcast item (AC5)', () => { + const registry = registryWithLocalWChords(); + const entries = registry.getEntriesForStage('plan_complete', false, 'podcast') + .filter(e => e.view === 'list' || e.view === 'both'); + const hints = formatFooterShortcutHints(entries); + expect(hints).toContain('w:write review...'); + expect(hints).toContain('w:write script...'); + expect(hints).toContain('w:write both...'); + // The bare collapsed form must no longer be the only w hint. + expect(hints).not.toBe('w:write...'); + }); + + it('footer help line collapses homogeneous ungated chord families (regression guard)', () => { + // `u p l` / `u p m` share the leader `u` but carry NO work-item-type + // allowlist, so they must keep the collapsed `u:update...` hint — + // proving the fix does not disturb other chord families. + const entries: ShortcutEntry[] = [ + { chord: ['u', 'p', 'l'], command: '/wl update --priority low', view: 'both', label: 'update priority low' }, + { chord: ['u', 'p', 'm'], command: '/wl update --priority medium', view: 'both', label: 'update priority medium' }, + { chord: ['c'], command: '/wl create', view: 'both', label: 'create new' }, + ]; + const hints = formatFooterShortcutHints(entries); + expect(hints).toContain('u:update...'); + expect(hints).not.toContain('u:update priority'); + expect(hints).toContain('c:create new'); + }); +}); + // ── Code Freeze: banner rendering ──────────────────────────────────────── // The banner must appear only when freeze is active and must never break the // `rows - 1` line-count invariant (WL-0MSAAON63003N6LO). @@ -5236,6 +5503,23 @@ describe('isInputActive — note-edit coverage and keystroke preservation (WL-0M // After submission, ensure no character was dropped from the buffer prior to confirm. // The buffer was verified above; a skipped scheduler tick never truncates it. }); + + it('fast-typing chunks lose no keystrokes into FormState across a skipped tick', async () => { + // Fast typing coalesces several keystrokes into one stdin chunk; the + // form must apply every character (WL-0MTV67MZU003H7SH). + const { FormState: Fs } = await import('./form-dialog.js'); + const state = new Fs('cmd <x>', 'Test', [{ name: 'x', default: '' }], () => {}, () => {}); + for (const chunk of ['the ', 'quick ', 'brown ', 'fox']) state.handleInput(chunk); + expect(state.fields[0].value).toBe('the quick brown fox'); + }); + + it('fast-typing chunks lose no keystrokes into ShipItDialogState across a skipped tick', async () => { + const { ShipItDialogState: SDS } = await import('./ship-it-dialog.js'); + const onConfirm = vi.fn(); + const state = new SDS(onConfirm, () => {}); + expect(state.handleInput('ship\r')).toBe('submitted'); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); }); // ── Header truncation guard (WL-0MSNI6TQ5003JY1Z) ───────────────────── diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 77dd4f913..a1f11b3de 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -48,7 +48,7 @@ import { TaskScheduler, DEFAULT_SCHEDULER_TICK_MS } from './scheduler.js'; import { loadSettings } from './settings.js'; import { DbChangeTracker, resolveCacheDir } from './db-change.js'; import { DEFAULT_DOWNTIME_POLL_INTERVAL_MS, DOWNTIME_RUN_TIMEOUT_MS, type DowntimeWorker } from './downtime-worker.js'; -import { recentDispatchedItems } from './downtime-log.js'; +import { groupRecentDispatchesByTimeBlock, recentDispatchedItems } from './downtime-log.js'; import { buildDispatchWorkItem, mergeDispatchRow } from './dispatch-view.js'; import { createHydratorRunner, @@ -67,7 +67,7 @@ import { } from './form-dialog.js'; import { readFromClipboard, writeToClipboard } from './clipboard.js'; import { ShipItDialogState, overlayShipItDialog } from './ship-it-dialog.js'; -import { extractFilePaths, regroupWorkItems } from './grouping.js'; +import { extractFilePaths } from './grouping.js'; import { renderMarkdown, renderMarkdownViewer } from './md-viewer.js'; import { findNoteInParagraph, @@ -96,6 +96,12 @@ import { * Design decision — skip, don't coalesce: ticks while typing are silently * dropped; the next regular tick after close fires normally. No queued * immediate refresh is emitted on close (avoids infinite-refresh loops). + * + * NOTE: this gate stops background re-render contention but does NOT by + * itself prevent dropped keystrokes when the user types fast — the PTY can + * coalesce several keys into one stdin chunk. The text-input handlers run + * every chunk through `splitKeypresses()` (`key-input.ts`) so no character + * is discarded (WL-0MTV67MZU003H7SH). */ export function isInputActive( formState: FormState | null, @@ -2669,6 +2675,63 @@ export function formatChordHintsForHelp( return result.join(' '); } +/** + * Build the dynamic footer hint string for the visible shortcut entries. + * + * Most chord leaders collapse to a single `<leader>:<firstWord>...` hint + * (e.g. `u:update...`), which keeps the footer compact for homogeneous + * variant families. The podcast-progression family (`w-r`/`w-s`/`w-b`) is + * the exception: its sub-chords are distinct, stage-gated steps whose labels + * differ after the shared leading word, so collapsing them hid the + * sub-options from the producer (OSL-0MUQ59IRF009ICIF AC5). + * + * A leader is expanded (every sub-chord hinted) only when its entries carry + * an explicit work-item-type allowlist — the marker that the entries were + * authored as type-specific progression steps rather than generic variants. + * Ungated families (`u`, `f`, `a`, `x`, `P`, `r`, `n`) keep the collapsed + * form, preserving existing behaviour. + * + * @param entries - Shortcut entries already filtered by stage/view/issue type. + * @returns Space-joined hint string (empty when no entry yields a hint). + */ +export function formatFooterShortcutHints(entries: ShortcutEntry[]): string { + const labelOf = (e: ShortcutEntry): string => e.label ?? e.command + .replace(/<[^>]+>/g, '') + .split(/\r?\n/)[0] + .trim() + .replace(/^\/(skill:)?/, ''); + + // Group multi-key chords by leader so a family is formatted as a unit. + const grouped = new Map<string, ShortcutEntry[]>(); + const parts: string[] = []; + for (const e of entries) { + if (e.chord && e.chord.length >= 2) { + const leader = e.chord[0]; + const group = grouped.get(leader) ?? []; + group.push(e); + grouped.set(leader, group); + } else if (e.chord && e.chord.length === 1) { + parts.push(`${e.chord[0]}:${labelOf(e)}`); + } + } + for (const [leader, group] of grouped) { + const isTypeSpecific = group.every( + e => e.workItemTypes !== undefined && e.workItemTypes.length > 0, + ); + if (isTypeSpecific && group.length > 1) { + // Distinct, type-gated progression steps: advertise each sub-option + // (e.g. `w:write review... w:write script... w:write both...`). + for (const e of group) { + parts.push(`${leader}:${labelOf(e)}...`); + } + } else { + // Homogeneous variant family: one collapsed leader hint as before. + parts.push(`${leader}:${labelOf(group[0]).split(/\s+/)[0]}...`); + } + } + return parts.join(' '); +} + /** * Get chord hints for showing in the help bar when in list mode. * Shows leader keys and abbreviated labels for all chords. @@ -4232,20 +4295,21 @@ export function fetchItemsForView( ]) .then(([rows, liveItems]) => { const liveById = new Map(liveItems.map((item) => [item.id, item])); - const projected = rows.map((row) => { - const live = liveById.get(row.itemId); - return live ? mergeDispatchRow(live, row) : buildDispatchWorkItem(row); - }); - // Reuse the canonical main-list ordering (Critical → Group N → Idea → - // Other → In Review, with the shared within-group comparator). The - // dispatch view stays a flat list, so the computed group stamps are - // dropped — only their ordering effect is kept. - return regroupWorkItems(projected).map((item) => { - const flat: WorkItem = { ...item }; - delete flat.group; - delete flat.groupLabel; - return flat; + // Group the log rows into 4-hour UTC time blocks, oldest block first + // (WL-0MUMM9NED009TLL3). Each block becomes a numbered group so the + // existing display-rows model interleaves a heading row before the + // block's items; within a block the projection's newest-first order + // is preserved. Group numbers are assigned in block order so no + // duplicate headings render. + const projected: WorkItem[] = []; + groupRecentDispatchesByTimeBlock(rows).forEach((block, index) => { + for (const row of block.rows) { + const live = liveById.get(row.itemId); + const item = live ? mergeDispatchRow(live, row) : buildDispatchWorkItem(row); + projected.push({ ...item, group: index + 1, groupLabel: block.label }); + } }); + return projected; }) .catch(() => []); } @@ -4527,7 +4591,13 @@ export async function resolvePodcastTarget( } resolved = resolved.replace(/<podcast-target>/g, `--doc ${synthesis} --force-single`); } else { - // Drafted/written episode — rewrite only when open note children exist. + // Drafted/written episode — rewrite only when a script exists AND open + // note children exist. The missing-script check MUST run first: without + // it, a script-less episode at a script-bearing stage reported the + // false "podcast script already present" message (OSL-0MUQ59IRF009ICIF). + if (!script) { + return { error: 'No podcast script found in Key Files: — this episode has not been drafted; add the script path to Key Files, or move the item back to intake_complete to author from the synthesis' }; + } let children: WorkItem[] = []; try { children = await fetchChildren(item.id); @@ -4541,9 +4611,6 @@ export async function resolvePodcastTarget( if (openNotes.length === 0) { return { error: 'podcast script already present, review and edit that rather than author a new one' }; } - if (!script) { - return { error: 'No podcast script found in Key Files:' }; - } resolved = resolved.replace(/<podcast-target>/g, `--rewrite ${script}`); } } @@ -6086,31 +6153,7 @@ export async function runWorklistTui( }); if (relevantEntries.length > 0) { - const seenChordLeaders = new Set<string>(); - const hints = relevantEntries - .filter(e => { - if (e.chord && e.chord.length >= 2) { - const leader = e.chord[0]; - if (seenChordLeaders.has(leader)) return false; - seenChordLeaders.add(leader); - } - return true; - }) - .map(e => { - const label = e.label ?? e.command - .replace(/<[^>]+>/g, '') - .split(/\r?\n/)[0] - .trim() - .replace(/^\/(skill:)?/, ''); - if (e.chord && e.chord.length >= 2) { - const leaderKey = e.chord[0]; - const firstWord = label.split(/\s+/)[0]; - return `${leaderKey}:${firstWord}...`; - } - return `${e.chord[0]}:${label}`; - }) - .join(' '); - dynamicHints = hints; + dynamicHints = formatFooterShortcutHints(relevantEntries); } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 81cef25d8..96ddfb75a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -212,6 +212,34 @@ export interface EmbeddingConfig { apiKey?: string; } +/** + * Configuration for the LLM (chat) provider used by the LLM-assisted + * producer-review explanation feature. + * + * Fields can be set in `.worklog/config.yaml` under the `llm` key, + * or via environment variables as fallbacks. Config values take precedence + * over environment variables. Mirrors `EmbeddingConfig` but adds `timeoutMs`. + * + * Example: + * ```yaml + * llm: + * baseUrl: http://localhost:8000/v1 + * model: compact + * apiKey: my-key + * timeoutMs: 15000 + * ``` + */ +export interface LlmConfig { + /** API base URL (default: http://192.168.0.199:8000/v1) */ + baseUrl?: string; + /** Model name (default: compact) */ + model?: string; + /** API key (optional — local providers like Ollama don't need one) */ + apiKey?: string; + /** Request timeout in milliseconds (default: 15000) */ + timeoutMs?: number; +} + /** * Configuration for a worklog project */ @@ -272,10 +300,7 @@ export interface WorklogConfig { statuses?: Array<{ value: string; label: string }>; stages?: Array<{ value: string; label: string }>; statusStageCompatibility?: Record<string, string[]>; - // When true, automatically submit a markdown summary to OpenBrain whenever - // a work item is marked as completed. Requires the `ob` CLI to be available - // on PATH (or WL_OB_BIN env var). Defaults to false. - openBrainEnabled?: boolean; + /** * Embedding provider configuration for semantic search. * When set in config, the embedder is considered available even without @@ -290,6 +315,19 @@ export interface WorklogConfig { * ``` */ embedding?: EmbeddingConfig; + /** + * LLM (chat) provider configuration for LLM-assisted features. + * Mirrors `EmbeddingConfig` but adds a `timeoutMs` field. + * + * Example: + * ```yaml + * llm: + * baseUrl: http://localhost:8000/v1 + * model: compact + * timeoutMs: 15000 + * ``` + */ + llm?: LlmConfig; } /** diff --git a/skill/pane-triage/SKILL.md b/skill/pane-triage/SKILL.md new file mode 100644 index 000000000..f09a4e006 --- /dev/null +++ b/skill/pane-triage/SKILL.md @@ -0,0 +1,154 @@ +--- +name: pane-triage +description: "Approval-gated close of idle panes in the current herdr workspace. Scans the invoking workspace via the shared pane-close classifier and closes only panes the producer explicitly approves. Trigger on: 'pane triage', 'close idle panes'" +--- + +# Pane-Triage Skill + +## Purpose + +Approval-gated, workspace-scoped cleanup of idle herdr panes. This is the +**manual complement** to the automatic pane-close reaper +(`WL-0MUJW9EEP009Q0V3`): the reaper closes settled panes unattended, while +this skill lets a producer review idle panes (with work-item context and a +log tail) and close a chosen subset. + +**Never closes anything without explicit producer approval.** The +never-close guards (implement panes, `needsProducerReview=true` items, the +invoking pane, and panes with live child processes) apply universally — to +both this skill and the automatic reaper. + +## Triggers + +- `/skill:pane-triage` +- "pane triage", "pane-triage", "close idle panes", "triage panes" + +## Invocation + +```bash +cd $(skill_path pane-triage) +./scripts/pane-triage.py # report, then prompt for approval +./scripts/pane-triage.py --dry-run # report only, never close +./scripts/pane-triage.py --json # machine-readable report +``` + +When invoked via `/skill:pane-triage` from the Pi chat, the agent runs the +script and relays the report, then asks the producer for approval. + +## Shared Classifier (do not reimplement) + +Idle state is **never** derived in this skill. Classification is delegated to +the shared classifier in ContextHub (`packages/herdr/src/pane-close.ts`), +reached through the compiled bridge CLI +(`packages/herdr/dist/pane-close-cli.js --json`). The bridge imports +`classifySession` / `extractFinalAssistantText` verbatim and emits one JSON +record per pane: + +```json +{ + "panes": [ + { + "paneId": "w2V:p7B", + "paneTitle": "Downtime triggered implement Foo - WL-0ABC123", + "itemId": "WL-0ABC123", + "workspaceId": "w2V", + "tabId": "w2V:t1X", + "kind": "implement", + "close": false, + "reasonCode": "implement", + "reasonSnapshot": { "needsProducerReview": false, "isInvokingPane": false }, + "sessionTail": ["...last 20 session lines..."], + "success": true + } + ], + "invokingPaneId": "w2V:p7H", + "timestamp": "2026-10-01T11:04:05.000Z", + "evaluated": 1, + "closeCount": 0, + "failureCount": 0, + "dryRun": true +} +``` + +`close: true` means the shared classifier judged the pane idle +(`reasonCode: "marker"` or `"idle-threshold"`); every other `reasonCode` +(`implement`, `producer-review`, `invoking-pane`, `live-children`, +`dead-agent`, `active`, `grace-period`) means the pane must stay open. + +## Workflow + +1. **Resolve workspace (AC1).** Read `HERDR_WORKSPACE_ID`; if absent, fall + back to `herdr pane current` → `result.pane.workspace_id`. Only the + invoking workspace is reported — no cross-workspace roll-up. +2. **Classify (AC2).** Shell out to the shared bridge with + `--json --dry-run --workspace <id>`. Fail closed on any bridge error. +3. **Report (AC3).** Render `## Idle` and `## Active` tables (pane id, tab, + title, work-item id, `needsProducerReview`, reason). +4. **Log tails (AC4).** For each idle pane, print the last 20 log lines under + the heading `<pane title> Needs Review <true|false>`. +5. **Recommend (AC5).** Build the "Recommended to close" table, excluding the + invoking pane (`HERDR_PANE_ID`) and any pane with `needsProducerReview=true`. +6. **Approve (AC6).** Prompt `yes` / `select N,M` / `no`. Ambiguity or `no` + closes nothing. The invoking pane is never closed. +7. **Re-verify and close (AC7).** Immediately before closing, re-read the pane + list through the bridge and drop any pane that is no longer idle; close the + remainder one at a time and record a per-pane ledger. + +## Scripts + +| Script | Purpose | +|---|---| +| `scripts/pane_triage.py` | Core logic — workspace resolution, bridge parsing, report, recommendation, approval parsing, re-verify-before-close | +| `scripts/pane-triage.py` | CLI wrapper — argv parsing, interactive prompt, log-tail fetching, herdr close calls | + +## Bridge Configuration + +The bridge path defaults to +`${CONTEXTHUB_DIR:-~/projects/ContextHub}/packages/herdr/dist/pane-close-cli.js` +and can be overridden with the `PANE_TRIAGE_BRIDGE` environment variable. +Build the bridge with: + +```bash +cd "${CONTEXTHUB_DIR:-$HOME/projects/ContextHub}/packages/herdr" && npm run build +``` + +## Environment Variables + +| Variable | Purpose | +|---|---| +| `HERDR_WORKSPACE_ID` | Invoking workspace id (preferred; falls back to `herdr pane current`) | +| `HERDR_PANE_ID` / `HERDR_PANE` | The invoking pane — never closed | +| `CONTEXTHUB_DIR` | ContextHub checkout root for the bridge path | +| `PANE_TRIAGE_BRIDGE` | Explicit bridge CLI path (overrides `CONTEXTHUB_DIR`) | + +## Constraints + +- **Shared classifier is mandatory** — classification is delegated to + `classifySession` via the bridge; no idle-state logic lives in this skill. +- **Shared reaper for closure** — the bridge uses `createHerdrReaperDeps` and + `runReaper` for pane enumeration and classification. +- **Never-close guards** (universal): `implement` panes, + `needsProducerReview=true` items, the invoking pane, and panes with live + child processes. +- **Approval gate is mandatory** — nothing is closed unattended. +- **Workspace-scoped only** — no cross-workspace roll-up. +- **Fail-closed** — a bridge/`herdr` error reports and exits non-zero without + any mutation. + +## Tests + +```bash +cd $(skill_path pane-triage) +python3 -m pytest tests/ -q +``` + +Offline fixture tests cover workspace resolution, classification mapping, +marker-mid-message rejection, work-item-id parsing, `needsProducerReview` +mapping, recommendation rules, and approval gating (no approval → zero close +calls). + +## Related Work Items + +- `WL-0MUJMXVPO0016DZM` — this skill +- `WL-0MUJW9EEP009Q0V3` — shared classifier + automatic reaper (dependency) +- `WL-0MUJL1NAH0042GOS` — why completed plan/intake sessions are not closed diff --git a/skill/pane-triage/__init__.py b/skill/pane-triage/__init__.py new file mode 100644 index 000000000..2be7365da --- /dev/null +++ b/skill/pane-triage/__init__.py @@ -0,0 +1,2 @@ +# pane-triage skill +# ruff: noqa: N999 diff --git a/skill/pane-triage/scripts/pane-triage.py b/skill/pane-triage/scripts/pane-triage.py new file mode 100755 index 000000000..97b7058fb --- /dev/null +++ b/skill/pane-triage/scripts/pane-triage.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +""" +pane-triage — approval-gated close of idle panes in the invoking herdr workspace. + +Manual, workspace-scoped complement to the automatic pane-close reaper. Idle +state is classified by the **shared** classifier via the ContextHub bridge CLI +(``packages/herdr/dist/pane-close-cli.js``); this skill owns rendering, the +approval prompt, re-verification, and the closure ledger. It never closes +anything without explicit producer approval. + +Usage: + ./scripts/pane-triage.py --dry-run # report only (default) + ./scripts/pane-triage.py # report, then prompt for approval + ./scripts/pane-triage.py --json # machine-readable report + ./scripts/pane-triage.py --workspace w2V # override workspace detection + +Exit codes: + 0 report rendered (and, if approved, closure completed) + 1 bridge/workspace error (fail-closed, nothing closed) +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from typing import Any + +_SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) +if _SCRIPTS_DIR not in sys.path: + sys.path.insert(0, _SCRIPTS_DIR) + +from pane_triage import ( + BridgeError, + build_report, + classify_via_bridge, + close_selected_panes, + recommend_to_close, + render_ledger, + render_log_tail, + resolve_workspace, + select_approval, +) + +# Number of log lines read from the pane for the tail (newline-delimited). +_PANE_TAIL_LINES = 20 + + +def _read_pane_tail(pane_id: str) -> list[str]: + """Return the last ``_PANE_TAIL_LINES`` lines of a pane's output. + + Fail-safe: an unreadable pane yields an empty list (the caller renders a + placeholder) rather than aborting the report. + """ + try: + proc = subprocess.run( + ["herdr", "pane", "read", pane_id], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except Exception: # noqa: BLE001 - best-effort tail + return [] + if proc.returncode != 0: + return [] + lines = proc.stdout.splitlines() + return lines[-_PANE_TAIL_LINES:] + + +def _invoking_pane_id() -> str | None: + """Return the invoking pane id from the environment, if set.""" + return os.environ.get("HERDR_PANE_ID") or os.environ.get("HERDR_PANE") or None + + +def _print_report( + panes: list[dict[str, Any]], + invoking_pane_id: str | None, + show_tails: bool, +) -> list[dict[str, Any]]: + """Print the report and per-idle-pane tails; return recommendations.""" + print(build_report(panes, invoking_pane_id)) + + if show_tails: + idle = [p for p in panes if p.get("close")] + for pane in idle: + snapshot = pane.get("reasonSnapshot") or {} + needs_review = bool(snapshot.get("needsProducerReview")) + # Prefer the tail supplied by the bridge (it read the session log + # with the shared reader); fall back to `herdr pane read`. + tail = pane.get("sessionTail") or _read_pane_tail(pane.get("paneId", "")) + print(render_log_tail(pane.get("paneTitle", ""), needs_review, tail)) + print() + + recommendations = recommend_to_close(panes, invoking_pane_id) + print("## Recommended to close") + print() + if recommendations: + for index, pane in enumerate(recommendations, start=1): + print(f"{index}. {pane.get('paneId')} — {pane.get('paneTitle')}") + else: + print("_None._") + print() + return recommendations + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--workspace", + default=None, + help="Workspace id (defaults to HERDR_WORKSPACE_ID / herdr pane current).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Report only; never prompt or close (default when not interactive).", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit the report as JSON (no prompts, no closure).", + ) + parser.add_argument( + "--response", + default=None, + help="Non-interactive approval response (yes/select N,M/no).", + ) + parser.add_argument( + "--no-tails", + action="store_true", + help="Skip fetching per-pane log tails.", + ) + args = parser.parse_args(argv) + + try: + workspace = args.workspace or resolve_workspace(os.environ) + invoking_pane_id = _invoking_pane_id() + panes = classify_via_bridge(workspace, invoking_pane_id) + except BridgeError as exc: + print(f"pane-triage: {exc}", file=sys.stderr) + return 1 + + if args.json: + print( + json.dumps( + { + "workspace": workspace, + "invokingPaneId": invoking_pane_id, + "panes": panes, + "recommended": [ + p.get("paneId") + for p in recommend_to_close(panes, invoking_pane_id) + ], + }, + indent=2, + ) + ) + return 0 + + recommendations = _print_report( + panes, invoking_pane_id, show_tails=not args.no_tails + ) + + if args.dry_run or not recommendations: + return 0 + + response = args.response + if response is None: + try: + response = input( + "Close recommended panes? [yes / select N,M / no]: " + ) + except EOFError: + response = "no" + + approved_ids = select_approval( + response, [p.get("paneId", "") for p in recommendations] + ) + if not approved_ids: + print("\nNo panes approved for closure.") + return 0 + + result = close_selected_panes( + approved_pane_ids=approved_ids, + current_panes=panes, + close_fn=_close_pane, + list_panes_fn=lambda: classify_via_bridge(workspace, invoking_pane_id), + invoking_pane_id=invoking_pane_id, + ) + print() + print(render_ledger(result)) + return 0 + + +def _close_pane(pane_id: str) -> bool: + """Close one pane via herdr. Returns True on success (fail-closed).""" + try: + proc = subprocess.run( + ["herdr", "pane", "close", pane_id], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except Exception: # noqa: BLE001 - recorded as failed + return False + return proc.returncode == 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skill/pane-triage/scripts/pane_triage.py b/skill/pane-triage/scripts/pane_triage.py new file mode 100644 index 000000000..1a3568a68 --- /dev/null +++ b/skill/pane-triage/scripts/pane_triage.py @@ -0,0 +1,454 @@ +""" +pane_triage — core logic for the pane-triage skill. + +This module is the approval-gated, workspace-scoped manual complement to the +automatic pane-close reaper (``WL-0MUJW9EEP009Q0V3``). It never reimplements +idle-state logic: classification is delegated to the shared classifier via the +ContextHub bridge CLI (``packages/herdr/dist/pane-close-cli.js --json``), which +imports ``classifySession`` / ``extractFinalAssistantText`` verbatim. + +Responsibilities: + +- ``resolve_workspace`` — env ``HERDR_WORKSPACE_ID`` → ``herdr pane current`` +- ``classify_via_bridge`` — shell out to the shared bridge, parse JSON +- ``build_report`` — render Idle/Active tables + work-item context +- ``render_log_tail`` — last-20-lines tail per idle pane +- ``recommend_to_close`` — never-close guards (invoking pane, review-pending) +- ``select_approval`` — parse the producer's yes/select/no response +- ``close_selected_panes`` — re-verify idle immediately before closing + +The module is import-safe and side-effect free; the CLI wrapper +(``scripts/pane-triage.py``) owns argv parsing and interactive prompts. +""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +from collections.abc import Callable, Iterable, Mapping, Sequence +from typing import Any + +__all__ = [ + "BridgeError", + "build_report", + "classify_via_bridge", + "close_selected_panes", + "parse_workspace_id", + "recommend_to_close", + "render_log_tail", + "resolve_workspace", + "select_approval", +] + +# Default bridge command. The ContextHub bridge accepts ``--json`` and the +# reaper flags. Overridable so tests can inject a fake and operators can point +# at a non-standard checkout. +DEFAULT_BRIDGE_COMMAND = os.path.join( + os.environ.get("CONTEXTHUB_DIR", os.path.expanduser("~/projects/ContextHub")), + "packages", + "herdr", + "dist", + "pane-close-cli.js", +) + +# Number of log lines to show per idle pane (AC4). +LOG_TAIL_LINES = 20 + +# Bridge command timeout (seconds). Generous: the bridge reads many session +# files and may call ``wl show`` per pane. +BRIDGE_TIMEOUT_S = 120 + + +class BridgeError(RuntimeError): + """Raised when the shared pane-close bridge cannot be invoked or parsed. + + Fail-closed: callers must not proceed to closure when the bridge fails, + because classification evidence is unavailable. + """ + + +# --------------------------------------------------------------------------- +# AC1 — workspace resolution +# --------------------------------------------------------------------------- + +def parse_workspace_id(env: Mapping[str, str]) -> str | None: + """Return the workspace id from ``HERDR_WORKSPACE_ID``, or ``None``. + + Blank/whitespace values are treated as absent so a stray empty export does + not shadow the ``herdr pane current`` fallback. + """ + value = (env.get("HERDR_WORKSPACE_ID") or "").strip() + return value or None + + +def _extract_json(raw: str) -> Any: + """Parse the first JSON value in *raw*, tolerating leading log lines.""" + starts = [i for i in (raw.find("{"), raw.find("[")) if i >= 0] + if not starts: + raise BridgeError(f"no JSON found in output: {raw[:200]!r}") + try: + return json.loads(raw[min(starts):]) + except json.JSONDecodeError as exc: + raise BridgeError(f"invalid JSON in output: {exc}") from exc + + +def resolve_workspace( + env: Mapping[str, str] | None = None, + run_herdr_current: Callable[[], str] | None = None, +) -> str: + """Resolve the invoking workspace id (AC1). + + Precedence: + 1. ``HERDR_WORKSPACE_ID`` environment variable. + 2. ``herdr pane current`` → ``result.pane.workspace_id``. + + Raises ``BridgeError`` when neither yields a workspace id, so the skill + fails closed rather than reporting on every workspace. + """ + env = env if env is not None else os.environ + from_env = parse_workspace_id(env) + if from_env: + return from_env + + runner = run_herdr_current or _run_herdr_current + try: + raw = runner() + except Exception as exc: + raise BridgeError(f"`herdr pane current` failed: {exc}") from exc + payload = _extract_json(raw) + pane = ((payload or {}).get("result") or {}).get("pane") or {} + workspace_id = pane.get("workspace_id") or pane.get("workspaceId") + if not workspace_id: + raise BridgeError( + "could not resolve workspace id from HERDR_WORKSPACE_ID or " + "`herdr pane current`" + ) + return str(workspace_id) + + +def _run_herdr_current() -> str: + """Run ``herdr pane current`` and return stdout (fail-closed).""" + proc = subprocess.run( + ["herdr", "pane", "current"], + capture_output=True, + text=True, + timeout=BRIDGE_TIMEOUT_S, + check=False, + ) + if proc.returncode != 0: + raise BridgeError( + f"`herdr pane current` exited {proc.returncode}: {proc.stderr.strip()}" + ) + return proc.stdout + + +# --------------------------------------------------------------------------- +# AC2 — classification via the shared bridge (never reimplemented) +# --------------------------------------------------------------------------- + +def _run_bridge(args: Sequence[str]) -> str: + """Invoke the shared bridge CLI and return stdout. + + The bridge is the only sanctioned path to the shared classifier. It is + invoked with ``--json`` so stdout is a single machine-parseable document. + """ + command = os.environ.get("PANE_TRIAGE_BRIDGE", DEFAULT_BRIDGE_COMMAND) + if not os.path.exists(command): + raise BridgeError( + f"pane-close bridge not found at {command!r}; build ContextHub " + "(`cd packages/herdr && npm run build`) or set PANE_TRIAGE_BRIDGE" + ) + proc = subprocess.run( + ["node", command, *args], + capture_output=True, + text=True, + timeout=BRIDGE_TIMEOUT_S, + check=False, + ) + if proc.returncode != 0: + raise BridgeError( + f"pane-close bridge exited {proc.returncode}: {proc.stderr.strip()}" + ) + return proc.stdout + + +def classify_via_bridge( + workspace: str | None = None, + invoking_pane_id: str | None = None, + extra_args: Sequence[str] | None = None, +) -> list[dict[str, Any]]: + """Return per-pane classification records from the shared bridge (AC2). + + The bridge runs the reaper in ``--dry-run`` mode (no closure) and emits + JSON: ``{panes: [{paneId, paneTitle, itemId, workspaceId, kind, close, + reasonCode, reasonSnapshot, success}], ...}``. + + Raises ``BridgeError`` on invocation or parse failure (fail-closed). + """ + args = ["--json", "--dry-run"] + if workspace: + args += ["--workspace", workspace] + if extra_args: + args += list(extra_args) + + raw = _run_bridge(args) + payload = _extract_json(raw) + if not isinstance(payload, dict) or not isinstance(payload.get("panes"), list): + raise BridgeError("bridge output missing a `panes` array") + return [p for p in payload["panes"] if isinstance(p, dict)] + + +def _is_invoking(pane: Mapping[str, Any], invoking_pane_id: str | None) -> bool: + """Whether *pane* is the invoking pane (by id or classifier flag).""" + if invoking_pane_id and pane.get("paneId") == invoking_pane_id: + return True + snapshot = pane.get("reasonSnapshot") or {} + return bool(snapshot.get("isInvokingPane")) + + +def _needs_review(pane: Mapping[str, Any]) -> bool: + """Whether the pane's work item is review-pending (never close).""" + snapshot = pane.get("reasonSnapshot") or {} + return bool(snapshot.get("needsProducerReview")) + + +# --------------------------------------------------------------------------- +# AC3 — report rendering +# --------------------------------------------------------------------------- + +def _md_table(headers: Sequence[str], rows: Iterable[Sequence[str]]) -> str: + """Render a GitHub-flavoured markdown table.""" + out = ["| " + " | ".join(headers) + " |"] + out.append("| " + " | ".join("---" for _ in headers) + " |") + for row in rows: + out.append("| " + " | ".join(str(c) for c in row) + " |") + return "\n".join(out) + + +def build_report( + panes: Sequence[Mapping[str, Any]], + invoking_pane_id: str | None, +) -> str: + """Render the Idle/Active report with work-item context (AC3). + + Sections: + - ``## Idle`` — panes the shared classifier flagged ``close=true`` + - ``## Active`` — all other panes, with the classifier reason + """ + idle = [p for p in panes if p.get("close")] + active = [p for p in panes if not p.get("close")] + + lines: list[str] = [] + lines.append(f"# Pane Triage — {len(panes)} pane(s) evaluated") + lines.append("") + lines.append("## Idle") + lines.append("") + if idle: + lines.append( + _md_table( + ["Pane", "Tab", "Title", "Work Item", "Needs Producer Review", "Reason"], + [ + ( + p.get("paneId", ""), + p.get("tabId") or "—", + p.get("paneTitle", ""), + p.get("itemId") or "—", + str(_needs_review(p)), + p.get("reasonCode", ""), + ) + for p in idle + ], + ) + ) + else: + lines.append("_None._") + lines.append("") + lines.append("## Active") + lines.append("") + if active: + lines.append( + _md_table( + ["Pane", "Tab", "Title", "Work Item", "Needs Producer Review", "Reason"], + [ + ( + p.get("paneId", ""), + p.get("tabId") or "—", + p.get("paneTitle", ""), + p.get("itemId") or "—", + str(_needs_review(p)), + p.get("reasonCode", ""), + ) + for p in active + ], + ) + ) + else: + lines.append("_None._") + lines.append("") + return "\n".join(lines) + + +def render_log_tail( + pane_title: str, + needs_review: bool, + lines: Sequence[str], + tail: int = LOG_TAIL_LINES, +) -> str: + """Render the last *tail* log lines under the required heading (AC4). + + Heading format: ``<pane title> Needs Review <true|false>``. + """ + trimmed = list(lines)[-tail:] + body = "\n".join(trimmed) if trimmed else "(no log output)" + review_flag = "True" if needs_review else "False" + return f"### {pane_title} Needs Review {review_flag}\n\n```\n{body}\n```" + + +# --------------------------------------------------------------------------- +# AC5 — recommendation rules (never-close guards apply universally) +# --------------------------------------------------------------------------- + +def recommend_to_close( + panes: Sequence[Mapping[str, Any]], + invoking_pane_id: str | None, +) -> list[Mapping[str, Any]]: + """Return idle panes eligible for the "Recommended to close" table. + + Exclusions (never-close guards, universal — manual + automatic reaper): + - the invoking pane (by id, and defensively by classifier flag) + - panes whose work item has ``needsProducerReview=true`` + - panes the classifier did not flag ``close`` + """ + recommendations: list[Mapping[str, Any]] = [] + for pane in panes: + if not pane.get("close"): + continue + if _is_invoking(pane, invoking_pane_id): + continue + if _needs_review(pane): + continue + recommendations.append(pane) + return recommendations + + +# --------------------------------------------------------------------------- +# AC6 — approval parsing (no approval → close nothing) +# --------------------------------------------------------------------------- + +def select_approval( + response: str, + recommended_pane_ids: Sequence[str], +) -> list[str]: + """Parse the producer's approval response (AC6). + + Returns the list of pane ids to close: + - ``yes`` / ``y`` → all recommended panes + - ``select 1,3`` / ``1 3`` → the indexed recommended panes (1-based) + - anything else (incl. ``no``) → ``[]`` (closes nothing on ambiguity) + """ + text = (response or "").strip().lower() + if not text: + return [] + if text in ("yes", "y"): + return list(recommended_pane_ids) + if text in ("no", "n"): + return [] + if text.startswith("select"): + text = text[len("select"):].strip() + tokens = [t for t in text.replace(",", " ").split() if t] + selected: list[str] = [] + for token in tokens: + if not token.isdigit(): + return [] # ambiguous input closes nothing + index = int(token) + if 1 <= index <= len(recommended_pane_ids): + selected.append(recommended_pane_ids[index - 1]) + return selected + + +# --------------------------------------------------------------------------- +# AC7 — re-verify before close +# --------------------------------------------------------------------------- + +def close_selected_panes( + approved_pane_ids: Sequence[str], + current_panes: Sequence[Mapping[str, Any]], + close_fn: Callable[[str], bool], + list_panes_fn: Callable[[], Any], + invoking_pane_id: str | None = None, +) -> dict[str, list[Any]]: + """Close approved panes after re-verifying each is still idle (AC7). + + Immediately before closing, the pane list is re-read via *list_panes_fn* + (which must return a bridge document or a pane list). Any approved pane + that is no longer idle — or that has become the invoking pane — is skipped + with a reason. The invoking pane is never closed. + + Returns ``{"closed": [...], "skipped": [{"paneId", "reason"}], ...}``. + """ + approved = list(approved_pane_ids) + if not approved: + return {"closed": [], "skipped": [], "failed": []} + + fresh = list_panes_fn() + if isinstance(fresh, dict) and isinstance(fresh.get("panes"), list): + fresh_panes = [p for p in fresh["panes"] if isinstance(p, dict)] + else: + fresh_panes = [p for p in (fresh or []) if isinstance(p, dict)] + by_id = {p.get("paneId"): p for p in fresh_panes} + + closed: list[str] = [] + skipped: list[dict[str, str]] = [] + failed: list[dict[str, str]] = [] + + for pane_id in approved: + if invoking_pane_id and pane_id == invoking_pane_id: + skipped.append({"paneId": pane_id, "reason": "invoking pane"}) + continue + current = by_id.get(pane_id) + if current is None: + skipped.append({"paneId": pane_id, "reason": "pane no longer exists"}) + continue + if not current.get("close"): + skipped.append( + { + "paneId": pane_id, + "reason": f"no longer idle ({current.get('reasonCode', 'unknown')})", + } + ) + continue + if _needs_review(current): + skipped.append({"paneId": pane_id, "reason": "needs producer review"}) + continue + try: + ok = close_fn(pane_id) + except Exception as exc: # noqa: BLE001 - recorded, not fatal + failed.append({"paneId": pane_id, "reason": str(exc)}) + continue + if ok: + closed.append(pane_id) + else: + failed.append({"paneId": pane_id, "reason": "close returned false"}) + + return {"closed": closed, "skipped": skipped, "failed": failed} + + +def render_ledger(result: Mapping[str, Sequence[Any]]) -> str: + """Render the closure ledger as markdown (AC7).""" + lines = ["## Closed", ""] + for pane_id in result.get("closed", []): + lines.append(f"- {pane_id} — closed") + for entry in result.get("skipped", []): + lines.append(f"- {entry.get('paneId')} — skipped: {entry.get('reason')}") + for entry in result.get("failed", []): + lines.append(f"- {entry.get('paneId')} — failed: {entry.get('reason')}") + if len(lines) == 2: + lines.append("_Nothing closed._") + return "\n".join(lines) + + +def shell_quote(argv: Sequence[str]) -> str: + """Return a shell-safe rendering of *argv* (for error/audit messages).""" + return " ".join(shlex.quote(a) for a in argv) diff --git a/skill/pane-triage/tests/test_pane_triage.py b/skill/pane-triage/tests/test_pane_triage.py new file mode 100755 index 000000000..541807253 --- /dev/null +++ b/skill/pane-triage/tests/test_pane_triage.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +Offline fixture tests for the pane-triage skill core module. + +These tests exercise the skill's *logic* against fixture data that models the +JSON emitted by the shared pane-close bridge CLI +(``packages/herdr/dist/pane-close-cli.js --json``). They do **not** invoke the +shared classifier themselves — the classification (`close` / `reasonCode`) is +produced by the bridge and consumed here. The tests assert that the skill +faithfully maps that shared output into its report, recommendation, and +approval-gated closure behaviour. + +Coverage maps to the work-item acceptance criteria +(``WL-0MUJMXVPO0016DZM``): + +- AC1 workspace resolution (env → ``herdr pane current`` fallback) +- AC2 idle classification via shared classifier (bridge output) +- AC3 Idle/Active tables with work-item id + ``needsProducerReview`` +- AC4 last-20-lines log tail per idle pane +- AC5 "Recommended to close" excludes invoking pane and review-pending +- AC6 approval gating: no approval → zero close calls +- AC7 re-verify before close drops panes that became active +- AC8 the above fixtures cover classification, marker-mid-message, + work-item-id parsing, review mapping, recommendation rules, + approval gating +""" + +import json +import os +import sys +import unittest +from unittest import mock + +# Make the skill's scripts importable regardless of the invocation directory. +_SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if os.path.join(_SKILL_DIR, "scripts") not in sys.path: + sys.path.insert(0, os.path.join(_SKILL_DIR, "scripts")) + +from pane_triage import ( + BridgeError, + build_report, + classify_via_bridge, + close_selected_panes, + parse_workspace_id, + recommend_to_close, + render_log_tail, + resolve_workspace, + select_approval, +) + +# --------------------------------------------------------------------------- +# Fixture helpers +# --------------------------------------------------------------------------- + +def _pane( + pane_id="wp:p1", + title="Downtime triggered plan Foo - WL-0ABC123", + item_id="WL-0ABC123", + workspace="w1", + tab="wp:t1", + kind="plan", + close=False, + reason_code="active", + success=True, + needs_producer_review=False, + is_invoking=False, + session_tail=None, +): + """Build one bridge-output pane record.""" + return { + "paneId": pane_id, + "paneTitle": title, + "itemId": item_id, + "workspaceId": workspace, + "tabId": tab, + "kind": kind, + "close": close, + "reasonCode": reason_code, + "reasonSnapshot": { + "kind": kind, + "needsProducerReview": needs_producer_review, + "isInvokingPane": is_invoking, + }, + "sessionTail": session_tail or [], + "success": success, + } + + +def _bridge_doc(panes, close_count=None): + """Build a full bridge JSON document.""" + return { + "panes": panes, + "evaluated": len(panes), + "closeCount": close_count if close_count is not None + else sum(1 for p in panes if p.get("close")), + "failureCount": 0, + "dryRun": True, + } + + +# --------------------------------------------------------------------------- +# AC1 — workspace resolution +# --------------------------------------------------------------------------- + +class TestParseWorkspaceId(unittest.TestCase): + def test_reads_herdr_workspace_id_env(self): + self.assertEqual( + parse_workspace_id({"HERDR_WORKSPACE_ID": "w2V"}), "w2V" + ) + + def test_returns_none_when_env_absent(self): + self.assertIsNone(parse_workspace_id({})) + + def test_blank_env_is_treated_as_absent(self): + self.assertIsNone(parse_workspace_id({"HERDR_WORKSPACE_ID": " "})) + + +class TestResolveWorkspace(unittest.TestCase): + def test_prefers_env_over_herdr(self): + result = resolve_workspace( + env={"HERDR_WORKSPACE_ID": "w2V"}, + run_herdr_current=lambda: "w9Z", + ) + self.assertEqual(result, "w2V") + + def test_falls_back_to_herdr_pane_current(self): + raw = json.dumps( + {"id": "cli:pane:current", "result": {"pane": {"workspace_id": "w3G"}}} + ) + result = resolve_workspace(env={}, run_herdr_current=lambda: raw) + self.assertEqual(result, "w3G") + + def test_returns_none_when_neither_available(self): + with self.assertRaises(BridgeError): + resolve_workspace(env={}, run_herdr_current=lambda: "not json") + + +# --------------------------------------------------------------------------- +# AC2/AC3 — classification & report tables +# --------------------------------------------------------------------------- + +class TestBuildReport(unittest.TestCase): + def test_idle_and_active_tables_split_by_shared_close_flag(self): + panes = [ + _pane(pane_id="wp:p1", close=True, reason_code="marker", + item_id="WL-0AAA111"), + _pane(pane_id="wp:p2", close=False, reason_code="active", + item_id="WL-0BBB222", title="Downtime triggered intake Bar - WL-0BBB222"), + ] + report = build_report(panes, invoking_pane_id="wp:pX") + self.assertIn("Idle", report) + self.assertIn("Active", report) + # Idle table carries the work-item id. + self.assertIn("WL-0AAA111", report) + # Active table carries the other work-item id. + self.assertIn("WL-0BBB222", report) + + def test_report_includes_tab_column(self): + panes = [_pane(pane_id="wp:p1", tab="wp:t9", close=True, + reason_code="marker")] + report = build_report(panes, invoking_pane_id="wp:pX") + self.assertIn("Tab", report) + self.assertIn("wp:t9", report) + + def test_report_includes_needs_producer_review_value(self): + panes = [ + _pane(pane_id="wp:p1", close=True, reason_code="marker", + needs_producer_review=True, item_id="WL-0RVW333"), + ] + report = build_report(panes, invoking_pane_id="wp:pX") + self.assertIn("WL-0RVW333", report) + # The review flag must be surfaced so the producer can decide. + self.assertRegex(report, r"(?i)needs.?producer.?review") + + def test_marker_mid_message_stays_active(self): + # The shared classifier emits close=false/'active' for a mid-message + # marker; the skill must keep it in Active. + panes = [_pane(pane_id="wp:p1", close=False, reason_code="active")] + report = build_report(panes, invoking_pane_id="wp:pX") + idle_section, active_section = _split_sections(report) + self.assertNotIn("wp:p1", idle_section) + self.assertIn("wp:p1", active_section) + + def test_implement_pane_is_active_not_idle(self): + panes = [_pane(pane_id="wp:p1", close=False, reason_code="implement", + kind="implement")] + report = build_report(panes, invoking_pane_id="wp:pX") + idle_section, _ = _split_sections(report) + self.assertNotIn("wp:p1", idle_section) + + +def _split_sections(report): + """Split a rendered report into (idle_section, active_section).""" + idle_start = report.index("## Idle") + active_start = report.index("## Active") + return report[idle_start:active_start], report[active_start:] + + +# --------------------------------------------------------------------------- +# AC4 — log tail rendering +# --------------------------------------------------------------------------- + +class TestRenderLogTail(unittest.TestCase): + def test_prints_last_20_lines_under_heading(self): + lines = [f"line {i}" for i in range(1, 31)] + rendered = render_log_tail("My Pane", needs_review=False, lines=lines) + self.assertIn("My Pane Needs Review False", rendered) + # Only the last 20 lines appear. + self.assertIn("line 11", rendered) + self.assertIn("line 30", rendered) + self.assertNotIn("line 10\n", rendered) + + def test_heading_reports_true_for_review_pending(self): + rendered = render_log_tail("My Pane", needs_review=True, lines=["x"]) + self.assertIn("My Pane Needs Review True", rendered) + + +# --------------------------------------------------------------------------- +# AC5 — recommendation rules +# --------------------------------------------------------------------------- + +class TestRecommendToClose(unittest.TestCase): + def test_recommends_idle_pane(self): + panes = [_pane(pane_id="wp:p1", close=True, reason_code="marker")] + recs = recommend_to_close(panes, invoking_pane_id="wp:pX") + self.assertEqual([p["paneId"] for p in recs], ["wp:p1"]) + + def test_excludes_the_invoking_pane(self): + panes = [_pane(pane_id="wp:pX", close=True, reason_code="marker", + is_invoking=True)] + recs = recommend_to_close(panes, invoking_pane_id="wp:pX") + self.assertEqual(recs, []) + + def test_excludes_review_pending_even_when_idle(self): + panes = [_pane(pane_id="wp:p1", close=True, reason_code="marker", + needs_producer_review=True)] + recs = recommend_to_close(panes, invoking_pane_id="wp:pX") + self.assertEqual(recs, []) + + def test_excludes_active_panes(self): + panes = [_pane(pane_id="wp:p1", close=False, reason_code="active")] + recs = recommend_to_close(panes, invoking_pane_id="wp:pX") + self.assertEqual(recs, []) + + def test_invoking_pane_never_recommended_even_if_not_flagged(self): + # Defensive: the invoking pane is excluded by id regardless of the + # classifier's isInvokingPane flag. + panes = [_pane(pane_id="wp:pX", close=True, reason_code="marker", + is_invoking=False)] + recs = recommend_to_close(panes, invoking_pane_id="wp:pX") + self.assertEqual(recs, []) + + +# --------------------------------------------------------------------------- +# AC6 — approval gating +# --------------------------------------------------------------------------- + +class TestSelectApproval(unittest.TestCase): + def test_no_closes_nothing(self): + self.assertEqual( + select_approval("no", ["wp:p1", "wp:p2"]), [] + ) + + def test_ambiguous_input_closes_nothing(self): + self.assertEqual(select_approval("maybe", ["wp:p1"]), []) + self.assertEqual(select_approval("", ["wp:p1"]), []) + + def test_yes_selects_all_recommended(self): + self.assertEqual( + select_approval("yes", ["wp:p1", "wp:p2"]), ["wp:p1", "wp:p2"] + ) + + def test_select_parses_indices(self): + self.assertEqual( + select_approval("select 1,3", ["wp:p1", "wp:p2", "wp:p3"]), + ["wp:p1", "wp:p3"], + ) + + def test_select_ignores_out_of_range(self): + self.assertEqual( + select_approval("select 9", ["wp:p1"]), [] + ) + + +class TestNoApprovalMeansZeroCloseCalls(unittest.TestCase): + def test_no_approval_never_calls_close(self): + close_calls = [] + + def fake_close(pane_id): + close_calls.append(pane_id) + return True + + result = close_selected_panes( + approved_pane_ids=[], + current_panes=[_pane(close=True, reason_code="marker")], + close_fn=fake_close, + list_panes_fn=lambda: _bridge_doc([_pane(close=True)]), + ) + self.assertEqual(close_calls, []) + self.assertEqual(result["closed"], []) + + +# --------------------------------------------------------------------------- +# AC7 — re-verify before close +# --------------------------------------------------------------------------- + +class TestCloseSelectedPanes(unittest.TestCase): + def test_reverifies_and_drops_pane_that_became_active(self): + calls = [] + + # Before approval the pane was idle; on re-verify it is active. + reverify_doc = _bridge_doc([ + _pane(pane_id="wp:p1", close=False, reason_code="active"), + ]) + result = close_selected_panes( + approved_pane_ids=["wp:p1"], + current_panes=[_pane(pane_id="wp:p1", close=True, reason_code="marker")], + close_fn=lambda pid: calls.append(pid) or True, + list_panes_fn=lambda: reverify_doc, + ) + self.assertEqual(calls, []) + self.assertEqual(result["closed"], []) + self.assertEqual(len(result["skipped"]), 1) + self.assertEqual(result["skipped"][0]["paneId"], "wp:p1") + + def test_closes_pane_still_idle_on_reverify(self): + calls = [] + reverify_doc = _bridge_doc([ + _pane(pane_id="wp:p1", close=True, reason_code="marker"), + ]) + result = close_selected_panes( + approved_pane_ids=["wp:p1"], + current_panes=[_pane(pane_id="wp:p1", close=True, reason_code="marker")], + close_fn=lambda pid: calls.append(pid) or True, + list_panes_fn=lambda: reverify_doc, + ) + self.assertEqual(calls, ["wp:p1"]) + self.assertEqual(result["closed"], ["wp:p1"]) + + def test_never_closes_invoking_pane(self): + calls = [] + reverify_doc = _bridge_doc([ + _pane(pane_id="wp:pX", close=True, reason_code="marker"), + ]) + result = close_selected_panes( + approved_pane_ids=["wp:pX"], + current_panes=[_pane(pane_id="wp:pX", close=True, reason_code="marker")], + close_fn=lambda pid: calls.append(pid) or True, + list_panes_fn=lambda: reverify_doc, + invoking_pane_id="wp:pX", + ) + self.assertEqual(calls, []) + self.assertEqual(result["closed"], []) + + +# --------------------------------------------------------------------------- +# Bridge integration (shared classifier, not reimplemented) +# --------------------------------------------------------------------------- + +class TestClassifyViaBridge(unittest.TestCase): + def test_parses_bridge_json(self): + payload = json.dumps(_bridge_doc([_pane(close=True)])) + with mock.patch("pane_triage._run_bridge", return_value=payload): + panes = classify_via_bridge(workspace="w1") + self.assertEqual(len(panes), 1) + self.assertTrue(panes[0]["close"]) + + def test_raises_bridge_error_on_unparseable_output(self): + with mock.patch("pane_triage._run_bridge", return_value="not json"), \ + self.assertRaises(BridgeError): + classify_via_bridge(workspace="w1") + + def test_workspace_is_passed_to_bridge(self): + captured = {} + + def fake_bridge(args): + captured["args"] = args + return json.dumps(_bridge_doc([_pane(close=True)])) + + with mock.patch("pane_triage._run_bridge", side_effect=fake_bridge): + classify_via_bridge(workspace="w7X") + self.assertIn("--workspace", captured["args"]) + self.assertIn("w7X", captured["args"]) + + def test_session_tail_is_preserved_from_bridge(self): + pane = _pane(close=True, reason_code="marker", + session_tail=["line 1", "line 2"]) + payload = json.dumps(_bridge_doc([pane])) + with mock.patch("pane_triage._run_bridge", return_value=payload): + panes = classify_via_bridge(workspace="w1") + self.assertEqual(panes[0]["sessionTail"], ["line 1", "line 2"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/cli-types.ts b/src/cli-types.ts index 49f9865a8..b7be0139f 100644 --- a/src/cli-types.ts +++ b/src/cli-types.ts @@ -196,7 +196,17 @@ export interface DeleteOptions { prefix?: string; recursive?: boolean; sync?: bo export interface ReviewedOptions { prefix?: string } -export interface InterviewOptions { prefix?: string } +export interface InterviewOptions { + prefix?: string; + /** Non-interactive JSON output mode (no prompts, no mutation). */ + json?: boolean; + /** Commander maps the `--no-llm` flag to `llm: false`. */ + llm?: boolean; + /** Test-harness spelling of `--no-llm`. */ + noLlm?: boolean; + /** Override the chat model used for the producer-review explanation. */ + model?: string; +} export interface DepOptions { prefix?: string; diff --git a/src/cli.ts b/src/cli.ts index caaf12e1c..0f1772e58 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -227,7 +227,7 @@ program.hook('preAction', () => { // Propagate the global --verbose flag into WL_VERBOSE so code paths that // detect verbosity via process.env or that run outside Commander can pick - // it up (e.g. background submitToOpenBrain). Use string '1' for truthy. + // it up. Use string '1' for truthy. try { const opts = program.opts(); if (opts && opts.verbose) { diff --git a/src/commands/close.ts b/src/commands/close.ts index cc4f074e8..b62b3c8d5 100644 --- a/src/commands/close.ts +++ b/src/commands/close.ts @@ -28,7 +28,7 @@ import type { WorkItem } from '../types.js'; import type { PluginContext } from '../plugin-types.js'; import type { CloseOptions } from '../cli-types.js'; -import { submitToOpenBrain } from '../openbrain.js'; + import { classifyAuditGap, type ParentAuditState } from '@worklog/shared/icons'; /** @@ -287,15 +287,6 @@ export default function register(ctx: PluginContext): void { result.auditGapWarnings = auditGapWarnings; } results.push(result); - - // Fire-and-forget: submit a summary to OpenBrain if enabled. - const config = utils.getConfig(); - if (config?.openBrainEnabled) { - submitToOpenBrain(updated).catch(() => { - // Errors are already logged inside submitToOpenBrain; swallow here - // so the close command is never blocked or aborted. - }); - } } else { // No children — standard single-item close (flag is a no-op) recordForceWaiver(item, reason, author, db); @@ -305,14 +296,6 @@ export default function register(ctx: PluginContext): void { continue; } results.push({ id, success: true }); - - const config = utils.getConfig(); - if (config?.openBrainEnabled) { - submitToOpenBrain(updated).catch(() => { - // Errors are already logged inside submitToOpenBrain; swallow here - // so the close command is never blocked or aborted. - }); - } } // ── Audit-gated recursive close ── } else if (shouldCloseRecursively(item, db)) { @@ -341,15 +324,6 @@ export default function register(ctx: PluginContext): void { result.auditGapWarnings = auditGapWarnings; } results.push(result); - - // Fire-and-forget: submit a summary to OpenBrain if enabled. - const config = utils.getConfig(); - if (config?.openBrainEnabled) { - submitToOpenBrain(updated).catch(() => { - // Errors are already logged inside submitToOpenBrain; swallow here - // so the close command is never blocked or aborted. - }); - } // ── Recovery path ── } else if (shouldRecoverOpenChildren(item, db)) { // Recovery path: parent is already completed/done but has open children. @@ -370,10 +344,6 @@ export default function register(ctx: PluginContext): void { } results.push(result); - // No OpenBrain submission for the recovery path: the parent was - // already done and presumably submitted to OpenBrain previously. - // Children were closed individually but each closeSingle does not - // trigger OpenBrain (consistent with the recursive close pattern). } else { // Standard (non-recursive) close — existing behaviour const auditGapWarning = auditGapWarningFor(item, db); @@ -409,14 +379,6 @@ export default function register(ctx: PluginContext): void { } } - // Fire-and-forget: submit a summary to OpenBrain if enabled. - const config = utils.getConfig(); - if (config?.openBrainEnabled) { - submitToOpenBrain(updated).catch(() => { - // Errors are already logged inside submitToOpenBrain; swallow here - // so the close command is never blocked or aborted. - }); - } } } diff --git a/src/commands/interview.ts b/src/commands/interview.ts index 2cb95bc04..dd7ae6e24 100644 --- a/src/commands/interview.ts +++ b/src/commands/interview.ts @@ -28,7 +28,9 @@ import type { PluginContext } from '../plugin-types.js'; import type { InterviewOptions } from '../cli-types.js'; -import type { WorkItem } from '../types.js'; +import type { WorkItem, Comment } from '../types.js'; +import { OpenAIChatClient, type ChatClient } from '../lib/llm.js'; +import { resolveLlmConfig } from '../config.js'; import * as readline from 'readline'; // ── Section markers ────────────────────────────────────────────────────── @@ -58,6 +60,18 @@ const ANSWER_LINE_RE = /** An explicit "awaiting producer" placeholder. */ const PLACEHOLDER_RE = /^\*{0,2}\(?\s*awaiting producer\s*\)?\*{0,2}/i; +/** + * An explicit "OPEN QUESTION" placeholder used by the intake/plan skills + * to mark a question that the producer has not yet answered, e.g. + * `— **OPEN QUESTION**, context: ...`. + */ +const OPEN_QUESTION_RE = /^\*{0,2}\(?OPEN\s+QUESTION\)?\*{0,2}/i; +/** + * An inline `— **OPEN QUESTION**` answer marker on a continuation line, + * capturing the placeholder token itself in group 1. + */ +const INLINE_OPEN_QUESTION_RE = + /\s*[—–-]\s*(\*{0,2}\(?OPEN\s+QUESTION\)?\*{0,2})/i; /** A "TBD" answer is treated as outstanding. */ const TBD_RE = /^\*{0,2}(?:tbd|to be determined)\b/i; /** Trailing attribution metadata that shares the answer line. */ @@ -250,7 +264,7 @@ function computeAnswerRegion( // Detect placeholders before stripping bold markers so the whole token is // replaced when the producer answers. const raw = content.slice(s, e); - const placeholder = raw.match(PLACEHOLDER_RE); + const placeholder = raw.match(PLACEHOLDER_RE) ?? raw.match(OPEN_QUESTION_RE); if (placeholder) { return { start: s, @@ -281,7 +295,8 @@ function computeAnswerRegion( // Placeholders/TBD may sit inside quotes. const inner = content.slice(s, e); - const innerPlaceholder = inner.match(PLACEHOLDER_RE); + const innerPlaceholder = + inner.match(PLACEHOLDER_RE) ?? inner.match(OPEN_QUESTION_RE); if (innerPlaceholder) { return { start: s, @@ -358,12 +373,21 @@ export function parseQAPairs(content: string): ClarifyingQAPair[] { let answerLine = cur.line; const inline = cur.remainder.match(INLINE_ANSWER_RE); - if (inline) { - question = cleanQuestion(cur.remainder.slice(0, inline.index)); - const tail = inline[2] ?? ''; + const inlineOpen = inline ? null : cur.remainder.match(INLINE_OPEN_QUESTION_RE); + if (inline || inlineOpen) { + const isOpen = !inline; + const match = (inline ?? inlineOpen)!; + const markerIdx = match.index ?? 0; + const markerLen = match[0].length; + const placeholder = isOpen ? (match[1] ?? '') : ''; + const tail = isOpen ? '' : (match[2] ?? ''); + question = cleanQuestion(cur.remainder.slice(0, markerIdx)); const rawStart = - cur.remainderStart + (inline.index ?? 0) + inline[0].length - tail.length; - const rawEnd = cur.remainderStart + (inline.index ?? 0) + inline[0].length; + cur.remainderStart + + markerIdx + + markerLen - + (isOpen ? placeholder.length : tail.length); + const rawEnd = cur.remainderStart + markerIdx + markerLen; region = computeAnswerRegion(content, rawStart, rawEnd); } else { let found: { @@ -402,6 +426,20 @@ export function parseQAPairs(content: string): ClarifyingQAPair[] { }; break; } + // An unresolved question uses an inline `— **OPEN QUESTION**` + // placeholder, which may sit on a continuation line. + const om = lines[j].match(INLINE_OPEN_QUESTION_RE); + if (om) { + const placeholder = om[1] ?? ''; + const idx = om.index ?? 0; + found = { + line: j, + start: lineStart[j] + idx + om[0].length - placeholder.length, + end: lineStart[j] + idx + om[0].length, + before: lines[j].slice(0, idx), + }; + break; + } } if (found) { @@ -411,7 +449,12 @@ export function parseQAPairs(content: string): ClarifyingQAPair[] { region = computeAnswerRegion(content, found.start, found.end); answerLine = found.line; } else { - question = cleanQuestion(cur.remainder); + // No answer marker: treat every line up to the next question as part + // of the question so multi-line questions are not truncated. + const endLine = + idx + 1 < starts.length ? starts[idx + 1].line : lines.length; + const continuation = lines.slice(cur.line + 1, endLine).join(' '); + question = cleanQuestion(`${cur.remainder} ${continuation}`); region = { start: sliceEnd, end: sliceEnd, @@ -484,6 +527,137 @@ export function rebuildDescription( return [...before, newContent, ...after].join('\n').trim(); } +// ── Producer-review explanation ───────────────────────────────────────── + +/** + * Instruction sent to the LLM when explaining how to clear the + * `needsProducerReview` flag (parent AC2). + */ +export const PRODUCER_REVIEW_INSTRUCTION = + 'Explain what the producer needs to do in order to remove the needsProducerReview flag'; + +/** Maximum size (bytes) of the item context embedded in the prompt. */ +export const MAX_PROMPT_CONTEXT_BYTES = 8192; + +/** Maximum length (characters) of the normalised explanation line. */ +export const MAX_EXPLANATION_LENGTH = 500; + +/** Default timeout (ms) for the explanation LLM call. */ +export const EXPLANATION_TIMEOUT_MS = 15000; + +/** + * Dependencies for {@link buildProducerReviewExplanation}. The chat client is + * injected so callers (and tests) never make network calls implicitly. + */ +export interface ProducerReviewExplanationDeps { + /** Newest-first comments for the item (`getCommentsForWorkItem` order). */ + comments: Comment[]; + /** Chat client to use; `null`/`undefined` disables the LLM path. */ + chatClient?: ChatClient | null; + /** When true, skip the LLM entirely and use the comment-based fallback. */ + noLlm?: boolean; + /** Per-call timeout override in milliseconds. */ + timeoutMs?: number; +} + +/** + * Collapse whitespace so an explanation renders as a single bounded line. + * Newlines (from either path) are collapsed; over-long text is truncated. + */ +export function normaliseExplanation( + text: string, + maxLength: number = MAX_EXPLANATION_LENGTH, +): string { + const collapsed = text.replace(/\s+/g, ' ').trim(); + if (collapsed.length <= maxLength) return collapsed; + return collapsed.slice(0, maxLength - 1).trimEnd() + '…'; +} + +/** First non-empty line of a comment body, trimmed. */ +function firstLine(text: string): string { + return text.split('\n').map(line => line.trim()).find(line => line.length > 0) ?? ''; +} + +/** Truncate `text` to at most `maxBytes` UTF-8 bytes without splitting a char. */ +function truncateBytes(text: string, maxBytes: number): string { + if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text; + let result = text; + while (result.length > 0 && Buffer.byteLength(result, 'utf8') > maxBytes) { + result = result.slice(0, -1); + } + return result; +} + +/** + * Build the LLM prompt: the fixed instruction plus item context + * (description and the two most recent comments), bounded to + * {@link MAX_PROMPT_CONTEXT_BYTES}. + */ +export function buildExplanationPrompt(item: WorkItem, comments: Comment[]): string { + const recent = comments + .slice(0, 2) + .map(c => `${c.author}: ${firstLine(c.comment)}`) + .join('\n'); + const context = [ + `Work item: ${item.id} — ${item.title}`, + '', + 'Description:', + item.description, + '', + 'Most recent comments:', + recent || '(none)', + ].join('\n'); + return `${PRODUCER_REVIEW_INSTRUCTION}.\n\n${truncateBytes(context, MAX_PROMPT_CONTEXT_BYTES)}`; +} + +/** + * Comment-based fallback: `<author>: <first line>` for the two most recent + * comments, joined on one line. When there are no usable comments a generic + * actionable line is returned so the producer still has a clear next step. + */ +export function buildCommentFallback(item: WorkItem, comments: Comment[]): string { + const recent = comments + .slice(0, 2) + .map(c => `${c.author}: ${firstLine(c.comment)}`) + .filter(entry => entry.replace(/^[^:]*:\s*/, '').length > 0); + if (recent.length > 0) return recent.join('; '); + return ( + `No recent comments; review the item and clear the flag with ` + + `\`wl reviewed ${item.id} false\` once the blocker is resolved.` + ); +} + +/** + * Produce a one-line explanation of how the producer clears + * `needsProducerReview`. + * + * Returns `null` when the item is not flagged. Otherwise it calls the injected + * chat client (unless `noLlm` is set or the client is unavailable) and falls + * back silently to the two most recent comments on any failure — network + * error, non-2xx, timeout or empty response (parent AC2/AC3). + */ +export async function buildProducerReviewExplanation( + item: WorkItem, + deps: ProducerReviewExplanationDeps, +): Promise<string | null> { + if (!item.needsProducerReview) return null; + + if (!deps.noLlm && deps.chatClient?.available) { + try { + const response = await deps.chatClient.complete( + buildExplanationPrompt(item, deps.comments), + { timeoutMs: deps.timeoutMs ?? EXPLANATION_TIMEOUT_MS }, + ); + const normalised = normaliseExplanation(response); + if (normalised.length > 0) return normalised; + } catch { + // Silent fallback — the explanation is advisory; never surface an error. + } + } + + return normaliseExplanation(buildCommentFallback(item, deps.comments)); +} + // ── Core interview loop ────────────────────────────────────────────────── /** Minimal database dependency required by {@link runInterview}. */ @@ -512,7 +686,13 @@ export interface InterviewOutcome { * * Answers are persisted after each response so an interrupted session can be * resumed; the producer review flag is only cleared once every question has - * a non-empty answer. + * a non-empty answer (the `allAnswered` auto-clear path, unchanged). + * + * This function only handles the question walkthrough. The "no questions + * detected" edge case (noSection / noQuestions) is handled by the command + * registration below, which explains how the producer clears an outstanding + * `needsProducerReview` flag and offers to clear it: see + * {@link buildProducerReviewExplanation} and `.option('--no-llm')`. */ export async function runInterview( item: WorkItem, @@ -615,15 +795,76 @@ function createPromptLoop(): { // ── Command registration ───────────────────────────────────────────────── -export default function register(ctx: PluginContext): void { +/** The shape returned by {@link createPromptLoop} — one shared iterator. */ +export interface InterviewPromptLoop { + next: (message: string) => Promise<string>; + close: () => void; +} + +/** Injectable dependencies for the interview command (used by tests). */ +export interface InterviewCommandDeps { + /** + * Build the chat client for the explanation. Return `null` to disable the + * LLM path. Defaults to an `OpenAIChatClient` over the resolved LLM config. + */ + chatClientFactory?: (options: { model?: string }) => ChatClient | null; + /** + * Create the interactive prompt loop. Overridable so tests never open a + * real readline interface (mirrors the {@link InterviewIO} pattern). + */ + promptLoopFactory?: () => InterviewPromptLoop; + /** + * Confirm whether the `needsProducerReview` flag should be cleared. + * Defaults to a `readline` `(y/N)` prompt on stdin/stdout. + */ + clearPrompt?: (message: string) => Promise<boolean>; +} + +/** + * Ask a yes/no question on the terminal, defaulting to **No** on anything + * other than an explicit `y`/`yes` (parent AC1/AC3). + */ +export async function defaultClearPrompt(message: string): Promise<boolean> { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await new Promise<string>(resolve => { + rl.question(message, resolve); + }); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} + +export default function register( + ctx: PluginContext, + deps: InterviewCommandDeps = {}, +): void { const { program, output, utils } = ctx; + const chatClientFactory = + deps.chatClientFactory ?? + ((options: { model?: string }): ChatClient => { + const config = utils.getConfig(); + return new OpenAIChatClient({ + ...(config ? resolveLlmConfig(config) ?? {} : {}), + hasExplicitConfig: true, + ...(options.model ? { model: options.model } : {}), + }); + }); + + const promptLoopFactory = deps.promptLoopFactory ?? createPromptLoop; + const clearPrompt = deps.clearPrompt ?? defaultClearPrompt; + program .command('interview <id>') .description( 'Walk through outstanding interview questions on a work item interactively, capturing answers and clearing the producer review flag', ) .option('--prefix <prefix>', 'Override the default prefix') + .option('--json', 'Non-interactive JSON output (no prompts, no mutation)') + .option('--no-llm', 'Disable the LLM explanation and use the comment fallback') + .option('--model <model>', 'Override the chat model for the explanation') .action(async (id: string, options: InterviewOptions) => { utils.requireInitialized(); const db = utils.getDatabase(options.prefix); @@ -638,27 +879,56 @@ export default function register(ctx: PluginContext): void { process.exit(1); } - // ── Step 1: Show brief summary ─────────────────────────────────── - if (!utils.isJsonMode()) { - console.log(`\n=== Interview: ${item.id} ===`); - console.log(`Title: ${item.title}`); - console.log(`Status: ${item.status}`); - console.log(`Stage: ${item.stage}`); - console.log( - `Review: ${item.needsProducerReview ? 'needs review' : 'not flagged'}\n`, - ); + const jsonMode = options.json === true || utils.isJsonMode(); + // Commander maps `--no-llm` to `options.llm === false`; the test harness + // passes the kebab-cased `noLlm`. Accept either spelling. + const noLlm = options.noLlm === true || options.llm === false; + + const section = extractClarifyingSection(item.description); + const pairs = section ? parseQAPairs(section.content) : []; + const noSection = section === null; + const noQuestions = section !== null && pairs.length === 0; + const outstanding = pairs.filter(p => p.unanswered).length; + const allAnswered = !noSection && !noQuestions && outstanding === 0; + + /** Explanation for the flagged no-question cases, else null. */ + const explanationFor = async (): Promise<string | null> => { + if (!item.needsProducerReview || !(noSection || noQuestions)) return null; + return buildProducerReviewExplanation(item, { + comments: db.getCommentsForWorkItem(item.id), + chatClient: noLlm ? null : chatClientFactory({ model: options.model }), + noLlm, + }); + }; + + // ── Non-interactive JSON mode: no prompts, no mutation ─────────── + if (jsonMode) { + const explanation = await explanationFor(); + output.json({ + success: true, + workItemId: item.id, + needsProducerReview: item.needsProducerReview, + producerReviewExplanation: explanation, + noSection, + noQuestions, + total: pairs.length, + outstanding, + allAnswered, + }); + return; } - if (utils.isJsonMode()) { - output.error( - 'Interview mode requires interactive (TTY) input; use without --json', - { success: false, error: 'requires-tty' }, - ); - process.exit(1); - } + // ── Step 1: Show brief summary ─────────────────────────────────── + console.log(`\n=== Interview: ${item.id} ===`); + console.log(`Title: ${item.title}`); + console.log(`Status: ${item.status}`); + console.log(`Stage: ${item.stage}`); + console.log( + `Review: ${item.needsProducerReview ? 'needs review' : 'not flagged'}\n`, + ); // ── Step 2: Interactive walkthrough ────────────────────────────── - const prompts = createPromptLoop(); + const prompts = promptLoopFactory(); let outcome: InterviewOutcome; try { outcome = await runInterview(item, db, { @@ -668,15 +938,38 @@ export default function register(ctx: PluginContext): void { prompts.close(); } + /** + * Print the explanation (when the item is flagged and there is nothing + * to interview), then offer to clear the flag. Returns true when the + * flag was cleared. Never called in `--json` mode. + */ + const explainAndOfferClear = async (): Promise<void> => { + const explanation = await explanationFor(); + if (!explanation) { + console.log('Nothing to interview. Exiting.'); + return; + } + console.log(''); + console.log(explanation); + console.log(''); + const shouldClear = await clearPrompt('Clear the needsProducerReview flag? (y/N)'); + if (shouldClear) { + db.update(item.id, { needsProducerReview: false }); + console.log(' needsProducerReview cleared.'); + } else { + console.log(' needsProducerReview remains flagged.'); + } + }; + // ── Step 3: Report outcome ─────────────────────────────────────── if (outcome.noSection) { console.log('No clarifying-questions section found.'); - console.log('Nothing to interview. Exiting.'); + await explainAndOfferClear(); return; } if (outcome.noQuestions) { console.log('No interview questions found in the clarifying section.'); - console.log('Nothing to interview. Exiting.'); + await explainAndOfferClear(); return; } if (outcome.recorded === 0 && outcome.allAnswered) { diff --git a/src/commands/update.ts b/src/commands/update.ts index 7ff4dd94f..3a6b8df9f 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -13,7 +13,6 @@ import { canValidateStatusStage, validateStatusStageCompatibility, validateStatu import { normalizeActionArgs } from './cli-utils.js'; import { buildAuditEntry, extractAuditFingerprint, formatInvalidAuditFirstLineMessage, inspectAuditFirstLine, redactAuditText } from '../audit.js'; import { loadStatusStageRules, normalizeStatusValue } from '../status-stage-rules.js'; -import { submitToOpenBrain } from '../openbrain.js'; import { normalizePriority, CANONICAL_PRIORITIES } from '../validators/priority.js'; export default function register(ctx: PluginContext): void { @@ -544,18 +543,6 @@ export default function register(ctx: PluginContext): void { impactfulChange = true; } - // Fire-and-forget: submit a summary to OpenBrain when the item - // transitions to completed, if the feature is enabled. - if (updates.status === 'completed') { - const config = utils.getConfig(); - if (config?.openBrainEnabled) { - submitToOpenBrain(item).catch(() => { - // Errors are already logged inside submitToOpenBrain; swallow here - // so the update command is never blocked or aborted. - }); - } - } - // Include audit data in JSON output when audit was written if (auditWritten && auditEntryForOutput) { (item as any).auditResult = db.getAuditResult(normalizedId); diff --git a/src/config.ts b/src/config.ts index b8510454b..4b1dd4339 100644 --- a/src/config.ts +++ b/src/config.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as yaml from 'js-yaml'; -import { WorklogConfig } from './types.js'; +import { WorklogConfig, LlmConfig } from './types.js'; import * as readline from 'readline'; import { resolveWorklogDir } from './worklog-paths.js'; import { theme } from './theme.js'; @@ -15,6 +15,11 @@ const CONFIG_FILE = 'config.yaml'; const CONFIG_DEFAULTS_FILE = 'config.defaults.yaml'; const INIT_SEMAPHORE_FILE = 'initialized'; +/** Default LLM provider configuration. */ +const DEFAULT_LLM_BASE_URL = 'http://192.168.0.199:8000/v1'; +const DEFAULT_LLM_MODEL = 'compact'; +const DEFAULT_LLM_TIMEOUT_MS = 15000; + /** @@ -185,6 +190,16 @@ export function loadConfig(): WorklogConfig | null { }; } + // Validate the optional LLM config section before resolving it. + const llmError = validateLlmConfig(config); + if (llmError) { + console.error(llmError); + return null; + } + + // Resolve LLM config section (config → env vars → defaults). + config.llm = resolveLlmConfig(config); + // Validate syncAllowedAuthors whitelist (AC1): [], string[], null, true allowed; other types rejected. const syncAllowedAuthorsError = validateSyncAllowedAuthors(config); if (syncAllowedAuthorsError) { @@ -197,7 +212,7 @@ export function loadConfig(): WorklogConfig | null { console.error(statusStageError); return null; } - + return config; } @@ -272,9 +287,53 @@ export function loadConfigRelaxed(): WorklogConfig | null { return null; } + // Resolve LLM config section (config → env vars → defaults). + config.llm = resolveLlmConfig(config); + return config; } +/** + * Validate the optional `llm` config section. + * + * Returns an error message string when the section is present but malformed, + * or `null` when it is absent or valid. + */ +function validateLlmConfig(config: WorklogConfig): string | null { + const llm = (config as { llm?: unknown }).llm; + if (llm === undefined || llm === null) return null; + if (typeof llm !== 'object' || Array.isArray(llm)) { + return 'Invalid config: llm must be an object mapping to baseUrl, model, apiKey, timeoutMs'; + } + + const section = llm as Record<string, unknown>; + + if (section.baseUrl !== undefined && (typeof section.baseUrl !== 'string' || section.baseUrl.trim() === '')) { + return 'Invalid config: llm.baseUrl must be a non-empty string'; + } + if (section.model !== undefined && (typeof section.model !== 'string' || section.model.trim() === '')) { + return 'Invalid config: llm.model must be a non-empty string'; + } + if (section.apiKey !== undefined && typeof section.apiKey !== 'string') { + return 'Invalid config: llm.apiKey must be a string'; + } + if (section.timeoutMs !== undefined) { + const timeout = section.timeoutMs; + if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) { + return 'Invalid config: llm.timeoutMs must be a positive finite number'; + } + } + + return null; +} + +/** Parse a positive finite numeric env var, returning undefined otherwise. */ +function parsePositiveNumber(value: string | undefined): number | undefined { + if (value === undefined || value === '') return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + function validateStatusStageConfig(config: WorklogConfig): string | null { const statuses = config.statuses; if (!Array.isArray(statuses) || statuses.length === 0) { @@ -351,6 +410,56 @@ function validateStatusStageConfig(config: WorklogConfig): string | null { return null; } +/** + * Resolve LLM provider configuration. + * + * Priority: (1) config file `llm.*`, (2) environment variables, + * (3) built-in defaults — mirroring the `embedding.*` precedence where + * config values win over environment variables, which win over defaults. + * + * Environment variable overrides: + * - LLM_BASE_URL + * - LLM_MODEL + * - LLM_API_KEY + * - LLM_TIMEOUT_MS + * + * The built-in default base URL is the local LLM proxy's OpenAI-compatible + * endpoint (`http://192.168.0.199:8000/v1` — the herdr + * `DEFAULT_DOWNTIME_PROXY_URL` host plus the `/v1` API prefix), the default + * model is `compact`, and the default timeout is 15000 ms. + * + * If neither the config section nor any LLM_* env var is set, returns + * `undefined` so downstream callers can detect "not configured" and apply + * their own defaults (e.g. the ChatClient's `available` flag). This mirrors + * the optional `embedding` section. + */ +export function resolveLlmConfig(config: WorklogConfig): LlmConfig | undefined { + const configLlm = config.llm; + + const baseUrl = configLlm?.baseUrl ?? process.env.LLM_BASE_URL ?? DEFAULT_LLM_BASE_URL; + const model = configLlm?.model ?? process.env.LLM_MODEL ?? DEFAULT_LLM_MODEL; + const apiKey = configLlm?.apiKey ?? process.env.LLM_API_KEY ?? ''; + const timeoutMs = + configLlm?.timeoutMs ?? + parsePositiveNumber(process.env.LLM_TIMEOUT_MS) ?? + DEFAULT_LLM_TIMEOUT_MS; + + const hasExplicitConfig = configLlm !== undefined && configLlm !== null; + const hasEnvVars = + process.env.LLM_BASE_URL !== undefined || + process.env.LLM_MODEL !== undefined || + process.env.LLM_API_KEY !== undefined || + process.env.LLM_TIMEOUT_MS !== undefined; + + if (!hasExplicitConfig && !hasEnvVars) { + // No explicit configuration at all — return undefined so callers can + // detect "not configured" (e.g. the ChatClient's `available` flag). + return undefined; + } + + return { baseUrl, model, apiKey, timeoutMs }; +} + /** * Save configuration to file */ diff --git a/src/lib/llm.ts b/src/lib/llm.ts new file mode 100644 index 000000000..66d7754f8 --- /dev/null +++ b/src/lib/llm.ts @@ -0,0 +1,196 @@ +/** + * LLM chat-completion module for Worklog + * + * Provides a small, dependency-injectable abstraction over an + * OpenAI-compatible chat-completions provider, used by LLM-assisted features + * such as the `wl interview` producer-review explanation. + * + * Architecture: + * ChatClient — abstraction over chat providers + * OpenAIChatClient — OpenAI-compatible `/chat/completions` client + * + * The client mirrors the `OpenAIEmbedder` graceful-degradation pattern in + * `src/lib/search.ts`: when no explicit configuration (and no `LLM_*` + * environment variable) is present it reports `available = false` and + * `complete()` throws a descriptive error rather than making a request. This + * lets callers fall back silently to non-LLM behaviour. + * + * Configuration priority (mirroring `OpenAIEmbedder` and + * `resolveLlmConfig` in `src/config.ts`): + * 1. constructor config + * 2. `LLM_*` environment variables + * 3. built-in defaults (local LLM proxy, `compact` model, 15 s timeout) + * + * No new runtime dependencies — uses the native `fetch` (Node 18+). + * + * WL-0MULI0LXL00829JI + */ + +import type { LlmConfig } from '../types.js'; + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +/** Default OpenAI-compatible base URL — the local LLM proxy. */ +export const DEFAULT_LLM_BASE_URL = 'http://192.168.0.199:8000/v1'; + +/** Default chat model — the proxy's `compact` model. */ +export const DEFAULT_LLM_MODEL = 'compact'; + +/** Default request timeout in milliseconds. */ +export const DEFAULT_LLM_TIMEOUT_MS = 15000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Per-call options for {@link ChatClient.complete}. */ +export interface ChatCompleteOptions { + /** Cap on generated tokens, forwarded as `max_tokens`. */ + maxTokens?: number; + /** Per-call timeout override in milliseconds (defaults to the client's). */ + timeoutMs?: number; +} + +/** Abstraction over an OpenAI-compatible chat-completion provider. */ +export interface ChatClient { + /** Whether this client is available/configured. */ + readonly available: boolean; + /** Generate a completion for `prompt`. Rejects when unavailable or on error. */ + complete(prompt: string, options?: ChatCompleteOptions): Promise<string>; +} + +/** + * Configuration accepted by {@link OpenAIChatClient}. + * + * Extends the shared {@link LlmConfig} with `hasExplicitConfig`, which + * mirrors `OpenAIEmbedder`: it marks that the caller explicitly requested the + * LLM path (e.g. the feature is default-on) even when neither a config section + * nor an environment variable is set. + */ +export interface OpenAIChatClientConfig extends LlmConfig { + /** Treat the client as configured even without config/env values. */ + hasExplicitConfig?: boolean; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Parse a positive finite number from an environment string, else null. */ +function parsePositiveNumber(value: string | undefined): number | null { + if (value === undefined || value.trim() === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +// --------------------------------------------------------------------------- +// OpenAIChatClient +// --------------------------------------------------------------------------- + +/** + * OpenAI-compatible chat-completions client. + * + * Posts `{ model, messages: [{ role: 'user', content: prompt }] }` to + * `${baseUrl}/chat/completions` and returns + * `choices[0].message.content`. The request is bounded by an + * {@link AbortController}-driven timeout (15 s by default) so a hung provider + * can never block the caller indefinitely. + */ +export class OpenAIChatClient implements ChatClient { + readonly available: boolean; + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly model: string; + private readonly timeoutMs: number; + + constructor(config?: OpenAIChatClientConfig) { + // Priority: 1) constructor config, 2) env vars, 3) defaults + this.apiKey = config?.apiKey ?? process.env.LLM_API_KEY ?? ''; + this.baseUrl = config?.baseUrl ?? process.env.LLM_BASE_URL ?? DEFAULT_LLM_BASE_URL; + this.model = config?.model ?? process.env.LLM_MODEL ?? DEFAULT_LLM_MODEL; + this.timeoutMs = + config?.timeoutMs ?? + parsePositiveNumber(process.env.LLM_TIMEOUT_MS) ?? + DEFAULT_LLM_TIMEOUT_MS; + + // Available when the caller explicitly requested the LLM path, an API key + // is set, or any LLM_* env var signals explicit user intent. + this.available = Boolean( + this.apiKey || + config?.hasExplicitConfig || + process.env.LLM_BASE_URL || + process.env.LLM_MODEL || + process.env.LLM_API_KEY || + process.env.LLM_TIMEOUT_MS + ); + } + + async complete(prompt: string, options: ChatCompleteOptions = {}): Promise<string> { + if (!this.available) { + throw new Error( + 'Chat provider is not configured. ' + + 'Set LLM_API_KEY or LLM_BASE_URL, configure llm in .worklog/config.yaml, ' + + 'or refer to CLI.md for local provider setup.' + ); + } + + const url = `${this.baseUrl.replace(/\/$/, '')}/chat/completions`; + + // Build headers conditionally — local providers don't need auth + const headers: Record<string, string> = { + 'Content-Type': 'application/json', + }; + if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}`; + } + + const body: Record<string, unknown> = { + model: this.model, + messages: [{ role: 'user', content: prompt }], + }; + if (options.maxTokens !== undefined) { + body.max_tokens = options.maxTokens; + } + + const timeoutMs = options.timeoutMs ?? this.timeoutMs; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok) { + const responseBody = await response.text().catch(() => ''); + throw new Error( + `Chat API error: ${response.status} ${response.statusText}` + + `${responseBody ? ` — ${responseBody.slice(0, 200)}` : ''}` + ); + } + + const data = await response.json() as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content; + + if (typeof content !== 'string' || content.trim() === '') { + throw new Error('Chat API returned an empty response'); + } + + return content; + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + throw new Error(`Chat API request timed out after ${timeoutMs} ms`); + } + throw err; + } finally { + clearTimeout(timer); + } + } +} diff --git a/src/openbrain.ts b/src/openbrain.ts deleted file mode 100644 index a9ea9a875..000000000 --- a/src/openbrain.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * OpenBrain integration — asynchronous submission of completed work-item summaries. - * - * When a work item transitions to a completed/done state this module fires a - * background process (`ob add`) that saves a concise markdown summary to the - * OpenBrain knowledge-base. The call is intentionally non-blocking: errors are - * logged to stderr but never surfaced to the user flow that marked the item - * complete. - * - * Fallback behaviour: - * - If `ob` is not on PATH the error is logged and silently swallowed. - * - If the submission fails for any reason a log entry is written to the - * OpenBrain queue file (.worklog/openbrain-queue.jsonl) so the entry can - * be retried later. - */ - -import { spawn, type SpawnOptions } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import { resolveWorklogDir } from './worklog-paths.js'; -import type { WorkItem } from './types.js'; - -export const OPENBRAIN_QUEUE_FILE = 'openbrain-queue.jsonl'; - -export interface OpenBrainQueueEntry { - workItemId: string; - title: string; - summary: string; - enqueuedAt: string; - reason?: string; -} - -/** - * Detect whether verbose logging was requested. Honor WL_VERBOSE env var - * (true/1/yes) or the presence of a global --verbose flag on process.argv. - */ -function isVerbose(): boolean { - try { - const ev = process.env.WL_VERBOSE; - if (ev && String(ev).trim() !== '') { - const v = String(ev).toLowerCase(); - return v === '1' || v === 'true' || v === 'yes'; - } - return Array.isArray(process.argv) && process.argv.includes('--verbose'); - } catch { - return false; - } -} - -/** - * Build a concise markdown summary for a completed work item. - */ -export function buildOpenBrainSummary(item: WorkItem, auditResult?: { summary: string | null; readyToClose: boolean } | null): string { - const lines: string[] = []; - lines.push(`# ${item.title}`); - lines.push(''); - lines.push(`**Work item:** ${item.id}`); - if (item.issueType) lines.push(`**Type:** ${item.issueType}`); - if (item.assignee) lines.push(`**Assignee:** ${item.assignee}`); - lines.push(`**Completed at:** ${item.updatedAt}`); - lines.push(''); - - if (item.description && item.description.trim() !== '') { - lines.push('## Objective'); - lines.push(''); - lines.push(item.description.trim()); - lines.push(''); - } - - if (auditResult?.summary && auditResult.summary.trim() !== '') { - lines.push('## What was done'); - lines.push(''); - lines.push(auditResult.summary.trim()); - lines.push(''); - } - - return lines.join('\n'); -} - -/** - * Append a failed submission to the local retry queue. - */ -export function appendToQueue(entry: OpenBrainQueueEntry, queueDir?: string): void { - try { - const dir = queueDir ?? resolveWorklogDir(); - const queuePath = path.join(dir, OPENBRAIN_QUEUE_FILE); - const line = JSON.stringify(entry) + '\n'; - fs.appendFileSync(queuePath, line, 'utf-8'); - if (isVerbose()) { - try { console.error(`[openbrain] queued submission to ${queuePath}: ${JSON.stringify(entry)}`); } catch (_) { /* ignore */ } - } - } catch (err) { - // Best-effort — if we cannot write the queue, log in verbose mode but - // never throw to avoid interfering with user flows. - if (isVerbose()) { - try { console.error(`[openbrain] failed to append to queue: ${(err as Error).message}`); } catch (_) { /* ignore */ } - } - } -} - -export interface SubmitToOpenBrainOptions { - /** Override the `ob` binary path (useful in tests). */ - obBin?: string; - /** Override spawn implementation (useful in tests). */ - spawnImpl?: (cmd: string, args: string[], opts: SpawnOptions) => ReturnType<typeof spawn>; - /** Override the queue directory (useful in tests). */ - queueDir?: string; - /** When true, await completion before returning (useful in tests). */ - waitForCompletion?: boolean; - /** When provided, force verbose logging on or off for this invocation. */ - verbose?: boolean; -} - -/** - * Submit a completed work item summary to OpenBrain asynchronously. - * - * Returns a Promise that resolves once the background process has been spawned - * (not waited on) unless `waitForCompletion` is set to true. Errors are - * logged to stderr and, if the submission fails, the entry is written to the - * local retry queue. - */ -export async function submitToOpenBrain( - item: WorkItem, - options: SubmitToOpenBrainOptions = {} -): Promise<void> { - const obBin = options.obBin ?? resolveObBinary(); - const spawnImpl = options.spawnImpl ?? spawn; - const summary = buildOpenBrainSummary(item); // auditResult intentionally omitted here; callers may pass it separately - - const verbose = options.verbose !== undefined ? Boolean(options.verbose) : isVerbose(); - if (verbose) { - try { console.error(`[openbrain] submitToOpenBrain: obBin=${obBin} title=${JSON.stringify(item.title)} wait=${Boolean(options.waitForCompletion)}`); } catch (_) { /* ignore */ } - } - - const run = (): Promise<void> => - new Promise<void>((resolve) => { - const args = ['add', '--stdin', '--title', item.title]; - - // Non-blocking mode (default): spawn fully detached with stdout/stderr ignored, - // write stdin, and return immediately without waiting for close. - // This prevents `wl close` from being delayed by OpenBrain process lifetime. - if (!options.waitForCompletion) { - let child: ReturnType<typeof spawn>; - try { - const spawnOpts: SpawnOptions = { stdio: ['pipe', 'ignore', 'ignore'], detached: true }; - if (verbose) { - try { console.error(`[openbrain] spawning (non-blocking): ${obBin} ${args.join(' ')} opts=${JSON.stringify(spawnOpts)}`); } catch (_) { /* ignore */ } - } - child = spawnImpl(obBin, args, spawnOpts); - } catch (spawnErr) { - const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr); - console.error(`[openbrain] Failed to spawn ob: ${msg}`); - appendToQueue( - { workItemId: item.id, title: item.title, summary, enqueuedAt: new Date().toISOString(), reason: msg }, - options.queueDir - ); - resolve(); - return; - } - - child.once('error', (err) => { - const msg = err.message; - console.error(`[openbrain] ob add error: ${msg}`); - appendToQueue( - { workItemId: item.id, title: item.title, summary, enqueuedAt: new Date().toISOString(), reason: msg }, - options.queueDir - ); - }); - - const childStdin = child.stdin; - if (childStdin) { - childStdin.on('error', (err: NodeJS.ErrnoException) => { - if (verbose) { - const code = err?.code ? ` code=${String(err.code)}` : ''; - try { console.error(`[openbrain] stdin write error:${code} ${err.message}`); } catch (_) { /* ignore */ } - } - }); - try { - childStdin.write(summary, 'utf-8'); - childStdin.end(); - } catch { - // Best-effort in non-blocking mode. - } - } - - try { child.unref(); } catch { /* ignore */ } - resolve(); - return; - } - - // Wait mode (tests/explicit callers): preserve full close/error handling. - let child: ReturnType<typeof spawn>; - let alreadyQueued = false; - let finished = false; - const safeResolve = () => { if (!finished) { finished = true; resolve(); } }; - try { - const spawnOpts: SpawnOptions = { stdio: ['pipe', verbose ? 'pipe' : 'ignore', 'pipe'], detached: false }; - if (verbose) { - try { console.error(`[openbrain] spawning: ${obBin} ${args.join(' ')} opts=${JSON.stringify(spawnOpts)}`); } catch (_) { /* ignore */ } - } - child = spawnImpl(obBin, args, spawnOpts); - if (verbose && child && (child as any).pid) { - try { console.error(`[openbrain] spawned child pid=${(child as any).pid}`); } catch (_) { /* ignore */ } - } - } catch (spawnErr) { - const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr); - console.error(`[openbrain] Failed to spawn ob: ${msg}`); - if (verbose && spawnErr instanceof Error && (spawnErr as any).stack) { - try { console.error(`[openbrain] spawn stack: ${(spawnErr as any).stack}`); } catch (_) { /* ignore */ } - } - appendToQueue( - { workItemId: item.id, title: item.title, summary, enqueuedAt: new Date().toISOString(), reason: msg }, - options.queueDir - ); - safeResolve(); - return; - } - - const childStdin = child.stdin; - if (childStdin) { - childStdin.on('error', (err: NodeJS.ErrnoException) => { - if (verbose) { - const code = err?.code ? ` code=${String(err.code)}` : ''; - try { console.error(`[openbrain] stdin write error:${code} ${err.message}`); } catch (_) { /* ignore */ } - } - }); - try { - childStdin.write(summary, 'utf-8'); - childStdin.end(); - if (verbose) try { console.error(`[openbrain] wrote ${String(summary.length)} bytes to child stdin`); } catch (_) { /* ignore */ } - } catch { - // Ignore synchronous write errors — we'll capture process outcome on close. - } - } - - const stderrLines: string[] = []; - const stdoutLines: string[] = []; - child.stderr?.on('data', (chunk: Buffer | string) => { - const s = chunk.toString(); - stderrLines.push(s); - if (verbose) try { console.error(`[openbrain] child stderr chunk: ${s.trim()}`); } catch (_) { /* ignore */ } - }); - child.stdout?.on('data', (chunk: Buffer | string) => { - const s = chunk.toString(); - stdoutLines.push(s); - if (verbose) try { console.error(`[openbrain] child stdout chunk: ${s.trim()}`); } catch (_) { /* ignore */ } - }); - - child.once('error', (err) => { - const msg = err.message; - console.error(`[openbrain] ob add error: ${msg}`); - if (verbose && (err as any).stack) try { console.error(`[openbrain] ob add error stack: ${(err as any).stack}`); } catch (_) { /* ignore */ } - alreadyQueued = true; - appendToQueue( - { workItemId: item.id, title: item.title, summary, enqueuedAt: new Date().toISOString(), reason: msg }, - options.queueDir - ); - safeResolve(); - }); - - child.once('close', (code) => { - const stderr = stderrLines.join('').trim(); - const stdout = stdoutLines.join('').trim(); - if (code !== 0) { - if (!alreadyQueued) { - const reason = stderr || `ob add exited with code ${code}`; - console.error(`[openbrain] ob add failed (exit ${code}): ${reason}`); - if (verbose) try { console.error(`[openbrain] full stderr: ${stderr || '<empty>'}`); } catch (_) { /* ignore */ } - appendToQueue( - { workItemId: item.id, title: item.title, summary, enqueuedAt: new Date().toISOString(), reason }, - options.queueDir - ); - } else if (verbose) { - try { console.error(`[openbrain] close after error, already queued; code=${code}`); } catch (_) { /* ignore */ } - } - } else { - if (verbose) try { console.error(`[openbrain] ob add exited 0 (success) for ${item.id}`); } catch (_) { /* ignore */ } - if (verbose && stdout) try { console.error(`[openbrain] ob add stdout: ${stdout}`); } catch (_) { /* ignore */ } - } - if (verbose) try { console.error(`[openbrain] child close code=${code} for ${item.id}`); } catch (_) { /* ignore */ } - safeResolve(); - }); - }); - - return run(); -} - -/** - * Resolve the `ob` binary path, respecting the WL_OB_BIN environment variable. - */ -export function resolveObBinary(explicit?: string): string { - if (explicit && explicit.trim() !== '') return explicit.trim(); - if (process.env.WL_OB_BIN && process.env.WL_OB_BIN.trim() !== '') { - return process.env.WL_OB_BIN.trim(); - } - return 'ob'; -} diff --git a/src/types.ts b/src/types.ts index b121622c8..ab5c54421 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,7 @@ export { type UpdateWorkItemInput, type WorkItemQuery, type EmbeddingConfig, + type LlmConfig, type WorklogConfig, type Comment, type DependencyEdge, diff --git a/src/version.ts b/src/version.ts index cdf4263d8..e47cbe5bb 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.16'; +export const WORKLOG_VERSION = '1.0.17'; diff --git a/test/tmp_mig/worklog.db b/test/tmp_mig/worklog.db index c6370f23d..4e44be5a7 100644 Binary files a/test/tmp_mig/worklog.db and b/test/tmp_mig/worklog.db differ diff --git a/tests/cli/interview.test.ts b/tests/cli/interview.test.ts index 3c8458786..12d047e1f 100644 --- a/tests/cli/interview.test.ts +++ b/tests/cli/interview.test.ts @@ -14,13 +14,18 @@ * triggered the original audit rejection). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import registerInterview, { extractClarifyingSection, parseQAPairs, rebuildQAPairs, rebuildDescription, runInterview, + buildProducerReviewExplanation, + buildExplanationPrompt, + buildCommentFallback, + normaliseExplanation, + PRODUCER_REVIEW_INSTRUCTION, } from '../../src/commands/interview.js'; import { createTestContext } from '../test-utils.js'; @@ -224,6 +229,54 @@ describe('parseQAPairs (canonical bullet appendix)', () => { expect(pairs[0].unanswered).toBe(true); }); + it('does not truncate question text containing em dashes (WL-0MUKCGV3X0030W6K)', () => { + // Question text with em dashes that are part of the content, not qualifiers + const content = + '- **Q:** 4. Crash behaviour — destroyed and respawn (like enemy) — **Answer:** *(awaiting producer)*.'; + const pairs = parseQAPairs(content); + expect(pairs).toHaveLength(1); + expect(pairs[0].question).toBe( + '4. Crash behaviour — destroyed and respawn (like enemy)', + ); + expect(pairs[0].unanswered).toBe(true); + }); + + it('captures the full multi-line question ending in an OPEN QUESTION marker (WL-0MUKCGV3X0030W6K)', () => { + // Reproduces the intake appendix from AH-0MUAYB2XR007N10W, where the + // answer placeholder sits on a continuation line and the question spans + // two lines. Previously only the first line was surfaced. + const content = `- **Q (round 1 follow-up):** Crash behaviour — destroyed and respawn (like enemy + collisions) or something else? — **OPEN QUESTION**, context: not yet answered. + Interim assumption: destroy + reuse the shared player-hit lifecycle (see + Assumptions). Source: wl comment \`AH-C0MUB8JD13004UD9E\`.`; + const pairs = parseQAPairs(content); + expect(pairs).toHaveLength(1); + expect(pairs[0].question).toBe( + 'Crash behaviour — destroyed and respawn (like enemy collisions) or something else?', + ); + expect(pairs[0].unanswered).toBe(true); + }); + + it('round-trips a multi-line OPEN QUESTION appendix losslessly', () => { + const content = `- **Q (round 1 follow-up):** Crash behaviour — destroyed and respawn (like enemy + collisions) or something else? — **OPEN QUESTION**, context: not yet answered. + Interim assumption: destroy + reuse the shared player-hit lifecycle. Source: reply.`; + const pairs = parseQAPairs(content); + expect(pairs).toHaveLength(1); + expect(rebuildQAPairs(pairs)).toBe(content); + }); + + it('captures a multi-line question with no answer marker at all', () => { + const content = `- **Q:** A question that spans + several lines with no marker?`; + const pairs = parseQAPairs(content); + expect(pairs).toHaveLength(1); + expect(pairs[0].question).toBe( + 'A question that spans several lines with no marker?', + ); + expect(pairs[0].unanswered).toBe(true); + }); + it('parses inline-numbered Q markers with bold answer attribution', () => { const content = `- **Q1**: "What is the timeout?" — **Answer** (user): "120s". Source: reply. - **Q2:** "And the scope?"`; @@ -556,6 +609,35 @@ No clarifying questions were asked; the brief was sufficient.`; expect(updated.description).toContain('Yes'); expect(updated.needsProducerReview).toBe(false); }); + + it('prompts with the full multi-line question and answers an OPEN QUESTION placeholder (WL-0MUKCGV3X0030W6K)', async () => { + const desc = `## Appendix: Clarifying questions + +- **Q (round 1 follow-up):** Crash behaviour — destroyed and respawn (like enemy + collisions) or something else? — **OPEN QUESTION**, context: not yet answered. + Interim assumption: destroy + reuse the shared player-hit lifecycle. + Source: wl comment \`AH-C0MUB8JD13004UD9E\`.`; + const store = makeStore(makeItem(desc, true)); + const prompts: string[] = []; + const outcome = await runInterview(store.current(), store, { + prompt: async (m: string) => { + prompts.push(m); + return 'Destroyed and respawn like enemy collisions'; + }, + }); + expect(prompts).toHaveLength(1); + // The full question text must be visible in the prompt, not just line 1. + expect(prompts[0]).toContain('destroyed and respawn (like enemy collisions)'); + expect(prompts[0]).toContain('or something else?'); + expect(outcome.recorded).toBe(1); + expect(outcome.allAnswered).toBe(true); + const updated = store.current(); + expect(updated.description).toContain('Destroyed and respawn like enemy collisions'); + expect(updated.description).not.toContain('OPEN QUESTION'); + // Metadata after the placeholder is preserved for the audit trail. + expect(updated.description).toContain('Source: wl comment'); + expect(updated.needsProducerReview).toBe(false); + }); }); // ── Command registration ───────────────────────────────────────────────── @@ -631,3 +713,391 @@ Some content. expect(item).not.toBeNull(); }); }); + +// ── Producer-review explanation ────────────────────────────────────────── + +function comment(author: string, body: string): any { + return { + id: `C-${author}`, + workItemId: 'WL-TEST-1', + author, + comment: body, + createdAt: new Date().toISOString(), + references: [], + }; +} + +function flaggedItem(description: string, flagged = true): any { + return { + id: 'WL-TEST-1', + title: 'Sample', + description, + needsProducerReview: flagged, + } as any; +} + +/** A chat client stub whose `complete` returns `response` (or throws). */ +function fakeChatClient(response: string | Error): any { + return { + available: true, + complete: vi.fn(async () => { + if (response instanceof Error) throw response; + return response; + }), + }; +} + +describe('buildProducerReviewExplanation', () => { + const comments = [ + comment('bob', 'Second comment\nwith a second line'), + comment('alice', 'First comment'), + ]; + + it('returns null when the item is not flagged for producer review', async () => { + const result = await buildProducerReviewExplanation(flaggedItem('desc', false), { + comments, + chatClient: fakeChatClient('unused'), + }); + expect(result).toBeNull(); + }); + + it('returns the normalised LLM explanation when the call succeeds', async () => { + const client = fakeChatClient('Ask the\noperator to\nclear the flag.'); + const result = await buildProducerReviewExplanation(flaggedItem('desc'), { + comments, + chatClient: client, + }); + expect(result).toBe('Ask the operator to clear the flag.'); + expect(client.complete).toHaveBeenCalledTimes(1); + }); + + it('skips the LLM and uses the two most recent comments when --no-llm', async () => { + const client = fakeChatClient('unused'); + const result = await buildProducerReviewExplanation(flaggedItem('desc'), { + comments, + chatClient: client, + noLlm: true, + }); + expect(client.complete).not.toHaveBeenCalled(); + expect(result).toContain('bob: Second comment'); + expect(result).toContain('alice: First comment'); + }); + + it('falls back silently when the LLM call fails', async () => { + const client = fakeChatClient(new Error('network unavailable')); + const result = await buildProducerReviewExplanation(flaggedItem('desc'), { + comments, + chatClient: client, + }); + expect(client.complete).toHaveBeenCalledTimes(1); + expect(result).toContain('bob: Second comment'); + }); + + it('falls back without calling an unavailable client', async () => { + const client = { available: false, complete: vi.fn() }; + const result = await buildProducerReviewExplanation(flaggedItem('desc'), { + comments, + chatClient: client as any, + }); + expect(client.complete).not.toHaveBeenCalled(); + expect(result).toContain('alice: First comment'); + }); + + it('uses a generic actionable fallback when there are no comments', async () => { + const result = await buildProducerReviewExplanation(flaggedItem('desc'), { + comments: [], + noLlm: true, + }); + expect(result).toContain('WL-TEST-1'); + }); + + it('builds the prompt from the instruction and the two most recent comments', async () => { + const client = fakeChatClient('ok'); + const older = comment('old', 'Ancient history'); + await buildProducerReviewExplanation(flaggedItem('desc'), { + comments: [...comments, older], + chatClient: client, + }); + const prompt = client.complete.mock.calls[0][0] as string; + expect(prompt).toContain(PRODUCER_REVIEW_INSTRUCTION); + expect(prompt).toContain('alice: First comment'); + expect(prompt).toContain('bob: Second comment'); + expect(prompt).not.toContain('Ancient history'); + }); + + it('normalises multi-line text to a single bounded line', () => { + expect(normaliseExplanation('a\n\nb c')).toBe('a b c'); + const bounded = normaliseExplanation('x'.repeat(2000), 50); + expect(bounded.length).toBeLessThanOrEqual(50); + }); + + it('buildCommentFallback renders <author>: <first line> per comment', () => { + const fallback = buildCommentFallback(flaggedItem('desc'), comments); + expect(fallback).toBe('bob: Second comment; alice: First comment'); + }); +}); + +describe('interview --json (non-interactive)', () => { + /** Register the command with a JSON-capturing output and injected comments. */ + function setup(options: { + item?: { description?: string; needsProducerReview?: boolean }; + comments?: any[]; + chatClient?: any; + } = {}) { + const ctx = createTestContext(); + const jsonOutput: any[] = []; + ctx.output = { + json: (data: any) => { jsonOutput.push(data); }, + success: () => {}, + error: () => {}, + }; + const id = ctx.utils.createSampleItem({}); + ctx.utils.db.update(id, { + description: options.item?.description ?? '# Task\n\nNo clarifying section here.', + title: 'JSON task', + needsProducerReview: options.item?.needsProducerReview ?? true, + }); + const baseGetDatabase = ctx.utils.getDatabase; + ctx.utils.getDatabase = (prefix?: string) => ({ + ...baseGetDatabase(prefix), + getCommentsForWorkItem: () => options.comments ?? [], + }); + registerInterview(ctx as any, { + chatClientFactory: () => options.chatClient ?? null, + }); + return { ctx, id, jsonOutput }; + } + + it('returns the explanation and question state without mutating the item', async () => { + const client = fakeChatClient('Clear the flag by doing X.'); + const { ctx, id, jsonOutput } = setup({ + item: { description: '# Task\n\nNo section.', needsProducerReview: true }, + comments: [comment('producer', 'Please clarify scope.')], + chatClient: client, + }); + + await ctx.runCli(['interview', id, '--json']); + + expect(jsonOutput).toHaveLength(1); + expect(jsonOutput[0]).toMatchObject({ + success: true, + workItemId: id, + needsProducerReview: true, + producerReviewExplanation: 'Clear the flag by doing X.', + noSection: true, + noQuestions: false, + }); + // No mutation in JSON mode applies to the flag or the description. + const stored = ctx.utils.db.get(id); + expect(stored.needsProducerReview).toBe(true); + expect(stored.description).toContain('No section.'); + }); + + it('uses the comment fallback with --no-llm and never calls the LLM', async () => { + const client = fakeChatClient('unused'); + const { ctx, id, jsonOutput } = setup({ + item: { description: '# Task\n\nNo section.', needsProducerReview: true }, + comments: [comment('producer', 'Please clarify scope.\nMore detail.')], + chatClient: client, + }); + + await ctx.runCli(['interview', id, '--json', '--no-llm']); + + expect(client.complete).not.toHaveBeenCalled(); + expect(jsonOutput[0].producerReviewExplanation).toBe('producer: Please clarify scope.'); + }); + + it('returns a null explanation for a non-flagged item', async () => { + const { ctx, id, jsonOutput } = setup({ + item: { description: '# Task\n\nNo section.', needsProducerReview: false }, + comments: [comment('producer', 'Hello')], + chatClient: fakeChatClient('unused'), + }); + + await ctx.runCli(['interview', id, '--json']); + + expect(jsonOutput[0].needsProducerReview).toBe(false); + expect(jsonOutput[0].producerReviewExplanation).toBeNull(); + }); + + it('reports outstanding-question state without prompting in JSON mode', async () => { + const desc = `# Task + +## Appendix: Clarifying questions + +- Q: "Scope?" — Answer: *(awaiting producer)*. Source: reply.`; + const { ctx, id, jsonOutput } = setup({ + item: { description: desc, needsProducerReview: true }, + comments: [comment('producer', 'Scope question raised.')], + chatClient: fakeChatClient('unused'), + }); + + await ctx.runCli(['interview', id, '--json', '--no-llm']); + + expect(jsonOutput[0]).toMatchObject({ + noSection: false, + noQuestions: false, + total: 1, + outstanding: 1, + allAnswered: false, + }); + // A flagged item with outstanding questions gets no explanation (only the + // noSection/noQuestions cases do). + expect(jsonOutput[0].producerReviewExplanation).toBeNull(); + }); +}); + +// ── Clear-the-flag interactive prompt ──────────────────────────────────── + +describe('interview clear-the-flag prompt', () => { + /** + * Register the command for an interactive run with captured output and a + * scripted prompt pool. The prompt loop is injected so no real readline + * interface is created. + */ + function setupInteractive(options: { + description?: string; + needsProducerReview?: boolean; + answers?: string[]; + comments?: any[]; + } = {}) { + const ctx = createTestContext(); + const messages: string[] = []; + const answers = [...(options.answers ?? [])]; + const originalLog = console.log; + console.log = (...args: any[]) => { messages.push(args.join(' ')); }; + + const id = ctx.utils.createSampleItem({}); + ctx.utils.db.update(id, { + description: options.description ?? '# Task\n\nNo clarifying section here.', + title: 'Prompt task', + needsProducerReview: options.needsProducerReview ?? true, + }); + const baseGetDatabase = ctx.utils.getDatabase; + ctx.utils.getDatabase = (prefix?: string) => ({ + ...baseGetDatabase(prefix), + getCommentsForWorkItem: () => options.comments ?? [], + }); + + registerInterview(ctx as any, { + chatClientFactory: () => null, + promptLoopFactory: () => ({ + next: async (message: string) => { + messages.push(message); + return answers.shift() ?? ''; + }, + close: () => {}, + }), + clearPrompt: async (message: string) => { + messages.push(message); + return /^y(es)?$/i.test((answers.shift() ?? '').trim()); + }, + }); + + return { + ctx, + id, + messages, + restore: () => { console.log = originalLog; }, + }; + } + + it('prompts to clear the flag and clears it on yes', async () => { + const t = setupInteractive({ answers: ['y'] }); + try { + await t.ctx.runCli(['interview', t.id]); + expect(t.messages.some(m => /Clear the needsProducerReview flag\? \(y\/N\)/.test(m))).toBe(true); + expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(false); + } finally { + t.restore(); + } + }); + + it('leaves the flag set on an explicit no', async () => { + const t = setupInteractive({ answers: ['n'] }); + try { + await t.ctx.runCli(['interview', t.id]); + expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(true); + expect(t.messages.some(m => /remains flagged/.test(m))).toBe(true); + } finally { + t.restore(); + } + }); + + it('leaves the flag set on empty input (default No)', async () => { + const t = setupInteractive({ answers: [''] }); + try { + await t.ctx.runCli(['interview', t.id]); + expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(true); + } finally { + t.restore(); + } + }); + + it('does not prompt when no explanation was shown (unflagged item)', async () => { + const t = setupInteractive({ needsProducerReview: false, answers: ['y'] }); + try { + await t.ctx.runCli(['interview', t.id]); + expect(t.messages.some(m => /Clear the needsProducerReview flag/.test(m))).toBe(false); + // Unchanged (still false). + expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(false); + } finally { + t.restore(); + } + }); + + it('never prompts or mutates in --json mode', async () => { + const ctx = createTestContext(); + const jsonOutput: any[] = []; + const messages: string[] = []; + const originalLog = console.log; + console.log = (...args: any[]) => { messages.push(args.join(' ')); }; + ctx.output = { json: (d: any) => jsonOutput.push(d), success: () => {}, error: () => {} }; + const id = ctx.utils.createSampleItem({}); + ctx.utils.db.update(id, { + description: '# Task\n\nNo clarifying section here.', + needsProducerReview: true, + }); + registerInterview(ctx as any, { chatClientFactory: () => null }); + + try { + await ctx.runCli(['interview', id, '--json']); + expect(jsonOutput).toHaveLength(1); + expect(ctx.utils.db.get(id).needsProducerReview).toBe(true); + expect(messages.some(m => /Clear the needsProducerReview flag/.test(m))).toBe(false); + } finally { + console.log = originalLog; + } + }); + + it('does not prompt when questions remain outstanding', async () => { + const desc = `# Task + +## Appendix: Clarifying questions + +- Q: "Scope?" — Answer: *(awaiting producer)*. Source: reply.`; + const t = setupInteractive({ description: desc, answers: ['answer', 'y'] }); + try { + await t.ctx.runCli(['interview', t.id]); + expect(t.messages.some(m => /Clear the needsProducerReview flag/.test(m))).toBe(false); + } finally { + t.restore(); + } + }); + + it('preserves the existing allAnswered auto-clear path (no prompt)', async () => { + const desc = `# Task + +## Appendix: Clarifying questions + +- Q: "Scope?" — Answer (user): "Repo-wide". Source: reply.`; + const t = setupInteractive({ description: desc, answers: [] }); + try { + await t.ctx.runCli(['interview', t.id]); + expect(t.messages.some(m => /Clear the needsProducerReview flag/.test(m))).toBe(false); + expect(t.ctx.utils.db.get(t.id).needsProducerReview).toBe(false); + } finally { + t.restore(); + } + }); +}); diff --git a/tests/cli/openbrain-close.test.ts b/tests/cli/openbrain-close.test.ts deleted file mode 100644 index b0965929f..000000000 --- a/tests/cli/openbrain-close.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Integration tests: close command triggers OpenBrain submission when - * openBrainEnabled is set in config. - * - * We inject a fake `ob` binary path and a custom spawnImpl so we can assert - * that the submission is triggered without requiring the real OpenBrain CLI. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import { - cliPath, - execAsync, - enterTempDir, - leaveTempDir, - writeConfig, - writeInitSemaphore, -} from './cli-helpers.js'; - -/** - * Write a config file with openBrainEnabled: true, including the full - * stages/statuses/compatibility section so status-stage validation works - * predictably in tests. - */ -function writeConfigWithOpenBrain(dir: string): void { - // Write the standard stages/statuses config first… - writeConfig(dir); - // …then append openBrainEnabled so the feature is activated. - const configPath = path.join(dir, '.worklog', 'config.yaml'); - fs.appendFileSync(configPath, '\nopenBrainEnabled: true\n', 'utf-8'); -} - -/** - * Write a config file WITHOUT openBrainEnabled (default off). - */ -function writeConfigWithoutOpenBrain(dir: string): void { - writeConfig(dir); -} - -describe('close command + OpenBrain integration', () => { - let tempState: { tempDir: string; originalCwd: string }; - - beforeEach(() => { - tempState = enterTempDir(); - writeInitSemaphore(tempState.tempDir); - }); - - afterEach(() => { - leaveTempDir(tempState); - }); - - it('closes a work item successfully (baseline, no OpenBrain config)', async () => { - writeConfigWithoutOpenBrain(tempState.tempDir); - - const { stdout: createOut } = await execAsync( - `tsx ${cliPath} --json create -t "Baseline close test"` - ); - const created = JSON.parse(createOut); - const id = created.workItem.id; - - const { stdout: closeOut } = await execAsync( - `tsx ${cliPath} --json close ${id} -r "done"` - ); - const closed = JSON.parse(closeOut); - expect(closed.closed).toBe(1); - expect(closed.failed).toBe(0); - }); - - it('closes a work item successfully when openBrainEnabled=true but ob is unavailable', async () => { - // Use a non-existent ob binary; the close must still succeed. - writeConfigWithOpenBrain(tempState.tempDir); - // Set WL_OB_BIN so the module picks up our fake binary. - const origEnv = process.env.WL_OB_BIN; - process.env.WL_OB_BIN = '/nonexistent/ob-fake'; - - try { - const { stdout: createOut } = await execAsync( - `tsx ${cliPath} --json create -t "OB unavailable test"` - ); - const created = JSON.parse(createOut); - const id = created.workItem.id; - - // The close itself must succeed even though ob is not on PATH. - const { stdout: closeOut } = await execAsync( - `tsx ${cliPath} --json close ${id} -r "done"` - ); - const closed = JSON.parse(closeOut); - expect(closed.closed).toBe(1); - expect(closed.failed).toBe(0); - } finally { - if (origEnv === undefined) { - delete process.env.WL_OB_BIN; - } else { - process.env.WL_OB_BIN = origEnv; - } - } - }); - - it('appends to queue when openBrainEnabled=true and ob fails', async () => { - // Write a mock ob script that exits non-zero. - const mockObDir = path.join(tempState.tempDir, 'mock-ob-bin'); - fs.mkdirSync(mockObDir, { recursive: true }); - const mockObPath = path.join(mockObDir, 'ob'); - fs.writeFileSync(mockObPath, '#!/bin/sh\nexit 1\n', 'utf-8'); - fs.chmodSync(mockObPath, 0o755); - - writeConfigWithOpenBrain(tempState.tempDir); - const origEnv = process.env.WL_OB_BIN; - process.env.WL_OB_BIN = mockObPath; - - try { - const { stdout: createOut } = await execAsync( - `tsx ${cliPath} --json create -t "OB fail test"` - ); - const created = JSON.parse(createOut); - const id = created.workItem.id; - - // Close must succeed even though ob exits 1. - const { stdout: closeOut } = await execAsync( - `tsx ${cliPath} --json close ${id}` - ); - const closed = JSON.parse(closeOut); - expect(closed.closed).toBe(1); - expect(closed.failed).toBe(0); - - // Give the background process a moment to write the queue entry. - await new Promise(r => setTimeout(r, 500)); - - const queuePath = path.join(tempState.tempDir, '.worklog', 'openbrain-queue.jsonl'); - // Queue file may or may not exist depending on timing; either outcome is - // acceptable — what matters is the close succeeded and, if the queue was - // written, it contains a valid entry. - if (fs.existsSync(queuePath)) { - const lines = fs.readFileSync(queuePath, 'utf-8').trim().split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThanOrEqual(1); - // Find the entry for the id we just closed (there may be others from - // earlier runs sharing the same temp dir in in-process mode). - const entries = lines.map(l => JSON.parse(l)); - const match = entries.find(e => e.workItemId === id); - if (match) { - expect(match.workItemId).toBe(id); - } - } - } finally { - if (origEnv === undefined) { - delete process.env.WL_OB_BIN; - } else { - process.env.WL_OB_BIN = origEnv; - } - } - }); - - it('update --status completed triggers OpenBrain when enabled', async () => { - writeConfigWithOpenBrain(tempState.tempDir); - const origEnv = process.env.WL_OB_BIN; - process.env.WL_OB_BIN = '/nonexistent/ob-fake'; - - try { - // Create in default open/idea state. - const { stdout: createOut } = await execAsync( - `tsx ${cliPath} --json create -t "Update to completed"` - ); - const created = JSON.parse(createOut); - const id = created.workItem.id; - - // Update to completed + in_review (a compatible stage) — must succeed - // even though ob is unavailable. - const { stdout: updateOut } = await execAsync( - `tsx ${cliPath} --json update ${id} --status completed --stage in_review` - ); - const updated = JSON.parse(updateOut); - expect(updated.success).toBe(true); - expect(updated.workItem.status).toBe('completed'); - } finally { - if (origEnv === undefined) { - delete process.env.WL_OB_BIN; - } else { - process.env.WL_OB_BIN = origEnv; - } - } - }); -}); diff --git a/tests/lib/llm.test.ts b/tests/lib/llm.test.ts new file mode 100644 index 000000000..19596ca6d --- /dev/null +++ b/tests/lib/llm.test.ts @@ -0,0 +1,256 @@ +/** + * Tests for the LLM chat-completion client (src/lib/llm.ts). + * + * Covers: + * - `available` resolution from config, environment variables and defaults + * (mirroring the `OpenAIEmbedder` pattern). + * - `complete()` request construction (URL, model, user message, auth header, + * optional max_tokens) and response parsing via a mocked global `fetch`. + * - Graceful degradation: unavailable client, non-2xx responses, empty + * responses and timeouts all surface as descriptive errors — no network + * calls are ever made. + * + * WL-0MULI0LXL00829JI (ChatClient: OpenAI-compatible chat completions) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + OpenAIChatClient, + DEFAULT_LLM_BASE_URL, + DEFAULT_LLM_MODEL, + DEFAULT_LLM_TIMEOUT_MS, + type ChatClient, +} from '../../src/lib/llm.js'; + +/** Environment variables consulted by the client (isolated per test). */ +const LLM_ENV_KEYS = [ + 'LLM_BASE_URL', + 'LLM_MODEL', + 'LLM_API_KEY', + 'LLM_TIMEOUT_MS', +] as const; + +/** Build an OpenAI-compatible JSON response with one completion choice. */ +function chatResponse(content: string): Response { + return new Response( + JSON.stringify({ choices: [{ message: { content } }] }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); +} + +describe('OpenAIChatClient', () => { + const savedEnv: Record<string, string | undefined> = {}; + + beforeEach(() => { + for (const key of LLM_ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of LLM_ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + // ── Availability ─────────────────────────────────────────────────────── + + describe('availability', () => { + it('is unavailable when no config and no env vars are set', () => { + expect(new OpenAIChatClient().available).toBe(false); + }); + + it('is available when hasExplicitConfig is true', () => { + expect(new OpenAIChatClient({ hasExplicitConfig: true }).available).toBe(true); + }); + + it('is available when an apiKey is provided via config', () => { + expect(new OpenAIChatClient({ apiKey: 'test-key' }).available).toBe(true); + }); + + it('is available when LLM_BASE_URL is set in the environment', () => { + process.env.LLM_BASE_URL = 'http://localhost:11434/v1'; + expect(new OpenAIChatClient().available).toBe(true); + }); + + it('is available when LLM_MODEL is set in the environment', () => { + process.env.LLM_MODEL = 'compact'; + expect(new OpenAIChatClient().available).toBe(true); + }); + + it('satisfies the ChatClient interface contract', () => { + const client: ChatClient = new OpenAIChatClient({ hasExplicitConfig: true }); + expect(typeof client.complete).toBe('function'); + }); + }); + + // ── complete() ───────────────────────────────────────────────────────── + + describe('complete()', () => { + function mockFetch( + impl: (url: string, init: RequestInit) => Promise<Response>, + ): ReturnType<typeof vi.fn> { + const fn = vi.fn(impl); + vi.stubGlobal('fetch', fn); + return fn; + } + + it('posts the prompt to {baseUrl}/chat/completions and returns the content', async () => { + const fetchMock = mockFetch(async () => chatResponse('Do this.')); + const client = new OpenAIChatClient({ + hasExplicitConfig: true, + baseUrl: 'http://proxy.local/v1', + model: 'compact', + }); + + const result = await client.complete('Explain the flag'); + + expect(result).toBe('Do this.'); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://proxy.local/v1/chat/completions'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + model: 'compact', + messages: [{ role: 'user', content: 'Explain the flag' }], + }); + }); + + it('strips a trailing slash from the base URL', async () => { + const fetchMock = mockFetch(async () => chatResponse('ok')); + const client = new OpenAIChatClient({ + hasExplicitConfig: true, + baseUrl: 'http://proxy.local/v1/', + }); + + await client.complete('hi'); + expect(fetchMock.mock.calls[0][0]).toBe('http://proxy.local/v1/chat/completions'); + }); + + it('defaults the model to compact when unset', async () => { + const fetchMock = mockFetch(async () => chatResponse('ok')); + const client = new OpenAIChatClient({ hasExplicitConfig: true }); + + await client.complete('hi'); + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string).model).toBe( + DEFAULT_LLM_MODEL, + ); + }); + + it('forwards maxTokens as max_tokens when provided', async () => { + const fetchMock = mockFetch(async () => chatResponse('ok')); + const client = new OpenAIChatClient({ hasExplicitConfig: true }); + + await client.complete('hi', { maxTokens: 42 }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string).max_tokens).toBe(42); + }); + + it('omits max_tokens when not provided', async () => { + const fetchMock = mockFetch(async () => chatResponse('ok')); + const client = new OpenAIChatClient({ hasExplicitConfig: true }); + + await client.complete('hi'); + expect(JSON.parse(fetchMock.mock.calls[0][1].body as string)).not.toHaveProperty( + 'max_tokens', + ); + }); + + it('sends an Authorization header when an apiKey is configured', async () => { + const fetchMock = mockFetch(async () => chatResponse('ok')); + const client = new OpenAIChatClient({ apiKey: 'secret-key' }); + + await client.complete('hi'); + const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>; + expect(headers.Authorization).toBe('Bearer secret-key'); + }); + + it('omits the Authorization header when no apiKey is configured', async () => { + const fetchMock = mockFetch(async () => chatResponse('ok')); + const client = new OpenAIChatClient({ hasExplicitConfig: true }); + + await client.complete('hi'); + const headers = fetchMock.mock.calls[0][1].headers as Record<string, string>; + expect(headers.Authorization).toBeUndefined(); + }); + + it('throws a descriptive error when the client is unavailable', async () => { + const fetchMock = mockFetch(async () => chatResponse('never')); + const client = new OpenAIChatClient(); + + await expect(client.complete('hi')).rejects.toThrow(/not configured/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws on a non-2xx response', async () => { + mockFetch( + async () => + new Response('boom', { status: 500, statusText: 'Internal Server Error' }), + ); + const client = new OpenAIChatClient({ hasExplicitConfig: true }); + + await expect(client.complete('hi')).rejects.toThrow(/Chat API error: 500/); + }); + + it('throws when the response contains no content', async () => { + mockFetch(async () => new Response(JSON.stringify({ choices: [] }), { status: 200 })); + const client = new OpenAIChatClient({ hasExplicitConfig: true }); + + await expect(client.complete('hi')).rejects.toThrow(/empty/i); + }); + + it('rejects with a timeout error when the request exceeds the configured timeout', async () => { + mockFetch( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }), + ); + const client = new OpenAIChatClient({ hasExplicitConfig: true, timeoutMs: 20 }); + + await expect(client.complete('hi')).rejects.toThrow(/timed out/i); + }); + + it('honours a per-call timeout override', async () => { + mockFetch( + (_url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => { + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + }); + }), + ); + // Config timeout is long; the per-call override must win and fire fast. + const client = new OpenAIChatClient({ hasExplicitConfig: true, timeoutMs: 10_000 }); + + await expect(client.complete('hi', { timeoutMs: 20 })).rejects.toThrow(/timed out/i); + }); + + it('propagates network errors', async () => { + mockFetch(async () => { + throw new TypeError('fetch failed'); + }); + const client = new OpenAIChatClient({ hasExplicitConfig: true }); + + await expect(client.complete('hi')).rejects.toThrow(/fetch failed/); + }); + }); + + // ── Defaults ─────────────────────────────────────────────────────────── + + describe('defaults', () => { + it('exposes the documented default base URL, model and timeout', () => { + expect(DEFAULT_LLM_BASE_URL).toBe('http://192.168.0.199:8000/v1'); + expect(DEFAULT_LLM_MODEL).toBe('compact'); + expect(DEFAULT_LLM_TIMEOUT_MS).toBe(15000); + }); + }); +}); diff --git a/tests/unit/config-llm.test.ts b/tests/unit/config-llm.test.ts new file mode 100644 index 000000000..742d623e5 --- /dev/null +++ b/tests/unit/config-llm.test.ts @@ -0,0 +1,253 @@ +/** + * Tests for the optional `llm` configuration section and `resolveLlmConfig`. + * + * The `llm.*` section mirrors `embedding.*`: config-file values take + * precedence over environment variables, which take precedence over the + * built-in defaults (local LLM proxy, `compact` model, 15 s timeout). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + getConfigDir, + getConfigPath, + loadConfig, + resolveLlmConfig, +} from '../../src/config.js'; +import type { WorklogConfig } from '../../src/types.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; + +/** Minimal valid WorklogConfig used for direct resolveLlmConfig tests. */ +function baseConfig(overrides: Partial<WorklogConfig> = {}): WorklogConfig { + return { + projectName: 'Test', + prefix: 'TST', + statuses: [ + { value: 'open', label: 'Open' }, + { value: 'completed', label: 'Completed' }, + ], + stages: [ + { value: 'idea', label: 'Idea' }, + { value: 'done', label: 'Done' }, + ], + statusStageCompatibility: { + open: ['idea'], + completed: ['done'], + }, + ...overrides, + }; +} + +describe('LLM configuration', () => { + let tempDir: string; + let originalCwd: string; + let savedEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + tempDir = createTempDir(); + originalCwd = process.cwd(); + process.chdir(tempDir); + + savedEnv = { ...process.env }; + delete process.env.LLM_BASE_URL; + delete process.env.LLM_MODEL; + delete process.env.LLM_API_KEY; + delete process.env.LLM_TIMEOUT_MS; + }); + + afterEach(() => { + process.chdir(originalCwd); + process.env = savedEnv; + cleanupTempDir(tempDir); + }); + + /** Write a config.yaml with the given body lines. */ + function writeConfig(body: string[]): void { + const configDir = getConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(getConfigPath(), body.join('\n'), 'utf-8'); + } + + describe('resolveLlmConfig precedence', () => { + it('returns undefined when neither config nor env vars are set', () => { + const result = resolveLlmConfig(baseConfig()); + expect(result).toBeUndefined(); + }); + + it('applies built-in defaults for an empty llm section', () => { + const result = resolveLlmConfig(baseConfig({ llm: {} })); + expect(result).toEqual({ + baseUrl: 'http://192.168.0.199:8000/v1', + model: 'compact', + apiKey: '', + timeoutMs: 15000, + }); + }); + + it('gives config values precedence over environment variables', () => { + process.env.LLM_BASE_URL = 'http://env-url/v1'; + process.env.LLM_MODEL = 'env-model'; + process.env.LLM_TIMEOUT_MS = '30000'; + + const result = resolveLlmConfig( + baseConfig({ + llm: { + baseUrl: 'http://config-url/v1', + model: 'config-model', + timeoutMs: 10000, + }, + }), + ); + + expect(result).toEqual({ + baseUrl: 'http://config-url/v1', + model: 'config-model', + apiKey: '', + timeoutMs: 10000, + }); + }); + + it('fills missing config fields from environment variables', () => { + process.env.LLM_BASE_URL = 'http://env-url/v1'; + process.env.LLM_TIMEOUT_MS = '30000'; + + const result = resolveLlmConfig(baseConfig({ llm: { model: 'config-model' } })); + + expect(result).toEqual({ + baseUrl: 'http://env-url/v1', + model: 'config-model', + apiKey: '', + timeoutMs: 30000, + }); + }); + + it('uses env vars alone when no llm section exists', () => { + process.env.LLM_BASE_URL = 'http://env-url/v1'; + + const result = resolveLlmConfig(baseConfig()); + + expect(result).toEqual({ + baseUrl: 'http://env-url/v1', + model: 'compact', + apiKey: '', + timeoutMs: 15000, + }); + }); + + it('ignores an invalid LLM_TIMEOUT_MS env var and falls back to the default', () => { + process.env.LLM_BASE_URL = 'http://env-url/v1'; + process.env.LLM_TIMEOUT_MS = 'not-a-number'; + + const result = resolveLlmConfig(baseConfig()); + + expect(result?.timeoutMs).toBe(15000); + }); + }); + + describe('loadConfig with an llm section', () => { + it('loads the full llm section from config.yaml', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'llm:', + ' baseUrl: http://localhost:8000/v1', + ' model: my-compact-model', + ' apiKey: secret-key', + ' timeoutMs: 20000', + ]); + + const config = loadConfig(); + expect(config?.llm).toEqual({ + baseUrl: 'http://localhost:8000/v1', + model: 'my-compact-model', + apiKey: 'secret-key', + timeoutMs: 20000, + }); + }); + + it('applies defaults when the llm section only sets the model', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'llm:', + ' model: compact', + ]); + + const config = loadConfig(); + expect(config?.llm?.baseUrl).toBe('http://192.168.0.199:8000/v1'); + expect(config?.llm?.model).toBe('compact'); + expect(config?.llm?.timeoutMs).toBe(15000); + }); + + it('leaves llm undefined when no llm config or env vars are present', () => { + writeConfig(['projectName: Test Project', 'prefix: TEST']); + + const config = loadConfig(); + expect(config).not.toBeNull(); + expect(config?.llm).toBeUndefined(); + }); + + it('does not set llm when only an embedding section is configured', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'embedding:', + ' baseUrl: http://localhost:11434/v1', + ' model: nomic-embed-text', + ]); + + const config = loadConfig(); + expect(config?.embedding).toEqual({ + baseUrl: 'http://localhost:11434/v1', + model: 'nomic-embed-text', + }); + expect(config?.llm).toBeUndefined(); + }); + }); + + describe('llm validation', () => { + it('rejects a non-object llm section', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'llm: not-an-object', + ]); + + expect(loadConfig()).toBeNull(); + }); + + it('rejects a non-positive llm.timeoutMs', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'llm:', + ' timeoutMs: -1', + ]); + + expect(loadConfig()).toBeNull(); + }); + + it('rejects an empty llm.baseUrl string', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'llm:', + ' baseUrl: ""', + ]); + + expect(loadConfig()).toBeNull(); + }); + + it('rejects a non-string llm.apiKey', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'llm:', + ' apiKey: 123', + ]); + + expect(loadConfig()).toBeNull(); + }); + }); +}); diff --git a/tests/unit/openbrain.test.ts b/tests/unit/openbrain.test.ts deleted file mode 100644 index 5024e079d..000000000 --- a/tests/unit/openbrain.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { EventEmitter } from 'events'; -import { - buildOpenBrainSummary, - submitToOpenBrain, - appendToQueue, - resolveObBinary, - OPENBRAIN_QUEUE_FILE, -} from '../../src/openbrain.js'; -import type { WorkItem } from '../../src/types.js'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeWorkItem(overrides: Partial<WorkItem> = {}): WorkItem { - return { - id: 'TEST-001', - title: 'My Test Task', - description: 'Do some important work', - status: 'completed', - priority: 'medium', - sortIndex: 0, - parentId: null, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - tags: [], - assignee: 'alice', - stage: 'done', - issueType: 'feature', - createdBy: '', - deletedBy: '', - deleteReason: '', - risk: '', - effort: '', - ...overrides, - }; -} - -/** - * Build a minimal fake child-process that behaves like the real spawn return value. - */ -function makeFakeChild(exitCode = 0) { - const stdin = new EventEmitter() as any; - stdin.write = vi.fn(); - stdin.end = vi.fn(); - - const stderr = new EventEmitter() as any; - const child = new EventEmitter() as any; - child.stdin = stdin; - child.stderr = stderr; - child.unref = vi.fn(); - - // Emit close on the next tick. - setTimeout(() => child.emit('close', exitCode), 0); - - return child; -} - -// --------------------------------------------------------------------------- -// buildOpenBrainSummary -// --------------------------------------------------------------------------- - -describe('buildOpenBrainSummary', () => { - it('includes work item id and title', () => { - const item = makeWorkItem(); - const summary = buildOpenBrainSummary(item); - expect(summary).toContain('TEST-001'); - expect(summary).toContain('My Test Task'); - }); - - it('includes description when present', () => { - const item = makeWorkItem({ description: 'A detailed objective' }); - const summary = buildOpenBrainSummary(item); - expect(summary).toContain('A detailed objective'); - }); - - it('omits description section when empty', () => { - const item = makeWorkItem({ description: '' }); - const summary = buildOpenBrainSummary(item); - expect(summary).not.toContain('## Objective'); - }); - - it('includes audit text when present', () => { - const item = makeWorkItem({}); - const auditResult = { workItemId: 'TEST-1', readyToClose: true, auditedAt: '2024-01-02T00:00:00.000Z', summary: 'Ready to close: Yes\nDid the work', rawOutput: null, author: 'alice' }; - const summary = buildOpenBrainSummary(item, auditResult); - expect(summary).toContain('Did the work'); - expect(summary).toContain('## What was done'); - }); - - it('omits audit section when audit is missing', () => { - const item = makeWorkItem({}); - const summary = buildOpenBrainSummary(item, null); - expect(summary).not.toContain('## What was done'); - }); - - it('includes assignee', () => { - const item = makeWorkItem({ assignee: 'bob' }); - const summary = buildOpenBrainSummary(item); - expect(summary).toContain('bob'); - }); - - it('includes issue type', () => { - const item = makeWorkItem({ issueType: 'bug' }); - const summary = buildOpenBrainSummary(item); - expect(summary).toContain('bug'); - }); -}); - -// --------------------------------------------------------------------------- -// resolveObBinary -// --------------------------------------------------------------------------- - -describe('resolveObBinary', () => { - const origEnv = process.env.WL_OB_BIN; - - afterEach(() => { - if (origEnv === undefined) { - delete process.env.WL_OB_BIN; - } else { - process.env.WL_OB_BIN = origEnv; - } - }); - - it('returns "ob" by default', () => { - delete process.env.WL_OB_BIN; - expect(resolveObBinary()).toBe('ob'); - }); - - it('uses WL_OB_BIN env variable', () => { - process.env.WL_OB_BIN = '/usr/local/bin/ob'; - expect(resolveObBinary()).toBe('/usr/local/bin/ob'); - }); - - it('uses explicit override over env var', () => { - process.env.WL_OB_BIN = '/usr/local/bin/ob'; - expect(resolveObBinary('/custom/ob')).toBe('/custom/ob'); - }); -}); - -// --------------------------------------------------------------------------- -// appendToQueue -// --------------------------------------------------------------------------- - -describe('appendToQueue', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-ob-test-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('creates a JSONL queue file and appends entries', () => { - const entry = { - workItemId: 'TEST-001', - title: 'My Task', - summary: '# My Task', - enqueuedAt: new Date().toISOString(), - }; - appendToQueue(entry, tmpDir); - - const queuePath = path.join(tmpDir, OPENBRAIN_QUEUE_FILE); - expect(fs.existsSync(queuePath)).toBe(true); - const line = fs.readFileSync(queuePath, 'utf-8').trim(); - const parsed = JSON.parse(line); - expect(parsed.workItemId).toBe('TEST-001'); - expect(parsed.summary).toBe('# My Task'); - }); - - it('appends multiple entries as separate lines', () => { - const base = { title: 'x', summary: 'y', enqueuedAt: new Date().toISOString() }; - appendToQueue({ ...base, workItemId: 'A' }, tmpDir); - appendToQueue({ ...base, workItemId: 'B' }, tmpDir); - - const queuePath = path.join(tmpDir, OPENBRAIN_QUEUE_FILE); - const lines = fs.readFileSync(queuePath, 'utf-8').trim().split('\n'); - expect(lines).toHaveLength(2); - expect(JSON.parse(lines[0]).workItemId).toBe('A'); - expect(JSON.parse(lines[1]).workItemId).toBe('B'); - }); -}); - -// --------------------------------------------------------------------------- -// submitToOpenBrain -// --------------------------------------------------------------------------- - -describe('submitToOpenBrain', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-ob-submit-')); - }); - - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it('spawns ob add and writes summary to stdin', async () => { - const item = makeWorkItem(); - const spawnCalls: Array<{ cmd: string; args: string[] }> = []; - - const fakeSpawn = (cmd: string, args: string[]) => { - spawnCalls.push({ cmd, args }); - return makeFakeChild(0); - }; - - await submitToOpenBrain(item, { - obBin: '/fake/ob', - spawnImpl: fakeSpawn as any, - queueDir: tmpDir, - waitForCompletion: true, - }); - - expect(spawnCalls).toHaveLength(1); - expect(spawnCalls[0].cmd).toBe('/fake/ob'); - expect(spawnCalls[0].args).toContain('add'); - expect(spawnCalls[0].args).toContain('--stdin'); - expect(spawnCalls[0].args).toContain(item.title); - }); - - it('uses fully detached non-blocking spawn mode by default', async () => { - const item = makeWorkItem(); - const spawnCalls: Array<{ cmd: string; args: string[]; opts: any; child: any }> = []; - - const fakeSpawn = (cmd: string, args: string[], opts: any) => { - const child = makeFakeChild(0); - spawnCalls.push({ cmd, args, opts, child }); - return child; - }; - - await submitToOpenBrain(item, { - obBin: '/fake/ob', - spawnImpl: fakeSpawn as any, - queueDir: tmpDir, - // default waitForCompletion=false - }); - - expect(spawnCalls).toHaveLength(1); - expect(spawnCalls[0].opts.detached).toBe(true); - expect(spawnCalls[0].opts.stdio).toEqual(['pipe', 'ignore', 'ignore']); - expect(spawnCalls[0].child.unref).toHaveBeenCalledTimes(1); - }); - - it('does not write to the queue when ob add succeeds', async () => { - const item = makeWorkItem(); - - await submitToOpenBrain(item, { - obBin: '/fake/ob', - spawnImpl: (() => makeFakeChild(0)) as any, - queueDir: tmpDir, - waitForCompletion: true, - }); - - const queuePath = path.join(tmpDir, OPENBRAIN_QUEUE_FILE); - expect(fs.existsSync(queuePath)).toBe(false); - }); - - it('appends to queue when ob add exits with non-zero code', async () => { - const item = makeWorkItem(); - - await submitToOpenBrain(item, { - obBin: '/fake/ob', - spawnImpl: (() => makeFakeChild(1)) as any, - queueDir: tmpDir, - waitForCompletion: true, - }); - - const queuePath = path.join(tmpDir, OPENBRAIN_QUEUE_FILE); - expect(fs.existsSync(queuePath)).toBe(true); - const parsed = JSON.parse(fs.readFileSync(queuePath, 'utf-8').trim()); - expect(parsed.workItemId).toBe(item.id); - }); - - it('appends to queue when spawn throws (ob not found)', async () => { - const item = makeWorkItem(); - - const fakeSpawn = () => { - throw new Error('spawn ob ENOENT'); - }; - - await submitToOpenBrain(item, { - obBin: '/nonexistent/ob', - spawnImpl: fakeSpawn as any, - queueDir: tmpDir, - waitForCompletion: true, - }); - - const queuePath = path.join(tmpDir, OPENBRAIN_QUEUE_FILE); - expect(fs.existsSync(queuePath)).toBe(true); - const parsed = JSON.parse(fs.readFileSync(queuePath, 'utf-8').trim()); - expect(parsed.workItemId).toBe(item.id); - expect(parsed.reason).toContain('ENOENT'); - }); - - it('appends to queue on child process error event', async () => { - const item = makeWorkItem(); - - const fakeSpawn = () => { - const child = new EventEmitter() as any; - const stdin = new EventEmitter() as any; - stdin.write = vi.fn(); - stdin.end = vi.fn(); - child.stdin = stdin; - child.stderr = new EventEmitter(); - child.unref = vi.fn(); - // Emit error on next tick instead of close - setTimeout(() => child.emit('error', new Error('connection refused')), 0); - return child; - }; - - await submitToOpenBrain(item, { - obBin: '/fake/ob', - spawnImpl: fakeSpawn as any, - queueDir: tmpDir, - waitForCompletion: true, - }); - - const queuePath = path.join(tmpDir, OPENBRAIN_QUEUE_FILE); - expect(fs.existsSync(queuePath)).toBe(true); - const parsed = JSON.parse(fs.readFileSync(queuePath, 'utf-8').trim()); - expect(parsed.reason).toContain('connection refused'); - }); -});