diff --git a/.githooks/pre-push b/.githooks/pre-push index 0079d83ff..5edca838a 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -1,19 +1,64 @@ #!/bin/sh -# worklog:pre-push-hook:v1 -# -# Auto-sync Worklog data before pushing. -# -# This hook runs `wl sync` before any push to ensure worklog data is -# committed and pushed to the dedicated refs/worklog/data branch. -# It uses a temporary worktree to avoid corrupting the project working tree -# (see WL-0MQRBT8BS00355AB for the bug this prevents). -# -# BYPASS: Set WORKLOG_SKIP_PRE_PUSH=1 to skip the sync entirely. -# export WORKLOG_SKIP_PRE_PUSH=1 -# git push origin HEAD:refs/heads/dev +# worklog:pre-push-hook:v2 +# 1. Auto-sync Worklog data before pushing (committed hooks). +# 2. Enforce branch naming policy: block pushes to protected branches. +# 3. Context-budget regression gate (F6, SA-0MSLK7XNZ00366YY): fail the push +# when the startup context surface exceeds committed thresholds. # +# Set WORKLOG_SKIP_PRE_PUSH=1 to bypass worklog sync. +# Set BRANCH_POLICY_SKIP=1 to bypass branch policy check (not recommended). +# Set CONTEXT_BUDGET_SKIP=1 to bypass the context-budget gate (not recommended). set -e +# ── Branch policy enforcement ──────────────────────────────────────────────── +# Check that the current branch is not a protected branch. +if [ "$BRANCH_POLICY_SKIP" != "1" ]; then + current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + # Protected branches that agents must never push to. + blocked_branches="main master HEAD" + for blocked in $blocked_branches; do + if [ "$current_branch" = "$blocked" ]; then + echo "ERROR: Push to '$current_branch' is blocked by branch policy." >&2 + echo " Regular agents must NOT push directly to main." >&2 + echo " Work in feature branches (wl--) and push into dev." >&2 + echo " See the framework ship skill for the push-to-dev workflow." >&2 + exit 1 + fi + done +fi + +# ── Context-budget regression gate ────────────────────────────────────────── +# Fail the push when the pi session startup context surface exceeds the +# committed thresholds (docs/dev/context-budget.thresholds.json). The gate is +# fail-open when the tooling or thresholds file is absent (e.g. worktrees of +# old commits, or repositories that do not ship the context-audit skill). +# measure_context.py is resolved first from the local skill/context-audit/ +# path, then from the installed global symlink +# (~/.pi/agent/skills/context-audit/scripts/measure_context.py) installed by +# the framework's install_pi.sh. +if [ "$CONTEXT_BUDGET_SKIP" != "1" ]; then + repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -f "$repo_root/skill/context-audit/scripts/measure_context.py" ]; then + measure="$repo_root/skill/context-audit/scripts/measure_context.py" + elif [ -f "$HOME/.pi/agent/skills/context-audit/scripts/measure_context.py" ]; then + measure="$HOME/.pi/agent/skills/context-audit/scripts/measure_context.py" + else + measure="" + fi + thresholds="$repo_root/docs/dev/context-budget.thresholds.json" + if [ -n "$repo_root" ] && [ -n "$measure" ] && [ -f "$measure" ] && [ -f "$thresholds" ]; then + if python3 "$measure" --repo-root "$repo_root" --include-hidden --thresholds "$thresholds" >/dev/null 2>&1; then + : + else + echo "ERROR: Context budget exceeded committed thresholds." >&2 + echo " Run: python3 $measure --repo-root $repo_root --include-hidden --thresholds $thresholds" >&2 + echo " See docs/dev/context-budget.md. Set CONTEXT_BUDGET_SKIP=1 to bypass." >&2 + exit 1 + fi + fi +fi + +# ── Worklog sync ───────────────────────────────────────────────────────────── if [ "$WORKLOG_SKIP_PRE_PUSH" = "1" ]; then echo "worklog: pre-push sync skipped (WORKLOG_SKIP_PRE_PUSH=1)" exit 0 diff --git a/API.md b/API.md index cf4922b86..0c588a176 100644 --- a/API.md +++ b/API.md @@ -51,6 +51,10 @@ npm start **Note:** All endpoints also support project prefix routing via `/projects/:prefix/...` +### Audit field on updates + +`PUT /items/:id` (and the prefixed variant) accepts an `audit` body field containing structured audit text whose first non-empty line is `Ready to close: Yes` or `Ready to close: No`. The audit is routed to the `audit_results` table (the sole source of truth for audit state). When the verdict is `Ready to close: No` and the item is in `in_review` (status `completed`), the item is automatically reverted to `open`/`plan_complete` (priority preserved) and the response includes a `reverted` field (`{ item, from: { status, stage }, to: { status, stage } }`) — consistent with the CLI audit write paths. See docs/AUDIT_STATUS.md. + ## Examples ### Create a Work Item diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9cc71d0..5634da2cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## v1.0.12 (2026-08-25) +### Features +- Herdr downtime dispatcher: scheduled prompts (best-effort frequency) config (WL-0MSS1Q5ER007QDKX) +- Downtime dispatcher: priority-first selection with round-robin tie-break across instances (WL-0MSSRED76008LGB6) +- Exclude child items from audit-tier dispatch selection (WL-0MSTLFW14000KPEC) +- Shortcut key to toggle downtime dispatch per herdr instance (WL-0MSZ4NSOE007AQEF) +- Downtime dispatcher: critical items always progress first regardless of stage (stage-appropriate skill) (WL-0MT3FM8VA005XBHE) +- Use centered toast for shortcut feedback instead of status line (WL-0MS7EP7U8008DCXO) +- FTS search: keep index fresh (wire upsertFtsEntry into save paths) and surface query syntax errors (WL-0MSLW8UCP001771K) +- Refactor Downtime Dispatcher: leader election with shared coordination file (WL-0MST3OJ8S0001ROL) +- Detail view ToC overflows pane — first lines scroll off the top of the screen (WL-0MSI28AP80002F5S) +- Dispatching continues while downtime dispatch is disabled (WL-0MT4BWUHW008LIFE) +- Downtime dispatch: use available slots while some slots busy (spare-capacity dispatch, default N instead of all-slots-free) (WL-0MT32F90V008UAD2) +- Subscribe to herdr window events instead of polling active window status (WL-0MSHB7DHO004RHBJ) +- Enable cut and paste in the herdr command-input page (all command forms) (WL-0MSW6KCTA0092DCV) +- Refresh the selection list when background (no-pane) shortcut commands complete (WL-0MT1KB70U0012X6T) +- Herdr worklist: mouse and touch support in the selection list (WL-0MSGHM5BQ0096BNJ) +- Herdr selection list: metadata section should use the same icons as the list section (WL-0MSGIXHHI009KFW9) +- Keep focus on the Herdr selection list when a shortcut command creates a pane (WL-0MSHIA53D009DJOT) +- Add per-shortcut option to control whether a shortcut opens a command pane (WL-0MSJLD1I70045ZUL) +- Herdr: filter by priority via f-p-* chords (stage filters become f-s-*) (WL-0MSKC8T46006999S) +- Herdr selection list: collapsible group headings with item counts (WL-0MSL5MPSZ003TG94) +- a-r (audit reject) should reset item to open/plan_complete/medium (WL-0MSM72QXN008GUP2) +- Herdr: flipping needsProducerReview must not make a valid audit stale (WL-0MSN6ZCTN0027U2R) +- Add tolerant work-item ID resolution to wl show (exact-first, unique substring fallback, ambiguity error) for agent tool use (WL-0MSOMI4GQ009WL8E) +- Incremental sync: only export/push changed work items instead of full JSONL (WL-0MSAKUBKW006FN8Q) +- Show complete, right-aligned model name on footer Line 3 (remove 30-col truncation) (WL-0MSMDYJ3X00895MP) +### Bug Fixes +- Fix riskOrdinal/effortOrdinal exact-match in packages/shared database.ts (server-side wl next risk/effort filter) (WL-0MT3FLZJ20076X9B) +- Audit lifecycle: verify status/stage transition after persist (silent failure leaves items in idea) (WL-0MSVVFBJ2003RRYK) +- Herdr worklist pane crashes with exit code 1 seconds after opening (prefix+l) due to unhandled ECONNRESET (WL-0MSZXB3OF009V5IQ) +- Fix NVIDIA kernel/userspace driver API mismatch (chrome-headless) (WL-0MT1KJNDK004SNPK) +- Herdr selection list: no indication of items below the fold (WL-0MSG8YXYJ008PWJJ) +- Herdr plugin: activity-gated proxy mode switching (cheap on operator idle, fast on operator command) (WL-0MSN3FWV5008KQE9) +- Downtime title bar shows 'idle' while llama UI is busy (per-slot mode display bug) (WL-0MT65T14L002HTWB) +- snapd udev monitor goroutine hang - watchdog kills and restart loops (WL-0MT1KJNHE0048HW3) +- Audit auto-green-run never works for ContextHub: NODE_SUITE_DIRS includes tests/node which does not exist (WL-0MSKSMRFP001Q3XU) +- Auto-revert in_review items to plan_complete when marked not ready to close (WL-0MSKHYI5U0069FVV) +### Other +- Worklist refresh wl spawns have no timeout — a hung wl wedges refreshInFlight permanently (WL-0MSJNJXX2001NMHS) +- Investigate chrome-headless workload causing GPU crashes and memory pressure (WL-0MT1KJNHY00286QE) +- wl pre-push author-identity gate blocks all pushes (empty user.email) (CG-0MSUZASM10035QSG) +- Operator action: reset herdr refreshIntervalMs to 30000 and restart pre-fix panes (WL-0MSBVYN700074A1T) +- Demote completed/in_review parent to plan_complete when a child is added (WL-0MSJL00P5004Y0L6) +- Repeat questions in final summary when dispatched in downtime (WL-0MT50K6BI000T052) +- Extensible shortcuts.json: bundled defaults + project-local overrides (WL-0MSHUMX5C004NC4O) +- wl next: exclude stage=done items from results by default (WL-0MSGRJWRX0068W3W) +- Provide worklog shortcuts when working with a work item id (WL-0MQTHL7ER0012JK1) +- Create npm run install routine for install steps and add install:plugin rule (WL-0MSHZR0J2000Q9SW) +- Rename 'Podcast Editing' tab to 'Worklog' (WL-0MSI4TAT70058921) +- Merge icons.ts into theme.ts as single source of truth; deprecate icons.ts wrapper (WL-0MSJ4BT4Z009LIB4) +- Lower downtimeIdleThresholdMs 240s -> ~60s (plus downtimePollIntervalMs 30s -> 10s floor) (WL-0MSOMV6CW003OXTK) +- Fully remove deprecated icons.ts from all projects that use it (src + herdr) (WL-0MSJ4BT4Z002HH9B) + ## v1.0.11 (2026-08-16) ### Features - Idle tracking now requires the same slots to stay free, not just any slots. (WL-0MSG7P9N8009PCKG) diff --git a/CLI.md b/CLI.md index bdbabb327..495bf7828 100644 --- a/CLI.md +++ b/CLI.md @@ -152,6 +152,8 @@ Automatic re-sort: Update fields on one or more existing work items. Accepts multiple IDs. Options mirror `create` for updatable fields, plus `--description-file ` (read description from a file), `--audit-text ` and `--audit-file ` (read audit text from a file; writes to the `audit_results` table), `--needs-producer-review ` (set needsProducerReview flag), and `--do-not-delegate ` (set or clear the do-not-delegate tag). +> **Auto-revert:** when `--audit-text`/`--audit-file` carries a `Ready to close: No` verdict for an item in `in_review` (status `completed`), the item is automatically reverted to `open`/`plan_complete` (priority preserved) and the output reports the transition (`reverted` field in JSON, `[ID reverted from completed/in_review to open/plan_complete]` in human mode). See docs/AUDIT_STATUS.md. + Automatic re-sort: - `wl update` will automatically invoke a re-sort when one or more updated fields are among: `status`, `priority`, `risk`, `effort`, or `stage`. By default this re-sort runs asynchronously so the CLI is not blocked. This helps `wl next` and other selection-based commands reflect recent priority or status changes without requiring a manual `wl re-sort`. @@ -261,6 +263,8 @@ Behavior: - Automatically sets `audited_at` to the current ISO 8601 timestamp. - Derives `author` from `WL_USER` / `USER` / `USERNAME` environment variables unless overridden by `--author`. +> **Auto-revert:** when `--ready-to-close no` is set on an item in `in_review` (status `completed`), the item is automatically reverted to `open`/`plan_complete` (priority preserved) and the output reports the transition (`reverted` field in JSON, `[ID reverted from completed/in_review to open/plan_complete]` in human mode). See docs/AUDIT_STATUS.md. + Options: - `--ready-to-close ` — Whether the work item is ready to close (required). @@ -440,6 +444,35 @@ Options: `-c, --children` — Also display descendants in a tree layout (optional). `--prefix ` (optional) `--no-icons` — Disable icon rendering for clean text output. When icons are disabled, priority and status display as plain text (e.g., `[CRIT]`, `[OPEN]`) instead of emoji. This is useful for scripting or copy-paste operations. +`--exact` — Force strict exact-match only (skip the tolerant substring fallback). + +#### Tolerant ID resolution + +`wl show` resolves work-item IDs with a two-tier strategy: + +1. **Exact match** — `normalizeCliId` normalises the ID (adds prefix if missing, + normalises case/prefix). If an item with that normalised ID exists, it is + returned. +2. **Substring fallback** — If no exact match is found and `--exact` is *not* + specified, the CLI scans all item IDs for a **case-insensitive substring** + match. When exactly one item matches, it is returned transparently. When + multiple items match, the CLI reports an `ambiguous-match` error listing the + candidate IDs so the caller can disambiguate. When nothing matches, the + CLI reports "Work item not found" (same as before). + +This tolerant resolution is especially useful for agent tooling that may hold +truncated, case-differing, or partial ID references. + +The `--exact` flag disables the substring fallback, restoring strict +exact-match semantics for callers that need deterministic behaviour. + +#### JSON error formats + +When `--json` is used the error output is always valid JSON: + +- **Ambiguous match** — `{"success": false, "error": "ambiguous-match", + "candidates": ["WL-XXX1", "WL-XXX2"]}` +- **Not found** — `{"success": false, "error": "Work item not found: "}` The output always includes `Risk` and `Effort` fields. When a field has no value a placeholder `—` is shown so the field is consistently visible for triage and prioritization. @@ -449,6 +482,9 @@ Examples: wl show WL-ABC123 wl --json show WL-ABC123 wl show WL-ABC123 -c +wl show WL-ABC # tolerant: resolves if unique substring +wl --json show WL-ABC # tolerant JSON output +wl show --exact WL-ABC # strict: fails if not an exact match ``` ### `next` [options] @@ -686,6 +722,10 @@ enhancement degrades gracefully when no embedder is configured. - **Partial ID** — Tokens of 8+ alphanumeric characters are matched as substrings against all work item IDs; partial matches appear below exact matches. - **Mixed queries** — `wl search WL-XXXXX some text` returns the ID match first, followed by FTS results for the full query (duplicates removed). +**Fresh index:** The FTS index is updated on every save path — creating/updating items, adding/updating/deleting comments, `import`, `upsert-items`, and status reconciliation — so `wl search` always reflects the latest data without a manual `--rebuild-index`. + +**Quoting punctuated terms:** Unquoted punctuated terms (version strings like `v0.1.11`, file paths like `src/lib/util.ts`, and IDs) are not valid FTS5 syntax, so the command auto-quotes the query as a phrase and prints a **warning** (also emitted as a `warning` field in `--json` output) — it is never a silent empty result. If auto-quoting also fails, the command exits non-zero with `Invalid search query: ...`. To search a literal punctuated phrase explicitly, quote it yourself: `wl search "v0.1.11"`. Genuinely zero-match queries return "No results found." with no warning. + Options: `-s, --status ` (optional) — Filter results by status @@ -705,7 +745,7 @@ Query embeddings are cached in-memory to avoid redundant API calls. `--semantic-only` (optional) — Return only semantic (embedding-based) results. Requires an embedder; errors if OPENAI_API_KEY is not set. `--prefix ` (optional) -`--json` (optional) — Output structured JSON with `id`, `title`, `status`, `priority`, `score`, `snippet`, `matchedField`. When `--semantic` is used, includes `semanticAvailable: true/false`. +`--json` (optional) — Output structured JSON with `id`, `title`, `status`, `priority`, `score`, `snippet`, `matchedField`. When the query was auto-quoted as a phrase (invalid FTS5 syntax), a `warning` field is included. When `--semantic` is used, includes `semanticAvailable: true/false`. Examples: @@ -1066,7 +1106,7 @@ Other commands cover repository bootstrap and local system status. Use these to ### `init` -Initialize Worklog configuration in the repository (creates `.worklog` and default config). `wl init` also installs `AGENTS.md` in the project root, prefixed with a pointer line to the global `AGENTS.md`. If `AGENTS.md` already contains the pointer line, installation is skipped (idempotent, no prompt). If `AGENTS.md` exists without the pointer, it prompts O/A/M — **O**verwrite (destructive), **A**dd pointer (keeps existing content), **M**anual (skip) — unless you pass `--agents-template` for unattended runs. When workflow templates are available, `wl init` prompts you to choose between no formal workflow, a basic Worklog-aware workflow, or manual management (unless you pass `--workflow-inline` for unattended runs). See [AGENTS.md Install Model](docs/AGENTS-INSTALL.md) for the full install flow. +Initialize Worklog configuration in the repository (creates `.worklog` and default config). `wl init` also installs `AGENTS.md` in the project root with the canonical global-reference structure (`## Global agent guidance` pointing at `~/.pi/agent/AGENTS.md` plus a `## Project-specific guidance` placeholder). If `AGENTS.md` already contains the global reference, installation is skipped (idempotent, no prompt). If `AGENTS.md` exists without the reference, it prompts O/A/M — **O**verwrite (destructive), **A**dd reference above existing content, **M**anual (skip) — unless you pass `--agents-template` for unattended runs. When workflow templates are available, `wl init` prompts you to choose between no formal workflow, a basic Worklog-aware workflow, or manual management (unless you pass `--workflow-inline` for unattended runs). See [AGENTS.md Install Model](docs/AGENTS-INSTALL.md) for the full install flow. Options: @@ -1074,7 +1114,7 @@ Options: - `--prefix ` — Issue ID prefix (optional). - `--auto-export ` — Auto-export data to JSONL after changes (optional). - `--auto-sync ` — Auto-sync data to git after changes (optional). -- `--agents-template ` — What to do when AGENTS.md exists (optional). Append inserts the pointer line at the top while keeping existing content. +- `--agents-template ` — What to do when AGENTS.md exists (optional). Append inserts the global-agents reference at the top while keeping existing content below. - `--workflow-inline ` — Answer the workflow prompt (yes chooses the basic workflow option; no chooses no formal workflow). Omit to prompt interactively. - `--stats-plugin-overwrite ` — Overwrite existing stats plugin if present (optional). diff --git a/CONFIG.md b/CONFIG.md index 26f6e44d7..aa2d77461 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -16,7 +16,7 @@ This will prompt you for: - **Issue ID prefix**: A short prefix for your issue IDs (e.g., WI, PROJ, TASK) - **Auto-sync**: Enable automatic git sync after changes (optional) -`wl init` installs `AGENTS.md` in the project root with a pointer line to the global `AGENTS.md`. If `AGENTS.md` already exists, it prompts before inserting the pointer and preserves the existing content (unless you pass `--agents-template` for unattended runs). When workflow templates are available, `wl init` prompts you to choose between no formal workflow, a basic Worklog-aware workflow, or manual management (unless you pass `--workflow-inline` for unattended runs). +`wl init` installs `AGENTS.md` in the project root with the canonical global-reference structure: a `## Global agent guidance` section pointing at the global `~/.pi/agent/AGENTS.md` file, plus a `## Project-specific guidance` placeholder for the project owner to fill in (never a copy of the global instruction set). If `AGENTS.md` already exists, it prompts before adding the global reference and preserves the existing content (unless you pass `--agents-template` for unattended runs). When workflow templates are available, `wl init` prompts you to choose between no formal workflow, a basic Worklog-aware workflow, or manual management (unless you pass `--workflow-inline` for unattended runs). **Note:** If you haven't installed the CLI globally, you can use `npm run cli -- init` for development. @@ -29,7 +29,7 @@ wl init --project-name "My Project" --prefix PROJ --auto-export yes --auto-sync ``` - `--workflow-inline yes` selects the basic workflow option. Use `--workflow-inline no` to skip workflow setup. -- `--agents-template append` inserts the global `AGENTS.md` pointer line at the top of your local `AGENTS.md` while preserving existing content. +- `--agents-template append` inserts the canonical global-agents reference at the top of your local `AGENTS.md` while preserving existing content below it. ### Example @@ -40,6 +40,46 @@ Issue ID prefix: MP This will create issues with IDs like `MP-0J8L1JQ3H8ZQ2K6D`, `MP-0J8L1JQ3H8ZQ2K6E`, etc. +## Scheduled-prompts config (`.worklog/scheduled-prompts.json`) + +`wl init` provisions a project-local scheduled-prompts config file at +`.worklog/scheduled-prompts.json` (create-if-absent — re-running `wl init` +never clobbers an existing file, so user edits and runtime state are +preserved). The file is consumed by the herdr plugin's downtime worker: +the worker dispatches each due prompt's text in a pi agent pane during +local-LLM idle time on a best-effort cadence (WL-0MSS1Q5ER007QDKX). + +The base set provisioned on a fresh init: + +```json +{ + "entries": [ + { + "id": "refactor", + "prompt": "/skill:refactor", + "intervalDays": 3, + "lastTriggeredAt": null + } + ] +} +``` + +Each entry carries: + +- `id` — stable entry id (used for the pane name `Downtime ` and the + rolling dispatch-log marker), +- `prompt` — any text the pi agent pane can run (e.g. `/skill:refactor`), +- `intervalDays` — best-effort frequency in whole days (a delayed dispatch + never fires more often than the frequency), +- `lastTriggeredAt` — ISO-8601 UTC datetime of the last dispatch + (`null` = never run; missing is treated as due). + +Add, remove, or adjust entries by editing the file directly — no code +changes needed. The file is gitignored (part of `.worklog/`). See the herdr +downtime-worker docs in `packages/herdr/README.md` for the dispatch +semantics (freeze gating, fail-closed absent/malformed handling, cooldown +interaction). + ## Configuration Override System The system loads configuration in this order: @@ -74,7 +114,7 @@ See [DATA_SYNCING.md](DATA_SYNCING.md) for full sync workflow details (git-backe ## Agent Onboarding (AGENTS.md) -AGENTS.md (the repository-facing onboarding/instructions file) is installed or updated by `wl init` when you consent during initialization. `wl init` is the canonical setup command: it writes config, attempts to install hooks, and can add the Worklog-aware AGENTS.md template into your repository. +AGENTS.md (the repository-facing onboarding/instructions file) is installed or updated by `wl init` when you consent during initialization. `wl init` is the canonical setup command: it writes config, attempts to install hooks, and can add the Worklog-aware AGENTS.md template into your repository. The installed template contains only a reference to the global `~/.pi/agent/AGENTS.md` (which carries the core work-item workflow and coding disciplines) plus a `## Project-specific guidance` placeholder — project-specific rules are added by the project owner, never by copying the global file. If you prefer to manage onboarding files manually, create an `AGENTS.md` in your project root with guidance for agents and contributors (the `templates/AGENTS.md` in the Worklog package is a good starting point). If you want concise Copilot guidance, add a `.github/copilot-instructions.md` file pointing at your AGENTS.md and key commands. diff --git a/DATA_SYNCING.md b/DATA_SYNCING.md index 7894b8c57..84469f780 100644 --- a/DATA_SYNCING.md +++ b/DATA_SYNCING.md @@ -31,6 +31,14 @@ Worklog stores work items and comments in `.worklog/worklog-data.jsonl` (the mos - Writes local DB + JSONL - Pushes the updated JSONL ref +> **Incremental sync (delta):** after the first full snapshot, `wl sync` +> pushes only the records that changed since the last successful push (a +> delta), keeping payloads proportional to the change set. The remote file +> carries a header distinguishing `full` vs `delta`; readers without a local +> base automatically fall back to a full snapshot. See +> [docs/design/incremental-sync.md](docs/design/incremental-sync.md) for the +> full architecture. + ### Typical Flow 1. Update local items (`wl create`, `wl update`, etc.) diff --git a/EXAMPLES.md b/EXAMPLES.md index e858668e1..d59ee06e2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -100,6 +100,31 @@ wl show WI-123 --json # Returns: workItem.auditResult = { readyToClose, summary, auditedAt, author } ``` +### Tolerant ID Resolution + +`wl show` tolerates truncated, case-differing, or partial ID references: + +```bash +# Full ID — works as before +wl show WI-123 + +# Partial ID — resolves if unique +wl show WI-1 +# → Returns WI-123 (unique substring match) + +# Case-insensitive substring +wl show wi-1 +# → Returns WI-123 + +# Ambiguous — multiple items match +wl show WI- +# → {"success": false, "error": "ambiguous-match", "candidates": ["WI-123", "WI-456"]} + +# Strict exact match only (skip substring) +wl show --exact WI-1 +# → Error: Work item not found (no exact match) +``` + ### Deleting Work Items ```bash diff --git a/PLUGIN_GUIDE.md b/PLUGIN_GUIDE.md index 3ed2a7702..d079f261a 100644 --- a/PLUGIN_GUIDE.md +++ b/PLUGIN_GUIDE.md @@ -647,3 +647,24 @@ You can also use Node.js debugging: ```bash node --inspect-brk $(which worklog) my-command ``` + +## Worklog-root config files + +`wl init` provisions configuration files under `.worklog/` that plugins can +consume (in addition to the plugin files in `.worklog/plugins/`). One example +is `.worklog/scheduled-prompts.json` (WL-0MSS1Q5ER007QDKX): `wl init` copies +`templates/scheduled-prompts.json` there on first init (create-if-absent — +re-running `wl init` never clobbers user edits or runtime state). The herdr +downtime worker reads the file to dispatch periodic maintenance prompts +(e.g. `/skill:refactor` every three days) during local-LLM idle time; the +worker updates each entry's `lastTriggeredAt` in place after a dispatch. +Plugins should follow the same rules as the CLI when consuming such files: + +- Treat an absent file as an empty/default set (never synthesize state into + the user's project on a read), +- Fail closed on malformed content (log the error, never crash the host), +- Write atomically (tmp + rename) when persisting runtime state so readers + never observe a partial write. + +See `CONFIG.md` and `packages/herdr/README.md` for the file format and the +worker's dispatch semantics. diff --git a/README.md b/README.md index e380bd2aa..ba7dd3662 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,12 @@ Install the extension globally: npm run install:pi-extension ``` +For a single-command install of all integrations (herdr plugin + Pi extension): + +```bash +npm run install +``` + Then start (or restart) `pi` and run: ```text @@ -101,6 +107,7 @@ You can get a lot of value from using Worklog as a memory for your agents. But y |----------|-------------| | [DATA_FORMAT.md](DATA_FORMAT.md) | JSONL data format, storage architecture, and field reference | | [DATA_SYNCING.md](DATA_SYNCING.md) | Git-backed syncing and GitHub Issue mirroring | +| [docs/design/incremental-sync.md](docs/design/incremental-sync.md) | Incremental (delta) sync architecture: delta format, full-snapshot cadence, fallback, backward compatibility (WL-0MSAKUBKW006FN8Q) | | [GIT_WORKFLOW.md](GIT_WORKFLOW.md) | Team collaboration patterns and Git hooks | ### Features @@ -138,6 +145,7 @@ You can get a lot of value from using Worklog as a memory for your agents. But y | [docs/validation/status-stage-inventory.md](docs/validation/status-stage-inventory.md) | Status/stage validation rules | | [docs/SKILL_AUTHORING.md](docs/SKILL_AUTHORING.md) | Skill authoring guide with script best practices | | [docs/dev/wl-process-healthcheck.md](docs/dev/wl-process-healthcheck.md) | wl process healthcheck watchdog usage (cron/systemd, thresholds) | +| [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 | ## Tutorials @@ -202,11 +210,12 @@ See [tests/README.md](tests/README.md) for detailed testing documentation. ### Test Mock Patterns -The `tests/extensions/worklog-browse-extension.test.ts` file (1700+ lines, 68 tests) demonstrates key mock patterns used by this project: +Work item tests under `tests/cli/` and `tests/unit/` demonstrate the mock +patterns used by this project: -- **Comprehensive `node:fs` mock:** Rather than stubbing only the 4 fs functions used directly by the code under test, the mock covers ~12 functions (`existsSync`, `statSync`, `lstatSync`, `readdirSync`, `accessSync`, `watch`, `promises`, `constants`, `readFileSync`, `writeFileSync`, `mkdirSync`, `realpathSync`) that may be called during module initialization. This prevents cascading errors and heap OOM during import. -- **`icons.js` mock:** The dynamic `require('dist/icons.js')` in the browse module is mocked with stub implementations for all 8 exported functions (`priorityIcon`, `statusIcon`, `stageIcon`, `auditIcon`, `epicIcon`, `iconsEnabled`, `riskIcon`, `effortIcon`), preventing uncontrolled file resolution during import. -- **Infinite loop guard:** `custom()` mocks return `{ type: 'shortcut', command: '/test-exit' }` instead of `null` to break the `runBrowseFlow` while(true) loop, preventing infinite-loop-based OOM in test mocks. +- **Comprehensive `node:fs` mock:** Rather than stubbing only the few fs functions used directly by the code under test, the mock covers the broader set that may be called during module initialization, preventing cascading errors and heap OOM during import. +- **Stale-import grep guard:** `tests/unit/icons.test.ts` fails the build if any file imports from the removed `icons.js` wrapper paths, preventing the deprecated `icons.ts` files from drifting back in. +- **Infinite loop guard:** `custom()` mocks return a terminating shortcut instead of `null` to break long-running TUI loops, preventing infinite-loop-based OOM in test mocks. ## License diff --git a/docs/AGENTS-INSTALL.md b/docs/AGENTS-INSTALL.md index ec37f3c12..1360fb6f6 100644 --- a/docs/AGENTS-INSTALL.md +++ b/docs/AGENTS-INSTALL.md @@ -1,12 +1,12 @@ # AGENTS.md Install Model How `wl init` installs agent guidance into a project, how that local guidance -relates to the global agent file, and where duplication/conflict risk remains. +relates to the global agent file, and why the model avoids duplication. > **Status:** documents the current behavior (verified against > `src/commands/init.ts` and `tests/cli/init.test.ts`). -> A recommendation to change this model is tracked in -> WL-0MSKEJK4G008BMS0 and the implementation vehicle is +> The prior duplicated model (full template copy + pointer line) was retired +> by SA-0MSITKWBP007VUJS; coordination with the Worklog side is tracked in > WL-0MSIXMKOX0052514 (open). ## Overview @@ -14,20 +14,18 @@ relates to the global agent file, and where duplication/conflict risk remains. Running `wl init` in a fresh project performs two agent-guidance installs: 1. **`templates/AGENTS.md` → `/AGENTS.md`** — the project-local - agent instruction file (258 lines of work-item tracking rules, CRITICAL - RULES, a CLI cheat-sheet, and architecture notes). + agent instruction file. The template now contains **only** the canonical + global-reference structure (a `## Global agent guidance` section pointing at + `~/.pi/agent/AGENTS.md` plus a `## Project-specific guidance` placeholder) — + it no longer duplicates the global instruction set. 2. **Workflow guidance → inlined into `AGENTS.md`** — the optional workflow template (`templates/WORKFLOW.md`) is *inlined* between `` / `` markers inside the project `AGENTS.md`; a standalone `WORKFLOW.md` file is **never** written to the repository. -Both installs are prefixed with a **pointer line** that defers to the global -`~/.pi/agent/AGENTS.md` file when one is present: - -``` -Follow the global AGENTS.md in addition to the rules below. The local rules below take priority in the event of a conflict. -``` +There is no separate pointer line anymore: the template **self-references** the +global file via its `## Global agent guidance` section. ## Install flow @@ -38,15 +36,15 @@ wl init │ ├─ ensureAgentTemplateInstalled() │ │ - │ ├─ AGENTS.md does NOT exist ──► write "{pointer}\n\n{template}" (always installs) + │ ├─ AGENTS.md does NOT exist ──► write template (always installs) │ │ │ └─ AGENTS.md EXISTS │ │ - │ ├─ pointer present ──────► no-op ("pointer already present") ← idempotent + │ ├─ global reference present ──► no-op ("global reference already present") ← idempotent │ │ - │ └─ pointer absent - │ ├─ action=overwrite ► replace with "{pointer}\n\n{template}" - │ ├─ action=append ───► prepend "{pointer}\n\n{existing}" + │ └─ global reference absent + │ ├─ action=overwrite ► replace with template + │ ├─ action=append ───► prepend template above existing content │ ├─ action=skip ─────► no-op ("user chose to manage manually") │ └─ no action ───────► interactive O/A/M prompt → same actions │ @@ -58,46 +56,51 @@ wl init if not already present ← idempotent ``` -### Pointer line +### Global-reference detection -The pointer line is the constant `WORKLOG_AGENT_POINTER_LINE` in -`src/commands/init.ts`: +The canonical template emits the `## Global agent guidance` heading and a +reference line pointing at `~/.pi/agent/AGENTS.md`. `analyzeAgentContent()` +detects either of those (or the legacy pointer line, for backward +compatibility with projects installed before SA-0MSITKWBP007VUJS) to decide +idempotence: ```ts -const WORKLOG_AGENT_POINTER_LINE = - 'Follow the global AGENTS.md in addition to the rules below. The local rules below take priority in the event of a conflict.'; +const CANONICAL_GLOBAL_REFERENCE_MARKER = '## Global agent guidance'; +const CANONICAL_GLOBAL_REFERENCE_LINE = + 'Read the global agent instructions at `~/.pi/agent/AGENTS.md`'; ``` -It is always written as the first non-empty line of the installed `AGENTS.md`. Its semantics: - **Defer to the global file** — when `~/.pi/agent/AGENTS.md` exists (the SorraAgents global install), agents should read it for core workflow instructions. -- **Local priority on conflict** — project-local rules take precedence over - global ones when they disagree. +- **Local rules stay below the reference** — project-specific rules are added + by the project owner in the `## Project-specific guidance` section (or kept + in the existing local content when appending); the global file is never + copied into the project. -The pointer is also the idempotence key: `analyzeAgentContent()` detects it as -`firstNonEmpty === WORKLOG_AGENT_POINTER_LINE`, and `ensureAgentTemplateInstalled()` -returns `skipped: true, reason: 'pointer already present'` without touching the -file — re-running `wl init` never duplicates the pointer or template. +When the reference is already present, `ensureAgentTemplateInstalled()` +returns `skipped: true, reason: 'global reference already present'` without +touching the file — re-running `wl init` never duplicates the reference or +template. ### O/A/M prompt -When `AGENTS.md` exists without the pointer and no `--agents-template` flag is -given, `promptAgentTemplateAction()` asks: +When `AGENTS.md` exists without the global reference and no `--agents-template` +flag is given, `promptAgentTemplateAction()` asks: | Choice | Meaning | Effect | |--------|---------|--------| | **O** – Overwrite | Replace the existing AGENTS.md entirely | Destructive; no chance of conflict with existing content | -| **A** – Add pointer | Prepend the pointer line, keep existing content | Non-destructive; retains existing instructions | +| **A** – Add reference | Prepend the global-reference template, keep existing content | Non-destructive; retains existing instructions below the reference | | **M** – Manual | Skip; user manages AGENTS.md themselves | No-op; Worklog agent guidance is not installed | ### CLI flags | Flag | Values | Behavior | |------|--------|----------| -| `--agents-template` | `overwrite` (or `o`), `append` (or `a`), `skip` (or `m`/`manual`/`manage`) | Non-interactive action when AGENTS.md exists without the pointer | +| `--agents-template` | `overwrite` (or `o`), `append` (or `a`), `skip` (or `m`/`manual`/`manage`) | Non-interactive action when AGENTS.md exists without the global reference | | `--workflow-inline` | `yes`/`true`/`1`, `no`/`false`/`0` | Inline workflow template into AGENTS.md (`yes`) or not (`no`); omitted → interactive prompt | `--agents-template skip` is the common choice for unattended init @@ -107,11 +110,11 @@ given, `promptAgentTemplateAction()` asks: | Scenario | Result | |----------|--------| -| No `AGENTS.md` | Installs `{pointer}\n\n{template}` | -| `AGENTS.md` with pointer | No-op — pointer already present | -| `AGENTS.md` without pointer, `--agents-template skip` | No-op | -| `AGENTS.md` without pointer, `--agents-template overwrite` | Replaces file | -| `AGENTS.md` without pointer, `--agents-template append` | Prepends pointer, keeps content | +| No `AGENTS.md` | Installs template (global reference + project placeholder) | +| `AGENTS.md` with global reference | No-op — reference already present | +| `AGENTS.md` without reference, `--agents-template skip` | No-op | +| `AGENTS.md` without reference, `--agents-template overwrite` | Replaces file with template | +| `AGENTS.md` without reference, `--agents-template append` | Prepends template, keeps content below | | `--workflow-inline yes` on re-run | No-op — markers already present | ### WORKFLOW.md: inlining only @@ -129,82 +132,95 @@ Two sources of agent guidance exist: | | Local (`templates/AGENTS.md`) | Global (`AGENTS_GLOBAL.md`) | |---|---|---| | **Install target** | `/AGENTS.md` via `wl init` | `~/.pi/agent/AGENTS.md` via SorraAgents `scripts/install_pi.sh` | -| **Install mechanism** | Copied (with pointer prefix) | Symlinked | +| **Install mechanism** | Copied (reference structure) | Symlinked | | **Scope** | One project | All projects on the machine | -| **Content** | 258 lines: CRITICAL RULES, work-item types/priorities, CLI cheat-sheet, architecture notes | Full agent workflow: work-item lifecycle, workflow steps, push policy, types/priorities | -| **Drift risk** | High — duplicated content must be edited twice | Low — single source | +| **Content** | Canonical reference: `## Global agent guidance` + `## Project-specific guidance` placeholder | Full agent workflow: work-item lifecycle, workflow steps, push policy, types/priorities | +| **Drift risk** | Low — no duplicated content to keep in sync | Low — single source | ### Precedence -The pointer line (installed by `wl init`) makes the relationship explicit: - -> *"Follow the global AGENTS.md **in addition to** the rules below. The local -> rules below take priority **in the event of a conflict**."* - -So precedence is: **local project rules > global file**, with both being read. +The `## Global agent guidance` section (installed by `wl init`) makes the +relationship explicit: the global file defines the core principles, the +Worklog (wl) work-item workflow, and the coding disciplines that apply to +every project. Project-specific rules live below the reference in +`## Project-specific guidance` and are added by the project owner — they are +**never** a copy of the global file. ### Duplication and conflict risk -The two files overlap substantially — both carry CRITICAL RULES, work-item -Types, priorities, and workflow guidance. That duplication is a real drift -risk: a change (e.g. adding an issue type) must be applied to both files. -Confirmed example: the recent `docs` issue-type addition had to be applied to -`templates/AGENTS.md` **and** `AGENTS_GLOBAL.md` (both carry -`--issue-type: … docs …` today). +The canonical template eliminates the previous duplication: the old template +carried CRITICAL RULES, work-item Types, priorities, and workflow guidance +that had to stay in sync with `AGENTS_GLOBAL.md` (the `docs` issue-type +addition had to be applied to both files). With the reference model, a change +to the global guidance is picked up by every project automatically. ## Interaction with the global install -- `wl init` does **not** detect whether `~/.pi/agent/AGENTS.md` exists, and - does **not** delegate to it. It unconditionally installs its own template - content (258 lines), prefixed with the pointer line. -- The pointer line is the *only* acknowledgement of the global file: it tells - agents to also read the global file, but the local template content is still - installed in full. -- There is **no delegation today** — no code path that says "if the global - file exists, skip the template and just write a reference." +- `wl init` installs its short canonical template unconditionally — the + template self-references `~/.pi/agent/AGENTS.md`, so when the global file +exists (SorraAgents install) agents read both; when it does not, the +reference is harmless and the project still has its own `AGENTS.md`. +- There is **no delegation switch today** — no code path that says "if the + global file exists, skip the template entirely." The template is short by +design so that no duplication occurs regardless of environment. ### Environments | Environment | What agents see | |-------------|-----------------| -| **Standalone** (no SorraAgents global install) | Only the project `AGENTS.md` (pointer line points at a file that does not exist — harmless, but no global workflow is loaded) | -| **Global** (SorraAgents `install_pi.sh` run) | Both the global `~/.pi/agent/AGENTS.md` (symlinked from `AGENTS_GLOBAL.md`) and the project `AGENTS.md`, with local rules taking precedence | +| **Standalone** (no SorraAgents global install) | Only the project `AGENTS.md` (reference points at a file that does not exist — harmless) | +| **Global** (SorraAgents `install_pi.sh` run) | Both the global `~/.pi/agent/AGENTS.md` (symlinked from `AGENTS_GLOBAL.md`) and the project `AGENTS.md` (reference + project-specific rules) | ## Drift history -- **`docs` issue-type change** — applied to both `templates/AGENTS.md` and - `AGENTS_GLOBAL.md` (drift risk confirmed in practice). +- **`docs` issue-type change** — previously required editing both + `templates/AGENTS.md` and `AGENTS_GLOBAL.md` (drift risk confirmed in + practice); the reference model removes this. - **SorraAgents dedup items** (SA-0MSITKHPW002XG4G, SA-0MSIUUYRD002GC8W, SA-0MSITKOXI007XD4N — completed): SorraAgents adopted a "reference global instead of duplicating" model. Its own project `AGENTS.md` now starts with a short pointer to the global file instead of - duplicating the full content. ContextHub's template has **not** followed. + duplicating the full content. ContextHub's template has now followed with + the same canonical structure (SA-0MSITKWBP007VUJS). ## Recommendation -**Adopt the single-source-of-truth model: delegate agent-guidance/workflow setup to the SorraAgents global install** (the reference-global pattern), with implementation tracked in WL-0MSIXMKOX0052514. The current duplicated model should be retired. +**Adopt the single-source-of-truth model: delegate agent-guidance/workflow setup to the SorraAgents global install** (the reference-global pattern), with implementation tracked in WL-0MSIXMKOX0052514. The current model already emits the canonical reference structure; further delegation (skipping the template when the global install is detected) is tracked there. ### Evidence -1. **Confirmed duplication.** `templates/AGENTS.md` (258 lines) and `AGENTS_GLOBAL.md` both carry CRITICAL RULES, work-item Types, priorities, and workflow guidance. The pointer line acknowledges the global file but the full template content is still installed — the pointer defers, the content duplicates. -2. **Confirmed drift in practice.** The `docs` issue-type change had to be applied to both files. Any future guidance change carries the same two-file maintenance burden (or worse, is applied to only one, silently diverging behavior between projects and machines). -3. **SorraAgents already adopted the model.** SA-0MSITKHPW002XG4G, SA-0MSIUUYRD002GC8W, SA-0MSITKOXI007XD4N (completed) moved SorraAgents to reference-global: its project `AGENTS.md` now starts with a short pointer to `~/.pi/agent/AGENTS.md` instead of duplicating content. ContextHub's template has not followed. -4. **ContextHub's own project AGENTS.md already uses the pattern.** The installed `/AGENTS.md` starts with a "Global agent guidance" section referencing `~/.pi/agent/AGENTS.md` — the template is out of step with the repo's own practice. -5. **Delegation has no downsides for the global environment.** When the SorraAgents install is present, the global file carries the canonical workflow; the project file only needs project-specific rules plus a reference line. This matches what `wl init`'s pointer line already half-intends. +1. **Duplication eliminated.** `templates/AGENTS.md` now emits only the + canonical reference structure (3 lines) instead of the ~258-line + instruction set that duplicated `AGENTS_GLOBAL.md`. +2. **SorraAgents already adopted the model.** SA-0MSITKHPW002XG4G, + SA-0MSIUUYRD002GC8W, SA-0MSITKOXI007XD4N (completed) moved SorraAgents to + reference-global: its project `AGENTS.md` now starts with a short pointer + to `~/.pi/agent/AGENTS.md` instead of duplicating content. +3. **ContextHub's own project AGENTS.md already uses the pattern.** The + installed `/AGENTS.md` starts with a "Global agent guidance" + section referencing `~/.pi/agent/AGENTS.md` — the template is now in step + with the repo's own practice. +4. **Reference model has no downsides for the global environment.** When the + SorraAgents install is present, the global file carries the canonical + workflow; the project file only needs project-specific rules plus a + reference line. ### What the change looks like (scope boundary — NOT implemented here) -- When the SorraAgents global install is detected (`~/.pi/agent/AGENTS.md` resolves to a SorraAgents symlink), `wl init` should emit the canonical reference structure (a `## Global agent guidance` section referencing `~/.pi/agent/AGENTS.md`, plus a `## Project-specific guidance` section), rather than installing the full 258-line template. -- Standalone environments (no global install) keep a self-contained `AGENTS.md` so Worklog remains usable without SorraAgents. -- **No behavioral change is made as part of this item (WL-0MSKEJK4G008BMS0)** — the investigation only documents and recommends. Implementation lands in WL-0MSIXMKOX0052514. +- When the SorraAgents global install is detected (`~/.pi/agent/AGENTS.md` + resolves to a SorraAgents symlink), `wl init` could skip emitting even the + reference template. This is tracked in WL-0MSIXMKOX0052514; not + implemented in this item. +- Standalone environments (no global install) keep the reference template so + Worklog remains usable without SorraAgents. ## Related - `src/commands/init.ts` — `ensureAgentTemplateInstalled`, `promptAgentTemplateAction`, `ensureWorkflowTemplateInstalled`, - `WORKLOG_AGENT_POINTER_LINE`, `WORKLOG_AGENT_TEMPLATE_RELATIVE_PATH` + `CANONICAL_GLOBAL_REFERENCE_MARKER`, `WORKLOG_AGENT_TEMPLATE_RELATIVE_PATH` - `templates/AGENTS.md`, `templates/WORKFLOW.md` — the installed templates -- `tests/cli/init.test.ts` — behavior tests (pointer insertion, idempotence, - `--agents-template`, `--workflow-inline`) +- `tests/cli/init.test.ts` — behavior tests (reference installation, + idempotence, `--agents-template`, `--workflow-inline`) - [docs/tutorials/01-your-first-work-item.md](tutorials/01-your-first-work-item.md) — the init walkthrough that mentions the AGENTS.md prompt diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index abbfeec57..0521f6020 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,6 +81,72 @@ Worklog uses a hybrid architecture with **SQLite as the runtime source of truth* └─────────────┘ ``` +## Incremental (Delta) Sync + +Sync reads/writes are **incremental**: after the first full snapshot, each +sync pushes only the records that changed since the last successful push +(a *delta*), instead of re-emitting the entire store. The full design lives in +[docs/design/incremental-sync.md](design/incremental-sync.md) (spec from +WL-0MT2KVIGP0004C8V). + +### Delta format + +Every synced JSONL file carries an optional first-line sync header: + +```json +{"__worklog_sync__": {"version": 1, "kind": "full"}} +{"__worklog_sync__": {"version": 1, "kind": "delta"}} +``` + +- **`full`** — a complete snapshot of the store. +- **`delta`** — only the records whose per-type timestamp (`updatedAt` for + work items, `createdAt` for comments, `createdAt` for edges, `auditedAt` + for audit results) is newer than the last export watermark. +- **no header** — a legacy full snapshot; treated as `full`. + +Deltas are a JSONL subset of the full format, so the existing by-ID merge +backbone applies unchanged to both kinds. + +### Dirty tracking & watermarks + +- A per-type `last_export_timestamps` table (SQLite) records the watermark + after each **successful push** (`markLastExportTimestamps`). +- A record is **dirty** when its timestamp is strictly newer than the + watermark for its type (`countDirtyRecords`). +- **No-baseline rule**: no watermark at all → everything is dirty → full + export. Watermarks are advanced only post-push, under the file lock. +- Soft-deleted records (`status: 'deleted'`) bump `updatedAt` on delete and + are captured by the same predicate, so deletions propagate in deltas. + +### Full vs. delta decision (push side) + +`performSync` chooses **full** when: no baseline exists (first sync), the +full-snapshot cadence is due (every `syncFullSnapshotEveryN` delta syncs, +default 10, or when accumulated delta bytes exceed +`syncDeltaSizeThreshold`, default 1 MB), `WL_DELTA_EXPORT_DISABLED=1`, or +the remote ref is absent. Otherwise it exports a **delta**. A zero-change +delta (nothing dirty) skips export+push entirely. + +### Pull side & fallback + +- A remote **`full`** (or legacy headerless) file replaces local state with +the by-ID merged result (existing behavior). +- A remote **`delta`** is merged **onto the local base** non-destructively +(upsert by ID) — local records absent from the delta are preserved. +- **Full-snapshot fallback**: a reader with NO local base (brand-new clone, +or a store recovered from corruption) cannot apply a delta alone (a delta +carries only *changes*). The pull side replays the remote ref history +(newest full snapshot + every delta after it) via `replayRemoteDeltaChain`, +and re-anchors the remote with a full push. An unrepairable chain fails +closed — no merge, no push, no data loss. + +### Backward compatibility + +- Old readers see a delta as a valid (partial) JSONL subset, not corruption. +- The first sync after enabling incremental sync always pushes a full +snapshot, and the cadence guarantees the remote ref always carries a recent +full snapshot — any reader can anchor. + ## Migration from Persistent JSONL ### Background @@ -327,10 +393,13 @@ simulates a 6-pane refresh and asserts ≥60% fewer work spawns. ## Future Considerations ### Potential Enhancements -1. **Incremental sync**: Only sync changed items -2. **Background sync**: Async sync without blocking UI -3. **Compression**: Compress JSONL for large datasets -4. **Delta encoding**: Send only diffs for efficiency +1. **Background sync**: Async sync without blocking UI +2. **Compression**: Compress JSONL for large datasets +3. **Delta encoding**: Send only diffs for efficiency + +> **Incremental sync** is implemented (see +> [Incremental (Delta) Sync](#incremental-delta-sync) above) and is no longer a +> future consideration. ### Migration Timeline - **Phase 1** (Complete): Remove autoExport infrastructure diff --git a/docs/AUDIT_STATUS.md b/docs/AUDIT_STATUS.md index ab5a93fe6..2ce158dbf 100644 --- a/docs/AUDIT_STATUS.md +++ b/docs/AUDIT_STATUS.md @@ -65,6 +65,32 @@ wl show SA-123 --json - `error: audit-invalid-first-line` - a `message` containing the found trimmed first line and indicators for BOM/non-printable/gutter characters. +## Auto-revert on "not ready to close" verdict + +When an item in `in_review` (status `completed`) receives a **not-ready-to-close** verdict, it is automatically reverted to status `open` / stage `plan_complete` so it drops out of the ready-to-close queue (heartbeat/release tooling) and returns to the planning queue for further work. The item's priority is preserved. + +Triggers (both produce the same behavior): + +- `wl update --audit-text "Ready to close: No\n..."` +- `wl audit-set --ready-to-close no` +- REST API `PUT /items/:id` (or `/projects/:prefix/items/:id`) with an `audit` body field whose first line is `Ready to close: No` + +The reversion fires **only** when all of these hold: + +1. The audit verdict is exactly "Ready to close: No" (no other audit text triggers it). +2. The item's current state is exactly `completed` / `in_review` (a `done` item, an already-`open`/`in-progress` item, or any other state is left untouched). +3. The reversion was not already applied (it is idempotent — a second not-ready write on an already-open item is a no-op). + +Reporting (mirrors the priority-downgrade cascade / `demotedParent` convention): + +- **JSON output** of `wl update` / `wl audit-set` includes a `reverted` field: `{ item, from: { status, stage }, to: { status, stage } }`, e.g. `{ "from": { "status": "completed", "stage": "in_review" }, "to": { "status": "open", "stage": "plan_complete" } }`. When no reversion occurs the field is absent. +- **Human output** prints a summary line: `[WL-XXX reverted from completed/in_review to open/plan_complete]`. +- **REST API** `PUT` responses include the same `reverted` field when the update caused a reversion. + +The reversion is **best-effort**: if the lifecycle write fails (e.g. database lock), the audit write still succeeds and a warning is surfaced — the command never crashes. + +The REST API path applies the same reversion for consistency with the CLI (decision recorded in WL-0MT0T1EQJ009DHO8). The `audit` field in a `PUT` body is routed to the `audit_results` table (the sole source of truth); a not-ready verdict then triggers the same reversion as the CLI commands. + ## Valid Examples ```text diff --git a/docs/design/incremental-sync.md b/docs/design/incremental-sync.md new file mode 100644 index 000000000..dfd982c9d --- /dev/null +++ b/docs/design/incremental-sync.md @@ -0,0 +1,325 @@ +# Incremental Sync Design — Delta Export/Push of Changed Work Items + +**Work item:** WL-0MSAKUBKW006FN8Q — Incremental sync: only export/push changed work items instead of full JSONL + +**Status:** Design (approved before implementation) + +--- + +## 1. Goal + +Make `wl sync` incremental: instead of re-exporting and re-pushing the full JSONL +(11.6 MB, 2133 items, 7494 comments) on every sync, only the records that changed +since the last sync are exported into the JSONL and pushed to the shared +`refs/worklog/data` ref. This eliminates the full-rewrite-per-sync cost that makes +syncs take 5–19 s and periodically trips client timeouts (WL-0MSAKM838006RZNR). + +The main wins target the **local** costs (per the constraint note in the intake): +- the full export write (11.6 MB to disk), +- the temp-worktree file copy, +- `git add` hashing/compressing of 11.6 MB, +- the pull-side `git show` + parse of 11.6 MB. + +Git already delta-compresses the *network* payload, so the design focuses on local +work proportional to the number of changed records, not the dataset size. + +## 2. Approach + +Approach **option 1** (dirty tracking + delta export) with a full-snapshot +fallback. This was chosen during planning over option 2 (batched full rewrites, +lower value) and option 3 (sharded JSONL, added ref/file complexity). + +The by-ID, field-level timestamp merge (`mergeWorkItems`, `mergeComments`, +`mergeDependencyEdges`, `mergeAuditResults` in `src/sync.ts`) remains the +**correctness backbone**. Deltas feed through the exact same merge logic. + +## 3. Delta format specification + +### 3.1 Record types (unchanged) + +A delta file reuses the existing flat record format from `src/jsonl.ts`: + +``` +{"type":"workitem","data":{...}} +{"type":"comment","data":{...}} +{"type":"audit_result","data":{...}} +``` + +Each record already carries `{ type, data }` and is `JSON.stringify`'d with +stable key sorting. Dependency edges are *derived* from `item.dependencies` +(flattened on export, normalized back on import), so they need no separate +record type. **A delta file is therefore a valid subset of the current format.** + +### 3.2 Version/header metadata + +To let readers distinguish a **delta** from a **full snapshot** (and to gate +backward compatibility), the first line of every JSONL written by the sync path +carries a header record: + +``` +{"__worklog_sync__":{"version":1,"kind":"delta"}} +...delta records... +``` + +or + +``` +{"__worklog_sync__":{"version":1,"kind":"full"}} +...full records... +``` + +**Design decision:** a header line, not a record-count heuristic. A heuristic +(fewer than N records = delta) is fragile because a legitimately tiny full +snapshot could be misclassified. The header is explicit and additive. + +### 3.3 Why a delta is self-describing and mergeable + +Because deltas are a subset of the full format: + +- **Merge semantics are preserved exactly.** `importFromJsonlContent` parses + delta records into `items`/`comments`/`dependencyEdges`/`auditResults`, which + are then fed to the existing by-ID merge functions. There is no divergent + resolution path. +- **Backward compatibility.** An old `wl` client (or Herdr / Pi TUI) reading a + delta sees *fewer* records than the full set. That is a *partial* view, not a + *corrupt* one. Mitigation below (§6.1) ensures every reader obtains a full + snapshot. + +## 4. Dirty tracking (per-type export timestamps) + +### 4.1 Mechanism: `updatedAt` threshold, persisted in SQLite, not write interception + +Per the intake risk mitigation ("track at the export boundary using `updatedAt` +thresholds rather than write interception"), dirty tracking is based on a +**per-type export watermark** recorded after each successful export/push: + +- a `last_export_timestamps` DB table (one row per record type, ISO-8601 + watermark), backed by the `SqlitePersistentStore`: + - `workitems` + - `comments` + - `edges` + - `audit_results` +- The table is created for new databases in `initializeSchema` and for existing +databases by the `20260821-add-last-export-timestamps` migration +(`src/migrations/index.ts`). +- On the next sync, a record is **dirty** if `updatedAt > watermark[type]`. + +**Why this is robust:** because it is computed at the export boundary against the +data's own `updatedAt` timestamps, it cannot miss changes written by any path +(ORM writes, direct DB writes, imports, migrations) that bump `updatedAt`. +The same threshold mechanism naturally covers comments (keyed by `createdAt` +as the effective update time), dependency edges (by `createdAt`) and audit +results (by `auditedAt`). + +**No-baseline rule:** when the table has no watermark (first sync), every +record is dirty → full export. The watermark is advanced only after a +successful export/push, under the existing file lock, so a concurrent sync +can never advance the baseline past data that was never published. + +**API surface** (`WorklogDatabase`): `getLastExportTimestamps()`, `markLastExportTimestamps(ts)`, and `exportForSync({ since })` which filters each record type by its watermark. + +### 4.2 Per-type coverage + +| Type | Dirty predicate | +|------|-----------------| +| `workitem` | `item.updatedAt > watermark.workitems` | +| `comment` | `comment.createdAt > watermark.comments` (comments are immutable after creation) | +| `edge` | `edge.createdAt > watermark.edges` | +| `audit_result` | `audit.auditedAt > watermark.audit_results` | + +> **Design note:** comments are treated as immutable-by-`id` (the merge +> dedupes by `id`), so `createdAt` is the correct dirty key — a comment that +> hasn't changed since the last export should not be re-emitted. If a future +> change allows editing comments, the predicate would switch to `updatedAt`. + +### 4.3 Deletion propagation (soft deletes) + +Deletions are **soft** (`status: 'deleted'`), so a deleted item is a record +whose `updatedAt` was bumped. It is therefore captured by the same dirty +predicate and emitted in the delta with `status:'deleted'`. No hard-delete +tombstone format is needed. On the remote side, the merge sees the +`deleted` item and converges. **(AC4)** + +## 5. Export/push orchestration (sync path) + +### 5.1 Decide full vs. delta + +During `performSync` (after the pull/merge and DB import), decide: + +``` +needsFull = + !lastExportTime // first sync / no baseline + OR lastSyncWasDelta && deltaReplayBroken // chain mismatch (§6.2) + OR fullSnapshotDue // cadence policy (§5.3) + OR nothing on remote base // no full snapshot exists remote +``` + +Otherwise → **delta export**. + +### 5.2 Delta export + +`exportForSync()` gains a `{ mode: 'full' | 'delta', since: lastExportTime }` +option. In delta mode: + +1. Query only dirty records: `getAllWorkItems({ since })`, + `getAllComments({ since })`, `getAllAuditResults({ since })`. +2. Build the JSONL **with the delta header** — a small, proportional payload. +3. Write it to `this.jsonlPath` (same path as today; ephemeral pattern + unchanged). +4. Return the path. + +Push proceeds exactly as today via `gitPushDataFileToBranch` — the only change +is that the file content is the delta. **The temp-worktree copy and `git add` +now touch a small file instead of 11.6 MB.** + +### 5.3 Full-snapshot cadence policy + +**Default:** after every **10 delta syncs**, OR when the accumulated delta +exceeds **1 MB** (advisory threshold), force a full snapshot. Both are +configurable (`sync.fullSnapshotEveryN`, `sync.deltaSizeThreshold`). + +Rationale (per plan): preempting that the remote retains a recent full +snapshot bounds any delta-replay cost, and a conservative threshold keeps the +rare slow syncs infrequent. A counter + accumulated-delta-size is persisted in +the `last_export_timestamps` table (as additional metadata rows) or a +companion metadata key. + +### 5.4 Zero-change fast path + +If **no** record is dirty AND a full snapshot is not due, skip export+push +entirely (write an empty delta is avoided). This preserves/narrows the existing +`last-sync-time` optimization. + +## 6. Pull/merge path & fallback + +### 6.1 Read path + +`getRemoteDataFileContentWithRef` streams the remote JSONL (unchanged). The +pull side inspects the first line: + +- header `kind:"full"` → parse normally, merge (current behavior). +- header `kind:"delta"` → parse the delta records and merge them **onto the + local base** using the existing by-ID merge. Deltas only ever *add/update* + records relative to what the local store already has, so merging a delta into + local state is safe. +- no header (legacy full file) → parse normally as a full snapshot. + +### 6.2 Full-snapshot fallback triggers + +The pull side requests a full snapshot (sets `needsFull = true` on the next +push, or performs a full pull then full push) when any of: + +1. **Missing base** — the local store has no baseline for the remote's delta + chain (e.g., a brand-new clone that fetched only deltas). +2. **Chain mismatch / corruption** — the remote stream or delta header is + unreadable, or `git show` fails, or the merge throws. +3. **First sync** in a fresh checkout — no local data to resolve against. +4. **Author-identity gate failure** or any other safety gate that indicates the + local state cannot trust the remote delta chain. + +On fallback the sync reverts to the **full export/merge path with no data +loss** — identical to today's behavior. **(AC5)** + +> Because the fallback does not delete anything locally and always rebuilds a +> full snapshot from the (complete) SQLite store, there is no data loss in any +> fallback path. + +### 6.3 Delta replay budget bound + +The full-snapshot cadence (§5.3) guarantees the remote always has a recent full +snapshot, so the maximum delta replay a fresh reader must apply is bounded by +the records changed since the last full snapshot — not unbounded history. + +## 7. Backward compatibility strategy + +1. **Delta = valid JSONL subset.** Old clients reading a delta see partial data + (not corruption). To avoid serving stale partial data, see #2. +2. **Automatic full-snapshot fallback.** The first sync after the incremental + path is enabled forces a full snapshot (§5.1 `nothing on remote base`), so + the remote ref always carries a full snapshot before/at the first delta. + Any reader that cannot interpret a delta triggers a full pull. +3. **Header is additive.** The `__worklog_sync__` header is a single extra line + the old parser *skips* (it would be treated as a record with neither + `type` nor `data` and, per `importFromJsonlContent`, falls into the + "old format, assume workitem" warn path). **Mitigation:** to avoid a stray + warn log on old clients, the header record includes no `data` wrapper and + the old parser treats `type === undefined && !parsed.data` as legacy; it + would `console.warn` and attempt to parse a workitem. We therefore keep the + header **outside** the old parser's recognized shapes by making the old + warn path harmless: since `stripWorklogMarkers(parsed.description)` with no + `description` is a no-op and the header has no required fields, the only + observable effect on an old client is a single warning line and a spurious + empty item. **To fully avoid this**, the header is emitted as a comment + (`#`) line rather than a JSON record when running on a format that must be + backward-readable by the legacy parser: + + ``` + #worklog-sync version=1 kind=delta + ``` + + The new parser recognizes `#worklog-sync ...` as the header; the old parser + already ignores `#`-prefixed lines in its `importFromJsonlContent`? **It does + NOT today** — plain `#` lines are treated as a JSON-parse error. + + **Final decision:** the header is an inline JSON object + `{"__worklog_sync__":{"version":1,"kind":"full"|"delta"}}` and we **patch the + legacy parser** (`importFromJsonlContent`) to detect and skip the + `__worklog_sync__` header line explicitly (defensive, forward-compatible + with the old format). Old *deployed* binaries still warn on it but never + crash (it's a parseable JSON object with `type` undefined). The stored-data + contract remains: **every reader that can't handle deltas falls back to a + full pull**, guaranteed by the full-snapshot-before-first-delta rule. + +## 8. File changes + +| File | Change | +|------|--------| +| `packages/shared/src/persistent-store.ts` | `last_export_timestamps` table in `initializeSchema`; `LastExportTimestamps` type; `getLastExportTimestamps()` / `setLastExportTimestamps()` store accessors | +| `packages/shared/src/database.ts` | `exportForSync()` accepts `{mode, since}`; dirty query filtering per record type; `getLastExportTimestamps()` / `markLastExportTimestamps()`; watermark advance after full export | +| `src/migrations/index.ts` | `20260821-add-last-export-timestamps` migration for existing DBs | +| `src/jsonl.ts` | header emission/parsing; a delta writer (`buildJsonlContent` with `since`) | +| `src/sync.ts` | pull side detects header kind; `exportForSync` call sites; full-snapshot fallback decision | +| `src/commands/sync.ts` | orchestration: decide full vs delta, cadence policy, zero-change skip | +| `tests/` | dirty-tracking, delta export/merge, deletion propagation, convergence (interleaved full+delta), fallback tests | +| `docs/ARCHITECTURE.md` | update "Future Considerations" §1 to reflect implemented design | + +## 9. Concurrency & existing protections + +The incremental path runs entirely inside the existing sync flow, so it +inherits unchanged: +- the file lock (`withFileLock`, `getLockPathForJsonl`), +- the single-flight guard and `--if-idle` skip semantics (WL-0MSAB7ZUC004SK7E), +- the ephemeral JSONL pattern (SQLite → JSONL → push → delete). + +The `last_export_timestamps` table is written only after a **successful** push, +under the same lock, so concurrent syncs cannot advance the baseline past data +that was never published. **(AC: no regression to concurrency protections)** + +## 10. Performance expectations + +- Small change sets (≤ 5 items/comments) export and push a delta proportional + in size to those records (kilobytes, not 11.6 MB). +- Typical incremental sync target: **≤ 3 s** on the current dataset (2133 items + / 7494 comments). Baseline today: full sync ~5.8 s local, 5–19 s with network. +- The `git add`/temp-copy/`git show`+parse costs scale with delta size, not + dataset size. + +## 11. Acceptance-criteria mapping + +| AC | Design coverage | +|----|-----------------| +| AC1 no full rewrite on small changes | §5.2 delta export, proportional payload | +| AC2 ≤3 s typical incremental sync | §10 performance expectations | +| AC3 correctness/convergence | §3 merge backbone, §8 convergence tests | +| AC4 deletion propagation | §4.3 soft-delete dirty capture | +| AC5 full-snapshot fallback | §6.2 fallback triggers, §7 no data loss | +| AC6 backward compatibility | §7 header scheme + full-pull fallback | +| AC7 tests + suite passes | §8 tests, follow-up tasks 8 | +| AC8 documentation | §8 ARCHITECTURE update, follow-up task 9 | + +## 12. Open items decomposed into child tasks + +The design resolves the format/cadence questions but the implementation is +executed by the planned child tasks (dirty tracking, delta export, push path, +pull/merge path, fallback, deletion propagation, integration tests, +documentation). diff --git a/docs/design/mode-switch-worker.md b/docs/design/mode-switch-worker.md new file mode 100644 index 000000000..d942da924 --- /dev/null +++ b/docs/design/mode-switch-worker.md @@ -0,0 +1,80 @@ +# Mode-Switch Worker Design — Activity-Gated Proxy Mode Switching + +**Work item:** WL-0MSN3FWV5008KQE9 — Herdr plugin: activity-gated proxy mode switching + +**Status:** Implemented (children merged to `dev`; docs) + +--- + +## 1. Goal + +Automatically switch the llama-proxy between **fast** (cloud-backed, 3-slot +pool) and **cheap** (2-slot local-first pool) operating modes based on operator +presence and proxy idle state, without operator intervention: + +- **Fast on command** — dispatching an agent-route command (`/skill:*`, + `/intake`, `/plan`, `/prompt:`) means the operator is actively working: + switch to fast mode immediately so cloud models are available. +- **Cheap on idle** — when the operator has been idle for a full inactivity + window *and* the proxy itself is idle, switch to cheap mode to save cost. + +## 2. Behavior + +### Fast switch (fire-and-forget, fail-open) + +- Trigger: any agent-route command routed through the plugin + (`packages/herdr/src/index.ts`, inside the `route === 'agent'` branch). +- Shell shortcuts (`!!`/`!`), stdout/plain commands, and the internal + `/downtime toggle` chord do **not** count as operator activity. +- The worker records the operator timestamp and POSTs + `/admin/set-mode {"mode":"fast"}` — unless the last-known mode is already + fast (dedup; refreshed via `GET /admin/mode` per poll). +- **Fail-open:** the POST is fire-and-forget and can never block or delay + command dispatch — a slow or unresponsive proxy never delays the operator. + +### Cheap switch (idle window + proxy idle, fail-closed) + +- Trigger: on each poll tick, operator idle ≥ `modeSwitchIdleThresholdMs` + **AND** proxy idle (reusing `evaluateIdle` from `downtime-worker.ts` with + `requiredFreeSlots = 0`: llama-server running, no active local query, no + model switch, no local lease). +- A busy proxy **delays** the switch — it never kills in-flight work. +- **Fail-closed:** endpoint failures, timeouts, network errors, and ambiguous + 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. + +### Restart semantics + +On plugin/pane restart the idle clock resets to "active now" (construction +time), so the proxy stays fast until a fresh full idle window passes +(fail-safe). + +## 3. Settings (re-read every tick — apply without plugin restart) + +| Setting | Default | Clamp | +|---|---|---| +| `modeSwitchEnabled` | `true` | — (disabled ⇒ no scheduler task, no-op hook) | +| `modeSwitchIdleThresholdMs` | `900000` (15 min) | floor `60000` | +| `modeSwitchPollIntervalMs` | `10000` | `[5000, 60000]` | + +The proxy URL is **not** a new setting — it reuses `downtimeProxyUrl`. + +## 4. Coexistence with the proxy's built-in time schedule + +The plugin's switches are **manual overrides** (the admin API's native +semantics, LP-0MSMF25V9002AY1J). The proxy's time-based schedule (cheap +01:00–10:00, fast 10:00–01:00) **reclaims control at its next boundary**. +This is documented behavior, not a conflict: the plugin switches the mode +immediately when needed; the next schedule boundary reasserts the +time-based plan. + +## 5. Key files + +- `packages/herdr/src/mode-switch-worker.ts` — worker core (idle clock, + admin API client, tick logic, clamps) +- `packages/herdr/src/mode-switch-worker.test.ts` — worker core tests +- `packages/herdr/src/mode-switch-integration.test.ts` — wiring tests + (settings → worker, route classification, scheduler interval constants) +- `packages/herdr/src/settings.ts` — settings schema + validation/clamps +- `packages/herdr/src/index.ts` — agent-route hook wiring +- `packages/herdr/src/worklist.ts` — scheduler task registration \ No newline at end of file diff --git a/docs/dev/chrome-headless-concurrency-memory-profile-investigation.md b/docs/dev/chrome-headless-concurrency-memory-profile-investigation.md new file mode 100644 index 000000000..8d3af6fc9 --- /dev/null +++ b/docs/dev/chrome-headless-concurrency-memory-profile-investigation.md @@ -0,0 +1,162 @@ +# Investigation: chrome-headless Concurrency Profile & Memory Footprint + +**Work items:** +- WL-0MT38GKEP0057T4I — Document concurrency profile and memory footprint of chrome-headless instances +- WL-0MT1KJNHY00286QE — Investigate chrome-headless workload causing GPU crashes and memory pressure (parent) + +**Status:** Investigation complete. One browser instance at a time per vitest +run (~7–8 processes), typical tree RSS 0.3–1.7 GiB, renderer dominant; +incident window (Aug 20 13:20–13:26) sustained 20+ concurrent instances +chained sequentially through vitest's fileParallelism:false project. + +**Related:** [chrome-headless-orchestrator-investigation.md](./chrome-headless-orchestrator-investigation.md) +(orchestrator = vitest browser mode via Playwright inside herdr-downtime pi +agents); [NVIDIA driver API mismatch RCA / fix (WL-0MT1KJNDK004SNPK)] (root +cause + mitigation, owned by sibling). + +--- + +## 1. Executive summary + +Each chrome-headless-shell instance is a **single browser process tree of 7–8 +processes** (root browser process + 2 zygotes + gpu-process + network utility + +audio utility + ≥1 renderer). During a running vitest browser suite, **exactly +one instance is alive at a time** (the `browser` vitest project is configured +with `fileParallelism: false, sequence: { concurrent: false }`), but instances +are churned sequentially as test files complete — over a ~45-minute incident +window on Aug 20 the kernel saw **90 distinct chrome-headless client PIDs** and +up to **24 concurrent in the worst minute**. + +Memory footprint per instance (live-observed, 2026-08-21 22:43–22:53): + +- Root browser process: **~100–110 MiB RSS**, VSZ ~48 GiB (virtual, normal for Chromium) +- gpu-process: ~100–156 MiB (SwiftShader software GL flags present) +- Renderer: **~40 MiB – 1.0 GiB RSS** (dominant; heavy page/tests drive it up) +- Network utility: ~60–104 MiB; audio utility: ~61 MiB; each zygote: ~40 MiB +- Whole tree RSS observed: **461 → 1,721 MiB** across samples in one run + (fresh instance ~315 MiB, loaded renderer instance ~1.5–1.7 GiB) + +## 2. Per-instance process anatomy (live capture 22:43) + +``` +chrome-headless-shell (root, PID 2884256, RSS 109,720 kB, VSZ 50.7 GB, 20 threads) +├─ zygote (2884258, 40,392 kB) +│ └─ gpu-process (2884275, 156,396 kB) +├─ zygote (2884259, 40,808 kB) +│ └─ renderer (2884289, 1,015,580 kB ← ~1 GiB!) +├─ utility network.mojom.NetworkService (2884277, 104,072 kB) +└─ utility audio.mojom.AudioService (2884521, 61,176 kB) +``` + +Tree total: **1,492.3 MiB across 7 processes** (this was a renderer-heavy +window; samples 40 s apart showed 461 MiB and 706 MiB for other instances). + +## 3. Concurrency profile + +### 3.1 Steady state (vitest `browser` project, sequential) + +Project config (Tableau-Card-Engine `vite.config.ts`, `browser` project): + +```ts +fileParallelism: false, +sequence: { concurrent: false }, +testTimeout: 30_000, +browser: { enabled: true, provider: 'playwright', headless: true, + instances: [{ browser: 'chromium' }], isolate: true } +``` + +Live sampling while the suite ran (2026-08-21 22:51–22:53): + +| Time | root instances | procs | tree RSS | +|------|---------------|-------|----------| +| 22:51:54 | 1 | 7 | 1,721 MiB | +| 22:52:34 | 1 | 6 | 461 MiB | +| 22:53:14 | 1 | 7 | 706 MiB | + +So the concurrency ceiling per vitest run is **1 instance at a time**, but the +sibling herdr pipeline runs **multiple pi agents in parallel** (9 agent +sessions observed running concurrently on this machine at 22:49), each of which +may run its own vitest browser suite → multiple instances can coexist when +several agents run tests simultaneously. During the Aug 20 incident this is +exactly what happened (see 3.2). + +### 3.2 Incident window (Aug 20 12:50–13:35) — concurrency evidence + +From kernel logs (`NVRM: API mismatch: the client 'chrome-headless' (pid …)`): + +- **703 NVRM API-mismatch events** logged in the 45-minute window; **655 of + them in 13:20–13:30** (the peak burst); **1,007 events** across the whole + Aug 20 day; 7,645 events total in the current `/var/log/kern.log`. +- **90 distinct chrome-headless client PIDs** observed over the window → + ~11–12 instance lifetimes × 7–8 procs each. +- **Distinct chrome PIDs per minute** (worst minutes): + `13:24: 24`, `13:23: 23`, `13:25: 21`, `13:22: 8`, `13:26: 7` — i.e., up to + **24 distinct chrome processes alive in the same minute** ≈ **3 concurrent + instances** (24 ÷ ~7–8 procs). +- Event bursts (per-second): `54 events @ 13:23:26`, `43 @ 13:23:33`, + `37 @ 13:22:09` — a GPU-call retry storm: each failed GL call logs one + mismatch event, so repeated failed GPU calls from multiple processes + generate hundreds of kernel lines per minute. + +### 3.3 Memory-pressure timeline (Aug 20 13:27–13:44) + +``` +13:27:30 systemd-journald: Under memory pressure, flushing caches +13:29:59 systemd-journald: Under memory pressure, flushing caches +13:30:36 systemd-journald: Under memory pressure, flushing caches +13:31:20 systemd-journald: Under memory pressure, flushing caches +13:33:47 systemd-resolved: Under memory pressure, flushing caches +13:37:50-52 journald + resolved: Under memory pressure, flushing caches +13:37:51 snapd.service: Watchdog timeout (limit 5min)! → failed watchdog +13:38:27 snapd watchdog goroutine dump +13:39:54 journald: Under memory pressure, flushing caches +13:40:44 journald: Under memory pressure, flushing caches +13:43:54 systemd-oomd started (userspace OOM-killer socket) +13:43:47 reboot completed (boot 0 begins at 13:43:47 IST) +``` + +The system became unresponsive and was rebooted at ~13:40–13:44. No OOM-kill +of a single process is recorded — the whole system froze under accumulated +memory pressure + watchdog timeout (the failure mode matches the sibling's RCA: +chrome-headless enters GPU-call retry loops, memory accumulates, system hangs). + +## 4. Per-process memory details + +| Process type | Observed RSS | Notes | +|---|---|---| +| Root browser process | 100–110 MiB | ~20 threads; VSZ ~48–51 GB (virtual reservations) | +| zygote ×2 | ~40 MiB each | | +| gpu-process | 100–156 MiB | runs with `--use-angle=swiftshader-webgl --enable-unsafe-swiftshader` | +| renderer | 40 MiB – 1,015 MiB | dominant, highly variable with workload | +| network utility | 60–104 MiB | | +| audio utility | ~61 MiB | | + +Server memory: **30 GiB total / 8 GiB swap**. A single renderer-heavy instance +(~1.7 GiB) is not by itself fatal, but N instance churn + retry loops + +parallel agent sessions is what overwhelmed the box on Aug 20. + +## 5. Commands used + +```bash +ps -C chrome-headless-shell -o pid,ppid,etimes,rss,vsz,comm,args # per-proc memory +grep -E '^(Name|VmRSS|VmSize|Threads)' /proc//status # instance detail +ps -C chrome-headless-shell -o rss= | awk '{s+=$1} END {print s}' # tree total +journalctl --since "2026-08-20 12:50" --until "2026-08-20 13:35" | grep 'NVRM: API mismatch' +journalctl ... | grep -oP 'pid \d+' | sort | uniq -c # per-PID concurrency +journalctl ... | grep -iE 'memory pressure|oom|watchdog|killed' # pressure timeline +free -h # host totals +``` + +## 6. Conclusion + +- **Concurrency profile:** 1 chrome-headless instance per vitest browser run + (sequential project), ~7–8 processes each; parallel herdr agent sessions + multiply instances (3+ concurrent observed at the Aug 20 peak; 90 distinct + instances churned across the 45-minute window). +- **Memory footprint:** root ~100–110 MiB, per-instance tree 0.3–1.7 GiB, + renderer dominant (up to ~1 GiB). Under normal test load this is + manageable; under the incident it compounded with GPU retry loops and + parallel agents into system-wide memory pressure and a reboot. +- This feeds the sibling mitigation (WL-0MT1KJNDK004SNPK): bounding + concurrency, reducing renderer memory, and/or eliminating the GPU-call + retry loop are the levers. \ No newline at end of file diff --git a/docs/dev/chrome-headless-orchestrator-investigation.md b/docs/dev/chrome-headless-orchestrator-investigation.md new file mode 100644 index 000000000..d363d68cd --- /dev/null +++ b/docs/dev/chrome-headless-orchestrator-investigation.md @@ -0,0 +1,157 @@ +# Investigation: What Orchestrates `chrome-headless-shell`? + +**Work items:** +- WL-0MT386OYV004DBD5 — Identify orchestrating process launching chrome-headless-shell +- WL-0MT1KJNHY00286QE — Investigate chrome-headless workload causing GPU crashes and memory pressure (parent) + +**Status:** Investigation complete. Single orchestrator identified — the +Vitest **browser-mode** test runner (Playwright provider) invoked by the +herdr-dispatched pi agent pipeline. + +**Related:** [NVIDIA driver API mismatch RCA / fix (WL-0MT1KJNDK004SNPK)] — root +cause and mitigation owned by the sibling item; concurrency + memory profile in +[chrome-headless-concurrency-memory-profile-investigation.md](./chrome-headless-concurrency-memory-profile-investigation.md). + +--- + +## 1. Executive summary + +`chrome-headless-shell` on this machine is **not** launched by systemd units, +cron jobs, scripts, or a standalone service. The only launch mechanism is the +**Vitest browser-mode test runner** (`vitest run --project browser`) executed +by the **test skill / implement workflow** inside **herdr-dispatched pi agent +sessions**. Vitest's browser provider is **Playwright**, which spawns the +Playwright-managed `chrome-headless-shell` binary from +`~/.cache/ms-playwright/chromium_headless_shell-1208/`. + +The observed live launch chain (2026-08-21 22:43–22:53): + +``` +herdr server (PID 8984) +└─ run-pi-agent.sh herdr-XXX --model plan (PID 68766) + └─ pi (PID 68796) + └─ bash -c "cd && timeout 1500 npx vitest run --project browser 2>&1 | grep -E 'Test Files|Tests |FAIL' | head -10" + (PID 2884024) + └─ timeout 1500 npx vitest run --project browser + (PID 2884026) + └─ npm exec vitest run --project browser (PID 2884030) + └─ sh -c vitest run --project browser (PID 2884090) + └─ node (vitest) (PID 2884092) + └─ chrome-headless-shell (PID 2884256, +6 child procs) +``` + +The vitest invocation was running inside a **Tableau-Card-Engine worktree** +(`.worklog/worktrees/wl-CG-0MT3C744B009DS84-...`) — the suite being tested is +Tableau-Card-Engine's `--project browser` branch of `vite.config.ts` +(`browser: { enabled: true, provider: 'playwright', headless: true, +instances: [{ browser: 'chromium' }] }`). + +## 2. Launch mechanisms assessed + +| Mechanism | Present? | Evidence | +|---|---|---| +| **Vitest browser mode (Playwright provider)** | ✅ **YES — the orchestrator** | Live pid-tree capture (section 3); `vite.config.ts` `browser` project; playwright dep in Tableau-Card-Engine `package.json` line 41 | +| systemd user services/timers | ❌ No | `systemctl list-units --user --all` — only desktop/GPG/pipewire/snap units; no chrome/playwright/browser entries; 4 user timers all unrelated (firmware-notifier, launchpadlib-cache-clean, ubuntu-insights) | +| systemd system services | ❌ No | `systemctl list-units --all` — no chrome/playwright/browser match | +| cron / at | ❌ No | `crontab -l` only has `llm-wiki-autoupdate` (runs `pi -p /wiki-run` — a Pi prompt, not a test/browser launcher); `atq` empty; `/etc/crontab` stock | +| Standalone scripts / git hooks | ❌ No | No vitest/playwright references in `.git/hooks`; no tmux/systemd-run test launchers | +| System-wide chromium | ❌ No | No `chromium`, `chromium-browser`, `google-chrome` on PATH; no snap chromium | + +**If multiple launch mechanisms exist, document all of them:** only one was +found (Vitest browser mode via Playwright). The `llm-wiki-autoupdate` cron +job (`0 8 * * * /bin/bash -lc '... pi -p "/wiki-run" ...'`) invokes the pi +CLI with a prompt called `/wiki-run`; the pi agent does not recognise that +prompt and it does not launch browsers. Verified: it is not a test runner. + +## 3. Live evidence (captured 2026-08-21 ~22:43) + +### 3.1 Process tree of a chrome-headless-shell instance + +``` +chrome-headless-shell (root, PID 2884256) +├─ chrome-headless-shell --type=zygote (2884258) +│ └─ chrome-headless-shell --type=gpu-process (2884275, 20 threads) +├─ chrome-headless-shell --type=zygote (2884259) +│ └─ chrome-headless-shell --type=renderer (2884289) +├─ chrome-headless-shell --type=utility network.mojom.NetworkService (2884277) +└─ chrome-headless-shell --type=utility audio.mojom.AudioService (2884521) +``` + +Each instance: **7–8 processes** (root browser process, 2 zygotes, gpu-process, +network utility, audio utility, ≥1 renderer). + +### 3.2 Parent chain (the orchestrator) + +| PID | Parent | Command | +|-----|--------|---------| +| 2884256 | 2884092 | `chrome-headless-shell` (binary from `~/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell-linux64/`) | +| 2884092 | 2884090 | `node (vitest)` | +| 2884090 | 2884030 | `sh -c vitest run --project browser` | +| 2884030 | 2884026 | `npm exec vitest run --project browser` | +| 2884026 | 2884024 | `timeout 1500 npx vitest run --project browser` | +| 2884024 | 68796 | `bash -c cd /home/rgardler/projects/Tableau-Card-Engine/.worklog/worktrees/wl-CG-0MT3C744B009DS84-... && timeout 1500 npx vitest run --project browser 2>&1 | grep ...` | +| 68796 | 68766 | `pi` | +| 68766 | 8984 | `bash .../packages/herdr/shared/run-pi-agent.sh herdr-1787312214-68751-18001 --model plan` | +| 8984 | — | `herdr server` | + +### 3.3 The suite configuration + +Tableau-Card-Engine `vite.config.ts`, `test.projects[]`, project `browser`: + +```ts +{ + name: 'browser', + include: ['tests/**/*.browser.test.ts'], + fileParallelism: false, + sequence: { concurrent: false }, + testTimeout: 30_000, + browser: { + enabled: true, + provider: 'playwright', + headless: true, + instances: [{ browser: 'chromium' }], + viewport: { width: 900, height: 700 }, + isolate: true, + }, +} +``` + +`package.json` declares `"playwright": "^1.58.2"`, and the Playwright-managed +binary is `~/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell-linux64/chrome-headless-shell`. + +### 3.4 Who triggers it: herdr-downtime dispatch + +The `pi` parent is a **herdr-downtime worker**: +`run-pi-agent.sh herdr-* --model plan` — herdr server auto-dispatches agents +to process work items; the **implement workflow** runs the full test suite via +the **test skill** (`/skill:test` → `run_tests.py` → suite commands), and for +Tableau-Card-Engine the suite (`npm --silent test` → `scripts/run-ci-tests.sh`) +includes `npx tsx scripts/vitest-run-with-retry.ts --project browser +--timeout-ms 900000`. During implementation, agents also invoke +`npx vitest run --project browser` directly (the live chain above shows the +direct `timeout 1500 npx vitest run --project browser` form). + +## 4. Commands used for identification + +```bash +pgrep -af chrome-headless-shell # find instances +ps -o ppid= -p # walk parents +ps -o pid,ppid,args -p # full chain +pstree -ap # instance tree +systemctl list-units --user --all --type=service,timer # no browser units +crontab -l; atq; ls /etc/cron.d; cat /etc/crontab # no test/browser jobs +grep -rln 'vitest\|playwright' .git/hooks # no git hooks +which chromium chromium-browser google-chrome # no system chromium +``` + +## 5. Conclusion + +- **Orchestrating process:** `node (vitest)` running `vitest run --project + browser`, parented by `npm exec` → `timeout` → `bash -c` → `pi` → + `run-pi-agent.sh` → `herdr server`. +- **Launching mechanism:** programmatic invocation — Vitest browser-mode with + the **Playwright** provider (Node.js code), not systemd/cron/script. +- **Single mechanism confirmed:** all other candidate mechanisms audited and + found absent (systemd user/system, cron/at, git hooks, system chromium). +- **Suite owner:** Tableau-Card-Engine (the machine hosts its worktrees; the + machine is also the ContextHub dev machine — herdr orchestrates both). \ No newline at end of file diff --git a/docs/dev/context-budget.thresholds.json b/docs/dev/context-budget.thresholds.json new file mode 100644 index 000000000..4d8b3766b --- /dev/null +++ b/docs/dev/context-budget.thresholds.json @@ -0,0 +1,6 @@ +{ + "global_agents": 0, + "project_agents": 1655, + "skills_prose": 239, + "total": 1894 +} diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md new file mode 100644 index 000000000..4f4e394b5 --- /dev/null +++ b/docs/dev/downtime-dispatcher.md @@ -0,0 +1,211 @@ +# Downtime Dispatcher: Leader Election + Shared Coordination File + +The herdr plugin's **downtime worker** (`packages/herdr/src/downtime-worker.ts`) +uses local-LLM idle compute to advance the worklog backlog automatically +(dispatch `/skill:audit`, `/skill:plan`, `/skill:implement`, scheduled +prompts). The 2026-08 refactor (work item **WL-0MST3OJ8S0001ROL**) changed the +dispatcher from "every herdr instance polls the llama-proxy and dispatches" to +a single-leader model: **one elected leader** handles all proxy polling and +dispatch while the other herdr instances coordinate instead of polling. + +This page is the deep-dive reference for operators and agents. The +package-level overview lives in +[`packages/herdr/README.md`](../../packages/herdr/README.md) *Downtime worker +(local-LLM idle dispatch)*. + +## Architecture + +``` + ┌─────────────────────────── single machine ───────────────────────────┐ + │ │ + herdr A ──▶ .worklog/downtime-leader.lock (file lock, first-to-create wins) │ + .worklog/downtime-leader-lease.json (5-min TTL, refreshed per poll) │ + .worklog/downtime-coordination.json (one entry per instance) │ + │ │ + herdr B ──▶ checks in every 30 min: offers its own worklog's most-important item │ + │ │ + leader ⊇ A polls llama-proxy → idle gate → dispatch tiers → removes entry │ + └──────────────────────────────────────────────────────────────────────┘ +``` + +### Leader election (file lock + lease) + +- The first instance to atomically create + `/.worklog/downtime-leader.lock` + (`O_CREAT|O_EXCL` — the "flock-equivalent" single-machine v1) becomes the + leader (`packages/herdr/src/leader-election.ts`). +- The leader holds a **5-minute lease** + (`DEFAULT_LEASE_TTL_SECONDS = 300`) written to + `.worklog/downtime-leader-lease.json`, refreshed on every proxy-poll cycle. +- If the lease expires (leader crashed or idle), a non-leader detects the + staleness, clears the stale lock/lease, and runs a new election (with + exponential backoff). +- **Fail-safe:** a missing/unreadable lock or lease file is treated as "no + leader" — the instance never dispatches without a valid leased lock. + +### Shared coordination file + +`/.worklog/downtime-coordination.json` stores one entry per +herdr instance: + +```json +{"version":1,"entries":[ + {"instanceId":"","workItemId":"","directory":"", + "assignedAt":"","lastUpdated":""} +]} +``` + +Lifecycle (`packages/herdr/src/coordination.ts`): + +1. **Check-in** — every instance (leader and non-leader alike) reads the file + on startup and every **30 minutes** + (`DEFAULT_COORDINATION_CHECK_IN_MS`), recomputes its own worklog's + **most-important item**, and upserts its entry (add if absent, update if + the item changed). +2. **Dispatch** — the leader removes an entry when it dispatches that item; + the owning instance re-offers its next most-important item at its next + check-in. +3. **Pruning** — stale entries (`lastUpdated` older than the lease TTL) are + pruned during dispatch cycles; operations are recorded in + `.worklog/downtime-coordination.log`. + +### Leader-only dispatch + +1. Only the leader polls the llama-proxy status and observes the **idle + gate** (continuous idle for the configured threshold — see timings + below). +2. When a **scheduled prompt** is due, it dispatches immediately + (WL-0MSS1Q5ER007QDKX). +3. Otherwise the leader reads the coordination list, classifies each offered + item by tier priority — **audit → critical-first** (stage-appropriate + intake/plan/implement, see *Critical-first tier & freeze split-by-skill* + below) **→ non-critical implement → plan → intake** — and dispatches the + highest-priority available item when a slot opens. +4. The dispatched entry is **removed** from the coordination file. The + existing dispatched-marker exclusion and CAS claim mechanisms are + preserved unchanged. +5. **Non-leaders** skip proxy polling and dispatch entirely — they only + refresh their lease check (a cheap local file read) and their + coordination entry. + +### Critical-first tier & freeze split-by-skill + +**Critical-first dispatch (WL-0MT3FM8VA005XBHE):** before the non-critical +implement/plan/intake tiers, the leader looks up the highest-priority open +**critical** item at ANY stage via `wl list --priority critical --status open +--json` (which — unlike `wl next` — does NOT exclude dependency-blocked +items) and dispatches it with the **stage-appropriate skill**: + +- `idea` → `/skill:intake ` +- `intake_complete` → `/skill:plan ` +- `plan_complete` → `/skill:implement ` (only when risk ≤ Medium AND + effort ≤ Medium — the F2 caps are retained for the implement kind) + +Selection is **deterministic**: a shared round-robin cursor over the +`critical` priority group (`.worklog/downtime-round-robin.json`, see +WL-0MSSRED76008LGB6). The tier needs ≥ 1 free slot (single-pane tier) and +flows through the same `dispatchClaimedTier` pipeline as every other tier — +CAS claim with the stage-appropriate `TIER_EXPECTED` entry +(stale-claim aborts), dispatched-marker write before spawn — so the claim- +CAS, dispatched-marker change-guard, and single-flight guards compose +unchanged. The rolling-log kind is the skill-mapped +`implement`/`plan`/`intake` (never a distinct critical kind). + +**Freeze split-by-skill (Q1):** while the code-freeze marker is frozen OR +ambiguous (fail-closed), a critical `plan_complete` (implement-kind) +candidate is SKIPPED — no new code changes land mid-release — but critical +`idea`/`intake_complete` (intake/plan-kind) candidates STILL dispatch: prep +work (intake/plan) is low-risk and allowed during a freeze, exactly matching +the non-critical plan/intake tiers. + +**Caps retention (Q2):** a critical `plan_complete` item dispatches with +`/skill:implement` only when risk ≤ Medium AND effort ≤ Medium; an +above-caps critical item is not a valid candidate and the tier falls +through. + +**Dependency-frontier dispatch (Q3):** when the selected critical item is +dependency-blocked (`wl dep list ` outbound `depends-on` edges), the +worker follows the blocking chain to the nearest OPEN blocker and dispatches +THAT blocker with its own stage-appropriate skill +(intake/plan/implement). Chains bottoming out in closed/non-dispatchable/ +above-caps items (or cycles) → no frontier candidate → the tier falls +through to the non-critical order. + +**Fail-closed lookup:** the critical tier consults its lookup on EVERY +dispatch — including during a code freeze — and resolves through the same +`DowntimeNextResult` error channel as the other tiers: `{ok:true, +candidate:null}` is a GENUINELY empty critical tier (falls through to the +non-critical tiers); `{ok:false}` is a `wl`/parse failure — a CLI-error +strike, never a silent fall-through (a broken critical lookup can never +masquerade as "no critical work"). + +### Multi-worklog support + +Each coordination entry records its own worklog root (`directory`). The +leader compares offers across directories using the standard tier priority +system and spawns dispatch panes into each entry's directory. v1 scope is +single-machine; a multi-machine (real flock/NFS) extension is future work. + +## Timing defaults (canonical) + +The original spec (parent AC8) said "30s proxy poll + 4 min continuous idle +threshold". **Accepted variance (2026-08-24): the code defaults are +canonical — 10s dispatch poll, 60s continuous idle threshold, with the proxy +status refresh unchanged at 30s.** + +| Setting | Default | Source | +|---|---|---| +| Dispatch poll interval | **10 s** (`downtimePollIntervalMs`) | `DEFAULT_DOWNTIME_POLL_INTERVAL_MS`, floor 10 s (`downtime-worker.ts`) | +| LLM continuous idle threshold | **60 s** (`downtimeIdleThresholdMs`) | `DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS`, floor 1 s (`downtime-worker.ts`) | +| Proxy status refresh | 30 s (`refreshIntervalMs`) | `settings.ts` (unchanged, pre-refactor cadence) | +| Leader lease TTL | 5 min (`DEFAULT_LEASE_TTL_SECONDS = 300`) | `leader-election.ts` | +| Coordination check-in | 30 min (`DEFAULT_COORDINATION_CHECK_IN_MS`) | `downtime-worker.ts` | + +Both dispatch-poll and idle-threshold are configurable in the herdr plugin +settings file (`~/.config/herdr/worklog-plugin.json`, +`downtimePollIntervalMs` / `downtimeIdleThresholdMs`) and are clamped on +load (see `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` in +`downtime-worker.ts`). + +## Files & runtime artifacts + +| Path | Purpose | +|---|---| +| `packages/herdr/src/leader-election.ts` | Lock acquisition, lease management, re-election | +| `packages/herdr/src/coordination.ts` | Coordination file read/write (entries, prune, upsert) | +| `packages/herdr/src/downtime-worker.ts` | Worker tick: election, check-in, idle gate, dispatch | +| `packages/herdr/src/downtime-log.ts` | Coordination/dispatch rolling logs | +| `/.worklog/downtime-leader.lock` | Leader lock file | +| `/.worklog/downtime-leader-lease.json` | Leader lease (5-min TTL) | +| `/.worklog/downtime-coordination.json` | Shared coordination list | +| `/.worklog/downtime-coordination.log` | Check-ins, elections, pruning | +| `/.worklog/downtime-dispatches.log` | Dispatched items | + +## Troubleshooting / operations + +- **No dispatches happening:** confirm a leader is elected (lease file + present + recent `lastUpdated` refresh), the proxy reports idle for ≥ 60 s + continuously, and the coordination list has offers. Check + `downtime-coordination.log` for check-ins and the dispatches log for the + last dispatch. +- **Two leaders:** impossible with `O_CREAT|O_EXCL` on one machine; if it + appears, check for leftover stale lock files (delete + `downtime-leader.lock` + lease to force re-election). +- **Corrupt coordination file:** read failures are treated as "missing" — + the instance degrades to the pre-refactor no-dispatch behavior, never + crashes. The file is safe to delete; instances rebuild it at their next + check-in. +- **Lease expiry after a crash:** the other instances detect the stale lease + (TTL 5 min), clean up, and re-elect automatically. + +## Related + +- Work item: **WL-0MST3OJ8S0001ROL** *Refactor Downtime Dispatcher: leader + election with shared coordination file* (+ its children H3UF5/H9UT6/HA1B/ + HA7LP/HAE2/HAKDT) +- Work item: **WL-0MT3FM8VA005XBHE** *Downtime dispatcher: critical items + always progress first regardless of stage* (critical-first tier + freeze + split-by-skill + caps retention + dependency-frontier dispatch) +- Package README: `packages/herdr/README.md` → *Downtime worker (local-LLM + idle dispatch)* +- Docs work item: **WL-0MT76H3Z900908TV** (this page) \ No newline at end of file diff --git a/docs/dev/fts.md b/docs/dev/fts.md new file mode 100644 index 000000000..ae533be06 --- /dev/null +++ b/docs/dev/fts.md @@ -0,0 +1,79 @@ +# FTS Search — Index Freshness & Query-Syntax Handling + +Worklog uses SQLite's FTS5 for full-text search over work items (title, +description, comments, tags). This document covers two behaviors that make +search reliable in practice: + +1. **Index freshness** — the FTS index is kept current on every write path, so + `wl search` never returns silently stale results. +2. **Query-syntax errors** — unquoted punctuated terms are auto-quoted with a + visible warning; genuinely invalid queries surface as errors (never a silent + empty result). + +## How search works + +- `WorklogDatabase.search()` (in `packages/shared/src/database.ts`) merges + ID-aware matches with FTS5 results (`SqlitePersistentStore.searchFts()`) + and dedupes them. +- When FTS5 is unavailable, it falls back to application-level `searchFallback()`. +- The FTS table (`worklog_fts`) is a content table — each row embeds the item's + title, description, comments, tags, status, and parent id, so the row for an + item must be rewritten whenever any of those fields changes. + +## Index freshness (save paths wired to `upsertFtsEntry`) + +Every path that mutates items or comments updates the FTS index: + +| Operation | FTS behavior | +|---|---| +| `create()` / `update()` | `upsertFtsEntry(item)` after save | +| `delete()` (soft) | `deleteFtsEntry(id)` | +| `import()` (bulk replace) | clears FTS via `clearWorkItems()`, then re-upserts every item | +| `upsertItems()` (incremental sync) | `upsertFtsEntry` per saved item | +| `reconcile*` (blocked/unblocked status changes) | `upsertFtsEntry` after status save | +| `createComment()` / `updateComment()` / `deleteComment()` | re-upserts the parent item (comment text lives in the parent's FTS row) | +| `importComments()` / `upsertComments()` (batch) | re-indexes all affected items | +| `clearWorkItems()` / `clearComments()` (used by `import`) | also `DELETE FROM worklog_fts` so stale rows are never left behind | + +Consequences: + +- `wl create` + `wl comment add` → both title and comment text are searchable + immediately, no `--rebuild-index` needed. +- Deleting an item or comment removes it from subsequent search results. +- `wl search --rebuild-index` remains the manual backstop for bulk migrations + (e.g. repairing a pre-existing index). + +## Query-syntax handling + +FTS5 rejects certain unquoted inputs — version strings (`v0.1.11`), file paths +(`src/lib/util.ts`), and IDs are common examples (`fts5: syntax error near "."`). +The old behavior swallowed these errors and returned `[]`, making "no matches" +indistinguishable from "invalid query". + +New behavior in `searchFts` → `WorklogDatabase.search()` → `wl search`: + +1. If the raw query throws an FTS5 error, the query is **retried as a quoted + phrase** (`"…"`, with embedded quotes escaped as `""`). +2. If the retry succeeds, results are returned normally and a **warning** is + emitted: `query "…" was auto-quoted as a phrase…` (printed with + `Warning: …` in human mode; included as a `warning` field in `--json`). +3. If the retry also fails (rare — e.g. a null byte in the query), the error is + **surfaced**: `wl search` exits non-zero with + `Invalid search query: invalid query syntax: "…" — quote phrases or punctuated terms. …`. +4. Genuinely zero-match queries return `No results found.` with no warning. + +### Quoting hygiene for users + +- Search a literal punctuated phrase by quoting it: `wl search "v0.1.11"`. +- Search a file path as a phrase: `wl search "src/lib/util.ts"`. +- FTS5 phrase/prefix/boolean operators still work as before: + `"closed with reason"`, `perf*`, `bug OR regression`, `-duplicate`. + +## Tests + +- `tests/fts-freshness.test.ts` — unit coverage for import/upsert/reconcile/ + comment freshness, delete removal, and the auto-quote/error-surfacing paths. +- `tests/cli/search-fts-freshness.test.ts` — end-to-end `wl search` coverage + (fresh index for created/imported items; auto-quote warning in human and JSON + output; no warning for quoted phrases or genuine empty results). +- `tests/fts-search.test.ts`, `tests/database.test.ts` — regression suites. \ No newline at end of file diff --git a/docs/dev/pause-when-hidden.md b/docs/dev/pause-when-hidden.md index 7c90719dc..2acb72d52 100644 --- a/docs/dev/pause-when-hidden.md +++ b/docs/dev/pause-when-hidden.md @@ -59,6 +59,34 @@ Two pollers were fixed: - `PollGate` (TTL ~2s) shares one `herdr tab get` call across refresh+sync ticks in a cycle (≤1 visibility exec per cycle). +#### Mouse and touch input (SGR reporting) + +- The worklist enables SGR mouse reporting (`\x1b[?1000h\x1b[?1002h\x1b[?1006h`) + on raw-mode entry and disables it on cleanup (parent WL-0MSGHM5BQ0096BNJ + AC1). Mouse events are parsed from raw stdin chunks in the `onData` handler + via `consumeMouseChunk()` — SGR sequences (`\x1b[9 hours +- **Current version:** 2.76.2 (27710) +- **Current goroutines:** 22 tasks, 78.8M memory, healthy + +### Journal Logs (Current Boot) + +snapd logs show normal operation with periodic refresh checks and cache cleanups: + +``` +Aug 22 22:39:32 snapd[6180]: Started snapd.service - Snap Daemon. +Aug 22 22:39:32 snapd[6180]: backends.go:70: AppArmor status: apparmor is enabled +Aug 22 22:39:32 snapd[6180]: standby.go:95: will consider standby after: 5s +``` + +No error logs or warning signs observed since the incident. + +## Mitigation Plan + +### Option A: Upgrade snapd to 2.76.3 (Recommended) + +The latest stable version (2.76.3) includes netlink parser fixes and shutdown reliability improvements that may address the root cause. + +```bash +sudo snap refresh snapd --channel=latest/stable +``` + +**Trade-off:** Minimal risk; this is the latest stable release. + +### Option B: Disable Watchdog (Fallback) + +If upgrading is not possible, disable the systemd watchdog to prevent forced kills: + +```bash +sudo systemctl set-property snapd.service WatchdogSec=0 +sudo systemctl restart snapd.service +``` + +**Trade-off:** If snapd hangs again, it will not be automatically recovered by systemd watchdog. Systemd restart policies (Restart=on-failure) may still catch it, but with longer delays. + +### Option C: Increase Watchdog Timeout + +If the watchdog needs to stay enabled but the timeout is too aggressive: + +```bash +sudo systemctl set-property snapd.service WatchdogSec=10min +sudo systemctl restart snapd.service +``` + +**Trade-off:** Longer hang before detection, but still allows automatic recovery. + +## Recommended Action + +**Apply Option A (upgrade to 2.76.3) with Option B (disable watchdog) as a fallback.** + +The upgrade is the primary fix because: +1. It addresses the root cause (netlink parser improvements) +2. It includes shutdown reliability fixes +3. It's the latest stable release with security updates + +If the upgrade is not possible or the issue recurs after upgrade, apply Option B as a safety net. + +## Monitoring Plan + +1. **Immediate:** Monitor snapd health for 7 days post-mitigation +2. **Ongoing:** Check `systemctl status snapd.service` daily for watchdog events +3. **Detection:** Watch for `journalctl -u snapd | grep -i "watchdog\|timeout\|stuck"` +4. **SIGQUIT monitoring:** If hang recurs, trigger `kill -ABRT $(pgrep snapd)` for goroutine dump +5. **Watchdog log:** `journalctl -u snapd | grep -i watchdog` + +## References + +- Work Item: WL-0MT1KJNHE0048HW3 +- snapd source: https://github.com/snapcore/snapd/tree/master/overlord/ifacestate/udevmonitor +- snapd bugs: https://bugs.launchpad.net/snapd +- Affected file: `udevmonitor/udevmon.go:147` +- Kernel: 7.0.0-30-generic (at time of incident) +- snapd installed: 2.76+ubuntu26.04.3 (security updates) +- snapd candidate: 2.76.3+ubuntu26.04 (90% phased) diff --git a/docs/dev/snapd-udev-monitor-hang-investigation.md b/docs/dev/snapd-udev-monitor-hang-investigation.md new file mode 100644 index 000000000..536ab5083 --- /dev/null +++ b/docs/dev/snapd-udev-monitor-hang-investigation.md @@ -0,0 +1,154 @@ +# snapd udev Monitor Goroutine Hang — Investigation Report + +**Work Item:** WL-0MT1KJNHE0048HW3 — snapd udev monitor goroutine hang - watchdog kills and restart loops +**Date:** 2026-08-23 +**Investigator:** Map + +--- + +## Incident Summary + +On 2026-08-20, the system experienced a hang caused by snapd becoming unresponsive: + +``` +Aug 20 13:37:51 systemd[1]: snapd.service: Watchdog timeout (limit 5min)! +Aug 20 13:37:51 systemd[1]: snapd.service: Killing process 1734095 (snapd) with signal SIGABRT. +Aug 20 13:37:59 systemd[1]: snapd.service: Failed with result 'watchdog'. +Aug 20 13:38:12 systemd[1]: Starting snapd.service - Snap Daemon... +Aug 20 13:39:52 systemd[1]: snapd.service: start operation timed out. Terminating. +``` + +The restart also timed out, forcing a manual reboot. + +## Root Cause Analysis + +### Smoking Gun: Goroutine Stack Dump + +From `journalctl -b -2 -u snapd` (boot before the crash), the SIGABRT stack dump shows: + +| Goroutine | State | Duration | Location | +|-----------|-------|----------|----------| +| **226** | `select` | 7740 minutes | `udevmonitor/udevmon.go:147` | +| **117** | `IO wait` | 7740 minutes | `main.runWatchdog.func1()` | +| **42** | `IO wait` | 7740 minutes | `dbus/v5.(*Conn).Auth` | +| **170** | `syscall` | 67 minutes | `udev/netlink/rawsockstop.go:57` | +| **2022** | `IO wait` | 388 minutes | `net/http.(*connReader).startBackgroundRead` | + +**Root Cause:** Goroutine 226 (`udevmonitor.(*Monitor).Run.func1()`) is stuck in an infinite `select` that never receives events and never times out. This is the classic Go `select` on a file descriptor that becomes unresponsive — the goroutine blocks forever, holding state locks and preventing daemon shutdown. + +The cascading effect: +1. Goroutine 226 stuck in `select` on the udev monitor socket +2. Goroutine 170 stuck in `stopperSelectReadable()` — the netlink stopper can't shut down the stuck monitor +3. Goroutine 42 stuck on D-Bus connection auth +4. The watchdog timer (5 min) expires → SIGABRT +5. snapd won't restart because it's waiting for locks held by stuck goroutines + +### Affected snapd Version + +- **Version:** 2.76.2 (build 27710) +- **File:** `overlord/ifacestate/udevmonitor/udevmon.go:147` +- **Platform:** Ubuntu 26.04 (Resolute) / kernel 7.0.0-30-generic + +## Upstream Research + +### Available Updates + +| Channel | Version | Release Date | Status | +|---------|---------|-------------|--------| +| latest/stable | 2.76.2 | 2026-08-13 | Currently installed | +| latest/candidate | 2.76.3 | 2026-08-20 | Available (90% phased in apt) | +| latest/beta | 2.77 | 2026-07-28 | Available | +| latest/edge | 2.77+g75.bee548f | 2026-08-21 | Available | + +### Relevant Changelog Entries in snapd 2.76 + +The following entries in snapd 2.76 may be related to the udev monitor stability: + +1. **"Allow equals signs in uevent values in netlink parser"** — A change to the netlink parser that could affect how udev events are handled and prevent parsing-related hangs +2. **"Ignore net.ErrClosed during daemon shutdown"** — Addresses error handling during shutdown, which is relevant since the stuck goroutine prevents clean shutdown +3. **"Restart snapd from daemon.Stop to improve restart reliability"** — Improves restart handling +4. **"Support racing Loop and Stop correctly in overlord"** — Fixes race conditions during stop + +### Known Bug References + +No specific Launchpad bug was identified for the udev monitor select hang. The issue class (Go `select` blocking on unresponsive file descriptors) is a known pattern in snapd's udev monitor implementation. The udev monitor goroutine uses `select` on the udev netlink socket without a timeout, making it vulnerable to hangs when the kernel/udev subsystem doesn't deliver events. + +**Relevant upstream bug tracker:** https://bugs.launchpad.net/snapd +**Relevant GitHub:** https://github.com/snapcore/snapd/issues + +## Current System Status + +### Verification: No Reproduction Since Incident + +Since the incident on 2026-08-20, snapd has been running stably: + +- **Last crash boot:** 2026-08-20 13:44 (boot -2) +- **Recovery boot:** 2026-08-20 13:43 (boot -1) — snapd started but was later killed by watchdog +- **Current boot:** 2026-08-22 22:39 (boot 0) — snapd has been running for >14 hours +- **Current version:** 2.76.2 (27710) +- **Current goroutines:** 22 tasks, 79.6M memory, healthy +- **Watchdog status:** 5min (mitigation not yet applied) + +### Journal Logs (Current Boot) + +snapd logs show normal operation with periodic refresh checks and cache cleanups. No error logs or warning signs observed since the incident. + +## Mitigation Plan + +### Option A: Upgrade snapd to 2.76.3 (Recommended) + +The latest stable version (2.76.3) includes netlink parser fixes and shutdown reliability improvements that may address the root cause. + +```bash +sudo snap refresh snapd --channel=latest/stable +``` + +**Trade-off:** Minimal risk; this is the latest stable release. + +### Option B: Disable Watchdog (Recommended Fallback) + +If upgrading is not possible, disable the systemd watchdog to prevent forced kills: + +```bash +sudo mkdir -p /etc/systemd/system/snapd.service.d +sudo cp snapd-watchdog-overrides.conf /etc/systemd/system/snapd.service.d/ +sudo systemctl daemon-reload +sudo systemctl restart snapd.service +``` + +**Trade-off:** If snapd hangs again, it will not be automatically recovered by systemd watchdog. Systemd restart policies (Restart=on-failure) may still catch it, but with longer delays. + +### Option C: Increase Watchdog Timeout + +If the watchdog needs to stay enabled but the timeout is too aggressive: + +```bash +sudo systemctl set-property snapd.service WatchdogSec=10min +sudo systemctl restart snapd.service +``` + +**Trade-off:** Longer hang before detection, but still allows automatic recovery. + +## Recommended Action + +**Apply Option B (disable watchdog) with Option A (upgrade snapd) as a complementary fix.** + +The systemd override is applied immediately as a safety net. The snapd upgrade should be applied when convenient. + +## Monitoring Plan + +1. **Immediate:** Monitor snapd health for 7 days post-mitigation +2. **Ongoing:** Check `systemctl status snapd.service` daily for watchdog events +3. **Detection:** Watch for `journalctl -u snapd | grep -i "watchdog\|timeout\|stuck"` +4. **SIGQUIT monitoring:** If hang recurs, trigger `kill -ABRT $(pgrep snapd)` for goroutine dump +5. **Watchdog log:** `journalctl -u snapd | grep -i watchdog` + +## References + +- Work Item: WL-0MT1KJNHE0048HW3 +- snapd source: https://github.com/snapcore/snapd/tree/master/overlord/ifacestate/udevmonitor +- snapd bugs: https://bugs.launchpad.net/snapd +- Affected file: `udevmonitor/udevmon.go:147` +- Kernel: 7.0.0-30-generic (at time of incident) +- snapd installed: 2.76+ubuntu26.04.3 (security updates) +- snapd candidate: 2.76.3+ubuntu26.04 (90% phased) diff --git a/docs/icons-design.md b/docs/icons-design.md index 839a8081b..ca6f5cd07 100644 --- a/docs/icons-design.md +++ b/docs/icons-design.md @@ -7,6 +7,16 @@ > [dcb09ac](https://github.com/TheWizardsCode/ContextHub/commit/dcb09ac), > [f3ca18b](https://github.com/TheWizardsCode/ContextHub/commit/f3ca18b) +> **Note:** The CLI's icon content lives in `src/theme.ts`; the deprecated +> `src/icons.ts` wrapper was removed in WL-0MSJ4BT4Z002HH9B. Herdr's icon +> data lives in the dependency-free `@worklog/shared/icons` module +> (`packages/shared/src/icons.ts`) — the plugin's local copy +> (`packages/herdr/src/icons.ts`) was removed in WL-0MSJ4BT4Z002HH9B and its +> consumers now import from the shared module. For parity (AC3), the shared +> module keeps the glyph values the herdr worklist has always rendered; a few +> of those intentionally differ from the CLI's `src/theme.ts` choices (see +> §13.1). + ## Overview This document defines the icon set for work item **priority** and **status** used @@ -67,6 +77,10 @@ When an audit result is `readyToClose: true` but the audit timestamp is stale in column 2 instead of the stage icon. This preserves the information that audit passed even after subsequent minor updates made the audit appear stale. +Note: flag-only flips of `needsProducerReview` do **not** move `updatedAt` +(WL-0MSN6ZCTN0027U2R), so a previously fresh audit stays fresh — the +stale-passed icon is only reached by genuine content changes. + The stale-passed icon only applies to `in_review` items. The regular audit icons (✅ / ❌ / ❔) are used for fresh audits, and the stage icon (🔍) is used when no audit exists or when the audit is stale with `readyToClose: false`. @@ -250,11 +264,13 @@ The icon lookup is a simple `Map` or plain object lookup — O(1) per call, negligible runtime cost. No SVG, image loading, or network requests are involved. -**Design decision:** Create a single `src/icons.ts` module that exports pure -functions: +**Design decision:** Create a single module (`src/theme.ts`) that exports pure +functions. (As of WL-0MSJ4BT4Z009LIB4, icon definitions were merged into +`src/theme.ts` alongside the chalk colour theme; `src/icons.ts` was removed +in WL-0MSJ4BT4Z002HH9B.) ```ts -// src/icons.ts +// src/theme.ts export interface IconOptions { /** When true, use text fallback instead of emoji/icon glyph */ @@ -292,7 +308,30 @@ export function iconsEnabled(opts?: { noIcons?: boolean }): boolean; ## 13. Implementation Guide -### 13.1 CLI List Rendering +### 13.1 Module Layout + +Icon maps and functions are defined in `src/theme.ts` alongside the chalk colour +theme. The `src/icons.ts` wrapper was removed in WL-0MSJ4BT4Z002HH9B; +CLI consumers import directly from `src/theme.js`. Herdr consumers import +from `@worklog/shared/icons` (the canonical home in +`packages/shared/src/icons.ts` of what used to be +`packages/herdr/src/icons.ts`). + +**Parity note (WL-0MSJ4BT4Z002HH9B AC3):** the shared module preserves the +glyph values the herdr worklist has always rendered, so removing the plugin's +local copy changes no rendered output. Where the CLI (`src/theme.ts`) and the +old herdr copy had diverged, the shared module keeps the *herdr* choices: + +| Item | Shared module (herdr) | CLI `src/theme.ts` | +|------|----------------------|--------------------| +| Audit unknown | `❓` | `❔` | +| Audit stale-passed | `⏳` | `🟩` (see §3a) | +| Epic | `⊙` | `🏰` | +| Risk critical | `💥` | `🚨` (key `severe`) | +| Effort levels | `🔹 🔷 🔶 💠` | `🐜 🐇 🐕 🐘 🐋` | +| Producer review undefined | `''` (nothing shown) | `✅` (defaults to complete) | + +### 13.2 CLI List Rendering Icons are prepended before the title in CLI list output: @@ -304,10 +343,10 @@ Icons are prepended before the title in CLI list output: > The Pi-based TUI browse list (which rendered status/stage/producer-review/ > epic icons via `getIconPrefix` in `packages/tui/extensions/Worklog/lib/browse.ts`) > has been removed — work item browsing is now provided by the Herdr plugin. -> The `dist/icons.ts` functions remain in use by the CLI and the retained -> session-health footer. +> `src/theme.ts` remains the CLI's self-contained icon source; the Herdr +> plugin imports from the shared `@worklog/shared/icons` module. -### 13.2 CLI Detail Output +### 13.3 CLI Detail Output File: `src/cli-output.ts`, `src/commands/helpers.ts` (`humanFormatWorkItem`) @@ -318,7 +357,7 @@ Status: 🟢 [OPEN] Priority: 🔴 [CRIT] ``` -### 13.3 Tests +### 13.4 Tests Tests should verify: - Icon functions return expected emoji for valid inputs @@ -332,7 +371,7 @@ Tests should verify: ## 14. Appendix: Example Usage ```ts -import { priorityIcon, statusIcon, iconsEnabled } from '../icons.js'; +import { priorityIcon, statusIcon, iconsEnabled } from './theme.js'; const useIcons = iconsEnabled({ noIcons: opts.noIcons }); @@ -355,12 +394,15 @@ lines.push(`Priority: ${pIcon} ${item.priority}`); | File | Change | |------|--------| -| `src/icons.ts` | Core icon module with emoji, fallback, and label functions | -| `src/commands/helpers.ts` | Added icon formatting to CLI output (summary, concise, normal, full) | +| `src/theme.ts` | Single source of truth: chalk colours + all icon maps/functions + `IconOptions` interface | +| `src/icons.ts` | REMOVED (WL-0MSJ4BT4Z002HH9B) — consumers import from `src/theme.js` | +| `@worklog/shared/icons` | NEW — canonical dependency-free icon/colour data for herdr (replaces `packages/herdr/src/icons.ts`) | +| `packages/herdr/src/icons.ts` | REMOVED (WL-0MSJ4BT4Z002HH9B) — herdr consumers import from `@worklog/shared/icons` | +| `src/commands/helpers.ts` | Rewired to import from `src/theme.js` | | `src/commands/list.ts` | Added `--no-icons` CLI flag | | `src/commands/show.ts` | Added `--no-icons` CLI flag | | `src/cli-types.ts` | Added `noIcons` to ListOptions and ShowOptions | -| `tests/unit/icons.test.ts` | 58 unit tests for icon functions | +| `tests/unit/icons.test.ts` | Unit tests for icon functions (wrapper parity tests removed) | ### CLI Usage diff --git a/docs/systemd/snapd-overrides.conf b/docs/systemd/snapd-overrides.conf new file mode 100644 index 000000000..815db3624 --- /dev/null +++ b/docs/systemd/snapd-overrides.conf @@ -0,0 +1,21 @@ +# Systemd override for snapd service watchdog +# +# This file should be installed via: +# sudo mkdir -p /etc/systemd/system/snapd.service.d +# sudo cp snapd-watchdog-overrides.conf /etc/systemd/system/snapd.service.d/ +# sudo systemctl daemon-reload +# sudo systemctl restart snapd.service +# +# Work Item: WL-0MT1KJNHE0048HW3 +# +# Rationale: snapd 2.76.2 contains a udev monitor goroutine hang bug +# (select without timeout on udev netlink socket). Disabling the systemd +# watchdog prevents the forced SIGABRT that contributes to cascading freezes. +# +# Trade-off: If snapd hangs, it will not be auto-recovered by watchdog. +# Systemd Restart=on-failure policy still applies. +# +# See: docs/dev/snapd-udev-monitor-hang-investigation.md + +[Service] +WatchdogSec=0 diff --git a/docs/systemd/snapd-watchdog-overrides.conf b/docs/systemd/snapd-watchdog-overrides.conf new file mode 100644 index 000000000..815db3624 --- /dev/null +++ b/docs/systemd/snapd-watchdog-overrides.conf @@ -0,0 +1,21 @@ +# Systemd override for snapd service watchdog +# +# This file should be installed via: +# sudo mkdir -p /etc/systemd/system/snapd.service.d +# sudo cp snapd-watchdog-overrides.conf /etc/systemd/system/snapd.service.d/ +# sudo systemctl daemon-reload +# sudo systemctl restart snapd.service +# +# Work Item: WL-0MT1KJNHE0048HW3 +# +# Rationale: snapd 2.76.2 contains a udev monitor goroutine hang bug +# (select without timeout on udev netlink socket). Disabling the systemd +# watchdog prevents the forced SIGABRT that contributes to cascading freezes. +# +# Trade-off: If snapd hangs, it will not be auto-recovered by watchdog. +# Systemd Restart=on-failure policy still applies. +# +# See: docs/dev/snapd-udev-monitor-hang-investigation.md + +[Service] +WatchdogSec=0 diff --git a/docs/tutorials/01-your-first-work-item.md b/docs/tutorials/01-your-first-work-item.md index 19434eb8f..a597ccc83 100644 --- a/docs/tutorials/01-your-first-work-item.md +++ b/docs/tutorials/01-your-first-work-item.md @@ -41,7 +41,7 @@ Navigate to the Git repository where you want to track work, then run: wl init ``` -Worklog will prompt you for a project name and an ID prefix (e.g. `WI` or `PROJ`). Accept the defaults or choose your own. When asked about `AGENTS.md`, choose the option that suits your project (O = overwrite, A = add pointer, M = manual). See [AGENTS.md Install Model](../AGENTS-INSTALL.md) for what gets installed and how it relates to the global agent file. +Worklog will prompt you for a project name and an ID prefix (e.g. `WI` or `PROJ`). Accept the defaults or choose your own. When asked about `AGENTS.md`, choose the option that suits your project (O = overwrite, A = add the global-agents reference above your content, M = manual). See [AGENTS.md Install Model](../AGENTS-INSTALL.md) for what gets installed and how it relates to the global agent file. After initialization you will have a `.worklog/` directory containing configuration and a local SQLite database. This directory is typically added to `.gitignore` since data is shared via `wl sync` rather than through direct file commits. diff --git a/package.json b/package.json index cfe881863..4be459180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "1.0.11", + "version": "1.0.12", "description": "A simple experimental issue tracker for AI agents", "main": "dist/index.js", "type": "module", @@ -26,6 +26,8 @@ "benchmark:sort-index": "tsx scripts/benchmark-sort-index.ts", "benchmark:wl-next": "tsx bench/wl-next-perf.ts", "benchmark:wl-next:json": "tsx bench/wl-next-perf.ts --json", + "install": "npm run install:plugin && npm run install:pi-extension", + "install:plugin": "bash ./scripts/install-herdr.sh", "install:pi-extension": "bash ./scripts/install-pi-extension.sh" }, "keywords": [ diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 9feab5a23..f32aeff64 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -5,20 +5,24 @@ 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** — Press `f` followed by a chord key (`i`=idea, `n`=intake, `p`=plan, `r`=review, `s`=sprint back to the default view), or type `/wl ` (shorthand alias or canonical stage name, e.g. `/wl intake_complete` or `/wl progress`), to filter items by stage. Stage-filtered views show every root item in the selected stage matching the stage's status rule (open items for most stages; `completed`/`in-progress`/`open` for the in_review stage) — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW) +- **Filter by stage and priority** — Press `f` then an axis key (`s`=stage, `p`=priority), then a value key, or type `/wl ` / `/wl --priority `. 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. Stage and priority filters are **mutually exclusive** (replace semantics — applying one clears the other); sprint clears both. 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) — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW, WL-0MSKC8T46006999S) - **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 detail view metadata section additionally shows the review status (❌ needs review / ✅ reviewed) and the 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. - **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`) -- **Command output** — When a chord resolves to a non-`/wl` command (e.g., `!!wl update --priority high`), the resolved command is executed **visibly in a new herdr pane** (see `scripts/run-in-pane.sh`) so the user sees the command line and its output; the wrapper keeps the pane's process alive so the pane stays open for inspection — dismiss it with Enter or close it with `prefix+x` +- **Command output** — When a chord resolves to a non-`/wl` command (e.g., `!!wl update --priority high`), the resolved command is executed **visibly in a new herdr pane** (see `scripts/run-in-pane.sh`) so the user sees the command line and its output; the wrapper keeps the pane's process alive so the pane stays open for inspection — dismiss it with Enter or close it with `prefix+x`. Panes spawned from the selection list open **without stealing focus**: the list keeps the keyboard focus while the command-output pane opens in the background (see [Design decisions](#design-decisions)). - **Command input form** — When a chord command contains unknown `` placeholders (e.g. `!!wl update --status --stage `), the plugin shows a modal input form so you can fill in the values before the command runs. Known identifiers like `` are still auto-substituted with the selected item's ID. The form is a full-pane page (no border/centering) that wraps text at the pane width and grows downward as content is entered. See [Command input form](#command-input-form). -- **Keyboard navigation** — Arrow keys or j/k to navigate (wraps at list boundaries), Page Up/Down, g/G for first/last, Enter to select, Escape to go back -- **Pi agent pane dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are automatically dispatched to a new pi agent pane opened to the right, where pi receives the command as its initial prompt. Free-form prompts use the `/prompt:` prefix: the routing prefix is stripped so pi receives only the prompt text. +- **Keyboard navigation** — Arrow keys or j/k to navigate (wraps at list boundaries), Page Up/Down, g/G for first/last, Enter to select, Escape to go back. +- **Mouse and touch** — Click/tap a row to select; double-click to open detail; mouse wheel or touch-scroll to navigate (list mode) or scroll detail; tap stage options in the filter prompt. Requires SGR mouse reporting (enabled on raw-mode entry, disabled on exit). Terminals without it fall back to keyboard-only. **Alt+m toggles mouse tracking** on/off (WL-0MT0AP2LR000JFWN): while tracking is off, the terminal's native drag-select-to-copy works; press Alt+m again to resume mouse interaction. The current state is shown in the footer (`alt+m mouse on/off`). +- **Fold indicators** — When the worklist has more items than fit the visible list area, the list shows dim `▼ more` / `▲ more` markers so you always know when items are hidden below the fold or above the current scroll position (WL-0MSG8YXYJ008PWJJ). See [Selection List Behaviour](#selection-list-behaviour). +- **Pi agent pane dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are automatically dispatched to a new pi agent pane opened to the right, where pi receives the command as its initial prompt. Free-form prompts use the `/prompt:` prefix: the routing prefix is stripped so pi receives only the prompt text. The agent pane opens **without stealing focus** from the selection list (see [Design decisions](#design-decisions)). +- **Downtime worker (local-LLM idle dispatch)** — During operator idle time the plugin dispatches pi agent panes to run audits/refactors of completed items against the local llama-server (see [Downtime worker](#downtime-worker-local-llm-idle-dispatch)) +- **Mode-switch worker (activity-gated proxy mode switching)** — Automatically switches the llama-proxy between fast (cloud) and cheap (local) modes: agent-route commands fire an immediate fast switch (fail-open), while a full operator-idle window plus a proxy-idle check triggers the cheap switch (fail-closed). The proxy URL reuses `downtimeProxyUrl` and the plugin's switches are manual overrides that the proxy's own time schedule reclaims. See [Mode-switch worker](#mode-switch-worker-activity-gated-proxy-mode-switching-wl-0msn3fwv5008kqe9). - **Agent status tracking** — When an agent command carrying a work-item ID is dispatched, the worklist records which pi agent pane is attached to that item (persisted to the gitignored `.worklog/agent-panes.json`, shared across worklist panes). The list shows a live agent-status icon at the start of each row's icon prefix: 🟢 working, ⛔ blocked, ⚪ idle. Done/closed items (and items without an agent) show no icon. The icon is a fixed-width slot so the item-ID column never shifts. See [Agent status icons](#agent-status-icons). - **Open Pi Agent action** — The plugin provides an action to open a fresh interactive pi session pane - **Tab-based opening** — The worklist opens in a new tab in the current workspace, providing full-screen access without reducing space for existing panes -- **Podcast Editing tab** — The `open-podcast-editor-tab` action (bound to `prefix+l`) opens the worklist pane in a new tab and renames that tab to **`Podcast Editing`** so it is instantly recognisable in the tab row during podcast production. See [Podcast Editing tab](#podcast-editing-tab). +- **Worklog tab** — The `open-podcast-editor-tab` action (bound to `prefix+l`) opens the worklist pane in a new tab and renames that tab to **`Worklog`** so it is instantly recognisable in the tab row among other open tabs. See [Worklog tab](#worklog-tab). - **Quit** — Press `q` to exit -- **Metadata panel** — The bottom portion of the list view (roughly 20–40% of the pane height, responsive to terminal size) is reserved for the selected item's metadata: ID, title, status, stage, priority, type, risk, effort, tags, audit info, and more, plus a **description preview** (first up-to-3 lines of the item's description) so you can see what an item is about at a glance. The panel scrolls independently with `m`/`M` (down/up) so long metadata never affects list navigation. See [Metadata panel](#metadata-panel). +- **Metadata panel** — The selected item's metadata (ID, title, status, stage, priority, type, risk, effort, tags, audit info, and more, plus a **description preview** — first up-to-3 lines of the item's description) is shown in a panel below the list. The list takes as much vertical space as its content needs, up to the full pane height; the metadata panel fills whatever space remains — expanding when the list is short, and sitting at the bottom (minimum 3 rows) when the list is long. The panel scrolls independently with `m`/`M` (down/up) so long metadata never affects list navigation. See [Metadata panel](#metadata-panel). - **Command log** — Every plugin-dispatched command that targets a work item (via `` substitution or an explicit item ID) is recorded to a local JSON log. For `in_progress` items the panel shows the **last command** at the bottom, so you can see exactly what was last dispatched against the item. See [Command log](#command-log). - **Stage grouping** — Work items are grouped by their Worklog stage (standard lifecycle stages only: `idea`, `intake_complete`, `plan_complete`, `in_progress`, `in_review`, `done` — no custom stage values). Podcast episode items group exactly as their frontmatter stages map 1:1 (PRD §7.2). See [Stage grouping](#stage-grouping). - **Generic md viewer** — When a work item's description carries a `Key Files:` path to a markdown document (e.g. a podcast episode `.podcast.md`), the detail view renders the file with a generic markdown viewer (frontmatter skipped, full GFM rendering: headings, lists, tables, blockquotes, code, links) as a preview. The description section is rendered with the same markdown renderer. A persistent **Related Docs** table of contents at the top of the detail view lists every `.md` Key File (`↑↓/j:k` to navigate, `Enter` to open in the viewer), and the metadata panel shows a display-only `Related Docs` row. See [Markdown viewer](#markdown-viewer). @@ -27,7 +31,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b ## Requirements -- [Herdr](https://herdr.dev) v0.7.0 or later +- [Herdr](https://herdr.dev) v0.7.0 or later (the plugin loads on 0.7.0–0.7.4 with polling-only behavior; the event-driven status path — immediate agent-status icons and focus/visibility updates — requires herdr ≥ 0.7.5 and degrades to polling on older versions) - [Worklog CLI (`wl`)](https://github.com/your-org/worklog) installed and on PATH ## Installation @@ -50,9 +54,11 @@ cd packages/herdr && npm run build - Links the plugin from the **main checkout** with `herdr plugin link /packages/herdr/herdr-plugin.toml`. When the build runs inside a linked git worktree (as happens during `/skill:implement`), the main checkout is resolved via `git worktree list` so the global herdr plugin is never registered against a transient worktree path — a worktree-based link would dangle once the worktree is cleaned up and silently break `prefix+l` (WL-0MSRG481O007QVEA). `herdr plugin link` updates an existing link in place, so a stale link from an older build is corrected automatically. - Inserts the `prefix+l` → `worklog-selection-list.open-podcast-editor-tab` keybinding into your herdr config (`~/.config/herdr/config.toml`, or `$HERDR_CONFIG_PATH` when set) **only if** it is not already present — re-running the build never creates duplicate keybindings. -- Migrates a legacy `prefix+l` → `worklog-selection-list.open-worklist` binding in-place to the new action, so the Podcast Editing tab name takes effect without a manual config edit. +- Migrates a legacy `prefix+l` → `worklog-selection-list.open-worklist` binding in-place to the new action, so the Worklog tab name takes effect without a manual config edit. - Warns (without failing the build) when `herdr` is not on PATH or the config cannot be written, so `npm run build` succeeds in CI/offline environments. +Alternatively, `npm run install` (or `npm run install:plugin` for the herdr step alone) runs the same install script outside of a build context. + Manual install (fallback, if you prefer not to run the build): ```bash @@ -63,7 +69,7 @@ herdr plugin link packages/herdr/herdr-plugin.toml # [[keys.command]] # key = "prefix+l" # command = "herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab" -# description = "Open the Podcast Editing tab (Worklog work item selection pane)." +# description = "Open the Worklog tab (Worklog work item selection pane)." ``` The plugin pane will then be available via the Herdr plugin system. @@ -73,7 +79,7 @@ The plugin pane will then be available via the Herdr plugin system. ### From the Herdr UI 1. Open the worklist pane: - - Press `prefix+l` to open the Podcast Editing tab (worklist pane in a new tab named `Podcast Editing`) + - Press `prefix+l` to open the Worklog tab (worklist pane in a new tab named `Worklog`) - Right-click in any pane → Plugins → Worklog Selection List → Open worklist - Or use the Herdr command palette: `herdr plugin action run worklog-selection-list open-worklist` @@ -88,19 +94,29 @@ The plugin pane will then be available via the Herdr plugin system. - `Tab` — Toggle expand/collapse a parent item with children (at any depth) - `Escape` — Go back (from detail or filter mode); in a child list, return to the parent level at the previous scroll position. When inside a child list the footer shows a `[esc] back` hint (with `(N levels)` when nested deeper than one level). -3. Filter by stage using chord shortcuts (or type `/wl `): - - Press `f` then `i` — Filter to idea-stage items - - Press `f` then `n` — Filter to intake_complete items - - Press `f` then `p` — Filter to plan_complete items - - Press `f` then `r` — Filter to in_review items - - Press `f` then `s` (sprint) — Return to the default unfiltered browse list +3. Filter by stage or priority using chord shortcuts (or type `/wl ` / `/wl --priority `): + - Press `f`, `s`, then a stage key — Filter to a stage: + - `f`, `s`, `i` — idea-stage items + - `f`, `s`, `n` — intake_complete items + - `f`, `s`, `p` — plan_complete items + - `f`, `s`, `r` — in_review items + - `f`, `s`, `s` (sprint) — Return to the default unfiltered browse list + - Press `f`, `p`, then a priority key — Filter to a priority: + - `f`, `p`, `l` — low priority items + - `f`, `p`, `m` — medium priority items + - `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) + - Stage and priority filters are **mutually exclusive**: applying a priority filter replaces the active stage filter and vice versa (single filter slot, replace semantics). Sprint (`f s s`, `f p s`, or `/wl` with no arguments) clears **both**. - `/wl ` accepts shorthand aliases (`idea`, `intake`, `plan`, `progress`, `review`) and canonical stage names (`intake_complete`, `plan_complete`, `in_progress`, `in_review`) - - `/wl` with no stage argument returns to the default unfiltered browse list - - Filtered views show every root item in the selected stage matching the stage's status rule (see [Selection List Behaviour](#selection-list-behaviour)) + - `/wl --priority ` accepts the canonical priority names `critical`, `high`, `medium`, `low`; unknown values fall back gracefully (no crash, no filter change) + - `/wl` with no stage/priority argument returns to the default unfiltered browse list + - Filtered views show every root item matching the filter's rule (see [Selection List Behaviour](#selection-list-behaviour)) - Press `Escape` to cancel an incomplete chord 4. Workflow shortcuts (single-key): - Press `c` — Create a new work item + - Press `d` — **Toggle downtime dispatch for the current pane** (per-pane only — sibling panes and other worklog roots are unaffected; the header shows `[Downtime Off]` while off and the live status when on. The disable persists across plugin restarts via a `.herdr-downtime-disabled` marker in the worklog root; press `d` again to return to following the global setting. See [Downtime worker](#downtime-worker-local-llm-idle-dispatch)) - Press `i` — Run the implement workflow on the selected item (intake_complete, plan_complete, in_progress) - Press `n` — Run the intake workflow on the selected item (idea stage) - Press `p` — Run the plan workflow on the selected item (intake_complete stage) @@ -144,19 +160,21 @@ Rows that have a worklist-spawned pi agent pane attached show the agent's live s | *(none)* | No agent attached, or the agent finished (`done`/closed) | The icon occupies a fixed-width slot, so the remaining icons and the item-ID column stay perfectly aligned whether or not a row has an agent. State is refreshed from the herdr CLI on each worklist refresh (with a short TTL); if the CLI is unavailable the list renders without icons rather than failing. + +**Event-driven updates (herdr ≥ 0.7.5, WL-0MSHB7DHO004RHBJ)** — when the herdr event subscription is active, a `pane_agent_status_changed` event updates the icon **immediately** (within one render cycle, no `herdr agent list` exec). `pane_agent_detected` re-reads the shared `.worklog/agent-panes.json` so agents spawned by any herdr instance in the same session (including late-spawned ones) are picked up; `pane_closed`/`pane_exited` prune associations so icons clear instantly. Rapid status changes are coalesced per render cycle (no render thrash). `herdr agent list` remains the one-time initial snapshot and the fail-open poll fallback when events are unavailable. ### From the command line ```bash # Direct invocation (opens in a new tab) herdr plugin action run worklog-selection-list open-worklist -# Open the Podcast Editing tab (worklist pane renamed to "Podcast Editing") +# Open the Worklog tab (worklist pane renamed to "Worklog") herdr plugin action run worklog-selection-list open-podcast-editor-tab ``` -### Podcast Editing tab +### Worklog tab -`herdr plugin pane open` has no tab-title option, so a worklist pane opened in a tab gets a generated numeric label. The `open-podcast-editor-tab` action (bound to `prefix+l`) fixes that: it opens the worklist pane in a new tab exactly like `open-worklist` (same flags and working-directory resolution) and then renames the created tab to **`Podcast Editing`** via `herdr tab rename`, so the podcast production tab is instantly recognisable among several open tabs. Each press opens a new tab — there is deliberately no focus-if-open toggle. +`herdr plugin pane open` has no tab-title option, so a worklist pane opened in a tab gets a generated numeric label. The `open-podcast-editor-tab` action (bound to `prefix+l`) fixes that: it opens the worklist pane in a new tab exactly like `open-worklist` (same flags and working-directory resolution) and then renames the created tab to **`Worklog`** via `herdr tab rename`, so the worklog pane is instantly recognisable among several open tabs. Each press opens a new tab — there is deliberately no focus-if-open toggle. The underlying script (`scripts/open-podcast-editor-tab.sh`) fails fast with a clear error and non-zero exit when the herdr CLI is unavailable, the pane open fails, or the created `tab_id` cannot be parsed — a missing rename is never silently skipped. The tab label can be overridden with the `TAB_LABEL` environment variable. @@ -165,6 +183,7 @@ The underlying script (`scripts/open-podcast-editor-tab.sh`) fails fast with a c The plugin respects the following environment variables: - `WL_COUNT` — Number of work items to fetch (default: 20, now superseded by `browseItemCount` in settings) +- `HERDR_SOCKET_PATH` — Path to the herdr Unix socket used by the event subscription (default `~/.config/herdr/herdr.sock`). When unset/unreachable the plugin falls back to polling. #### Plugin Settings (config file) @@ -172,23 +191,67 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in - `autoRefresh` — Enable periodic auto-refresh of the work item list (default: `true`) - `refreshIntervalMs` — Interval in ms between auto-refreshes (default: `30000`). Refresh cycles are single-flight: a tick that fires while the previous refresh is still awaiting its `wl` calls is skipped (no overlapping refresh cycles / wl spawn bursts from a pane), and the cadence resumes on the next tick (WL-0MSBVYBMD004007C). Each refresh is **atomic with respect to expanded state**: children of expanded parents are re-fetched in parallel with the top-level list and applied in one synchronous swap, so an expanded hierarchy never momentarily collapses mid-refresh (WL-0MSBVBNGH002RDP5). + + **Bounded wl spawns** — every `wl` spawn in the refresh/sync fetch path (`wl next`, `wl list`, `wl show`, `wl sync`, …) runs with a bounded `DEFAULT_WL_TIMEOUT_MS` (60s, matching the auto-sync spawn safety timeout) unless the caller supplies an override (e.g. the claim path's 3s `CLAIM_TIMEOUT_MS`). A hung `wl` child is killed after the bound, the refresh fails with a `Refresh failed` toast, the single-flight guard is cleared in `finally`, and the next tick retries — a hung spawn never wedges `refreshInFlight` permanently (WL-0MSJNJXX2001NMHS). The `checkWlAvailable` startup probe (`wl --version`) is bounded the same way so a hung probe cannot block TUI startup. - `autoSync` — Enable periodic background `wl sync` before auto-refreshes (default: `true`). Background syncs use a single-flight in-process guard and pass `wl sync --if-idle`, so overlapping syncs (from this pane or other panes/TUI instances) are skipped instead of piling up — preventing wl sync lock storms (WL-0MSAB7ZUC004SK7E). - `syncIntervalMs` — Interval in ms between background `wl sync` calls (default: `60000`, minimum: `60000`; set to `0` to disable auto-sync) - `browseItemCount` — Max number of non-mandatory items to show in the list (default: `20`, range `1`–`50`; critical and completed/in_review items are always shown regardless) - `showHelpText` — Show the shortcut hint line at the bottom of the list (default: `true`). When `false`, **all** shortcut hint lines are hidden — including the chord-in-progress footer (`chord: _ `) — consistent with the pi browse widget (WL-0MSGJDSMJ004128E). Chord key *handling* still works while hints are hidden; only rendering is affected. Changes apply on the next render without a plugin restart -- `showIcons` — Toggle icons in the list (default: `true`); changes apply on the next render without a plugin restart +- `showIcons` — Toggle icons in the list and metadata (default: `true`); changes apply on the next render without a plugin restart. When disabled, list rows use text fallbacks (`[OPEN]`, `[IDEA]`, …) and metadata values fall back to plain text (no emoji) ### Downtime worker (local-LLM idle dispatch) +**Leader-election + shared coordination list (parent WL-0MST3OJ8S0001ROL):** +since the refactor, ONE elected leader handles all llama-proxy polling and +dispatches; the other herdr instances coordinate instead of polling: + +- **Leader election** — the first instance to acquire a file lock at + `/.worklog/downtime-leader.lock` wins (single-machine v1, + flock-equivalent `O_CREAT|O_EXCL`). The leader holds a **5-minute lease** + (`downtime-leader-lease.json`), refreshed on every proxy-poll cycle; if + the lease expires (leader crashed or idle) another instance detects it + and takes over within the TTL. +- **Shared coordination file** — every instance (leader and non-leader + alike) checks in on startup and every **30 minutes**, offering its own + worklog's **most-important item** at + `/.worklog/downtime-coordination.json`: + `{instanceId, workItemId, directory, assignedAt, lastUpdated}`. +- **Leader dispatch** — only the leader polls the proxy; once the LLM has + been idle continuously for the threshold, it first checks the + **scheduled-prompts** config (a due prompt dispatches immediately, + WL-0MSS1Q5ER007QDKX — see *Scheduled prompts* below), and only when none + is due does it read the coordination + list, classify each offer (tier priority: **audit → implement → plan → + intake**), and dispatch the highest-priority available item when a slot + opens. The dispatched entry is **removed**, and its owning instance + re-offers its next most-important item at its next check-in. +- **Non-leaders** — skip proxy polling and dispatch entirely; they only + refresh their lease check (a cheap local file read) and their + coordination entry. +- **Fail-safe** — a missing/unreadable coordination file or a failed + 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. + +Coordination operations (check-ins, elections/takeovers, stale-entry +pruning) are recorded in `.worklog/downtime-coordination.log` — a separate +rolling log from the dispatch log, so the dispatch-marker readers never see +coordination records. + When the local LLM (llama-server behind the llama-proxy) is idle, the plugin can use that compute to advance the worklog backlog automatically: after the proxy reports idle continuously for the configured threshold, it opens a visible (non-focus-stealing) pi agent pane. Dispatch priority -(WL-0MSI8H3HP000K0RG, WL-0MSMAYPQP001FLR6): -first a completed/in_review item **without a valid audit** → `/skill:audit -`; else the highest-priority open `plan_complete` item with risk `Low` -and effort `Small`/`Extra Small` → `/skill:implement `; else -`/skill:plan` on the next `intake_complete` item; else falls back +(WL-0MSS1Q5ER007QDKX, WL-0MSI8H3HP000K0RG, WL-0MSMAYPQP001FLR6, +WL-0MT3FM8VA005XBHE): +first a due **scheduled prompt** (see *Scheduled prompts* below) dispatches +its prompt text; else a completed/in_review item **without a valid audit** → +`/skill:audit `; else the highest-priority open **critical** item at ANY +stage (or its dependency-frontier blocker) → stage-appropriate +`/skill:intake`/`/skill:plan`/`/skill:implement` (see *Critical-first tier* +below, WL-0MT3FM8VA005XBHE); else the highest-priority open `plan_complete` +item with risk `Low` and effort `Small`/`Extra Small` → `/skill:implement `; +else `/skill:plan` on the next `intake_complete` item; else falls back to `/skill:intake` on the next `idea` item (parent WL-0MSF49FMW009M06K). A "valid" audit is defined by the review-icon freshness rule: the audit is @@ -198,19 +261,28 @@ current — i.e. the review icon is **neither** the hourglass `⏳` (stale passe staleness buffer of updatedAt); missing audit timestamps are treated as not-fresh and therefore selected. +Guarantee (WL-0MSN6ZCTN0027U2R): `updatedAt` is bumped only on **content** +changes (title, description, status, stage, priority, tags, assignee, etc.). +Flag-only flips of `needsProducerReview` persist the flag but do **not** move +`updatedAt`, so a previously valid audit stays fresh — the TUI keeps showing +the passed icon (not the stale hourglass) and the audit tier does not +re-dispatch a redundant `/skill:audit` for unchanged content. + Settings (all re-read each poll, so changes apply without a plugin restart): - `downtimeEnabled` — Enable the downtime worker (default: `true`) - `downtimeIdleThresholdMs` — Minimum continuous idle duration before a -dispatch (default: `240000` = 4 minutes, floor 1s) -- `downtimeRequiredFreeSlots` — Required free slots; `0` means **all** slots -must be free (default). A positive integer N is accepted; with per-slot -identity data the worker requires the **same N slots** continuously free (see -per-slot idle tracking below) for `0 < N < total`; without per-slot data it -fails closed to all-slots-free for `0 < N < total` (never any-N dispatch); -`N > total` never dispatches. +dispatch (default: `60000` = 60 seconds, floor 1s) +- `downtimeRequiredFreeSlots` — Required free slots (default: `2` of 3 — +**spare-capacity dispatch**, parent WL-0MT32F90V008UAD2: at least two +slots must be free so one slot stays reserved for the operator session). +`0` means **all** slots must be free. A positive integer N is accepted; +with per-slot identity data the worker requires the **same N slots** +continuously free (see per-slot idle tracking below) for `0 < N < total`; +without per-slot data it fails closed to all-slots-free for `0 < N < total` +(never any-N dispatch); `N > total` never dispatches. - `downtimePollIntervalMs` — Poll interval for the proxy status endpoint -(default: `30000`, hard floor `10000`) +(default: `10000`, hard floor `10000`) - `downtimeProxyUrl` — Base URL of the llama-proxy (default: `http://192.168.0.199:8000`) - `downtimeModel` — pi model pattern for dispatched panes (default: `plan`) @@ -225,30 +297,74 @@ serves `local_active_query` — preferred over the global `active_query`, so remote-only streams with free local slots do not block dispatch; absent on pre-fix proxies, the global `active_query` is used as the fallback), no model switch, no active local lease, and the required free-slot condition met. +**In per-slot identity mode with `0 < N < total` the query/lease checks are +relaxed** (spare-capacity dispatch, parent WL-0MT32F90V008UAD2): a query or +lease tied to a busy slot is the operator's own session and must not block +dispatch into the free slots — the per-slot `is_processing` signal is the +authoritative busy signal and only llama-server-down and model-switch remain +global gates. Endpoint failures, timeouts, and ambiguous responses are treated as **busy** (no dispatch) and never crash the plugin. Each poll is single-flight with a per-poll timeout. +**Per-pane toggle** (parent WL-0MSZ4NSOE007AQEF) — pressing `d` in the +worklist toggles downtime dispatch for the **current pane only**: a +per-instance override flips the worker's effective enabled state +(override takes precedence over the global `downtimeEnabled` setting). The +cycle is a strict disable/follow double-toggle — `d` disables; `d` again +returns to following the global setting. There is **no force-enable state** +(repeated presses can never force dispatch on). While overridden off, the +worker performs no proxy polling, idle tracking, or dispatch (identical to +the settings-disabled path). + +**Durability** (WL-0MT5SFP990001FNW) — unlike the original in-memory-only +override, a disable is **persisted per worklog root** in a small marker file +`.herdr-downtime-disabled` created next to the worklog data (in the pane's +resolved worklog root). The marker is written when you press `d` and removed +when you press `d` again (or when you delete the file). On plugin restart +the worker reads the marker back and starts **disabled**, and the header +shows `[Downtime Off (restored)]` so a restored disable is always visible +(never a silent return to the enabled-idle header). Re-enable by pressing +`d` (removes the marker), or delete the marker file manually. The marker is +per root — sibling panes on other roots are unaffected, and the shared +settings file is never written. + **Per-slot idle tracking** (LP-0MSG5TA7Y002GN39) — when the proxy serves per-slot detail (`slots: [{slot_id, is_processing}]`) in the status payload AND `downtimeRequiredFreeSlots` is `0 < N < total`, the worker tracks the idle duration of **each slot individually** and dispatches only when the **same N slots** have each been continuously free for the full `downtimeIdleThresholdMs` — a slot that starts processing resets only its -*own* idle timer, so transient any-N availability never counts. Any *global* -busy condition (active query, model switch, local lease, server down, or an -ambiguous/unparseable poll) resets **all** slot timers, requiring a fresh -full idle period. This assumes `slot_id` values are stable across polls -(they identify the physical slots on the llama-server). Malformed per-slot -data (non-array, missing/empty `slot_id`, non-boolean `is_processing`, or -duplicate ids) is treated as busy (fail-closed). Without per-slot data — or -when N is `0` or ≥ `total` — the worker falls back to the count-based -all-slots-free logic: N of `total` slots free never dispatches without -per-slot identity. +*own* idle timer, so transient any-N availability never counts (and a busy +slot holding the operator's query/lease does not reset the free slots' +timers — spare-capacity dispatch, parent WL-0MT32F90V008UAD2). In per-slot +mode only **server down** and **model switch** remain GLOBAL busy +conditions that reset **all** slot timers and require a fresh full idle +period; an ambiguous/unparseable poll also resets all timers (fail-closed). +This assumes `slot_id` values are stable across polls (they identify the +physical slots on the llama-server). Malformed per-slot data (non-array, +missing/empty `slot_id`, non-boolean `is_processing`, or duplicate ids) is +treated as busy (fail-closed). Without per-slot data — or when N is `0` or +≥ `total` — the worker falls back to the count-based all-slots-free logic +with the FULL global checks (server, local query, model switch, lease): N +of `total` slots free never dispatches without per-slot identity. + +**Per-tier free-slot minimums** (parent WL-0MT32F90V008UAD2 AC3) — at +selection time, against the latest polled status, each dispatch tier +additionally requires a minimum number of free slots (independent of the +idle-duration gate): the **audit** tier needs **≥ 2 free slots** (a parent +audit plus its Phase 2 child at `AUDIT_PHASE2_PARALLELISM=1` needs two +local slots), while the single-pane tiers (scheduled prompts, critical, +implement, plan, intake) need **≥ 1 free slot**. An unmet minimum skips that +tier to the next eligible one — an ineligible skip, never a three-strike +error and never an empty-backlog cooldown (mirrors the code-freeze skip). **Dispatch behaviour** — once idle has been continuous for the threshold, the -worker first runs `wl list --status completed --stage in_review --json` and -selects the first completed/in_review item **without** a valid audit that was +worker first runs `wl list --status completed --stage in_review --root-only +--json` and selects the first completed/in_review **root** item (no parent — +child/sub-task items are excluded server-side by `--root-only`, so only +parent items are ever dispatched for audit; WL-0MSTLFW14000KPEC) **without** +a valid audit that was **modified within the last 7 days** (`updatedAt` recency window; a candidate with a missing `updatedAt` is still selected — recency cannot be verified — while an unparseable one is skipped), dispatching `/skill:audit ` (pane @@ -263,6 +379,32 @@ dispatch still governs (fresh → not a candidate). A missing or unreadable log is treated as empty (fail-safe), so audit dispatch keeps working on a fresh worklog. +> **Single-active-audit guarantee (WL-0MT3PHW4I002SNOV):** exactly one +> audit is dispatched at a time during downtime dispatch — strictly +> sequential, never fan-out across candidates. Before the audit tier +> selects a candidate, the worker runs the active-audit check +> (`getActiveAudit`): it reads the shared `.worklog/downtime-dispatches.log` +> (the cross-instance source of truth — an audit dispatched by ANY +> instance, leader or not, is seen here) and keeps only `kind:audit` +> markers dispatched within the last **2 hours** +> (`DOWNTIME_AUDIT_STALE_WINDOW_MS`). A marker older than the window is +> **stale** — the audit pane may have crashed without updating the work +> item — and is ignored, so a NEW audit dispatch can proceed. The non-stale +> marker ids are intersected with a single `wl list --status in_progress +> --json` query: when a marker maps to an item still `in_progress` +> (dispatched but not yet completed/reviewed), an audit is **in flight** +> and the audit tier is skipped, falling through to the implement tier. The +> skip is logged as reason `audit-in-flight` (never `no-candidate`, so the +> empty-backlog cooldown is not entered while an audit runs — mirrors the +> code-freeze skip) and the worker re-checks on the next idle tick. +> **Fail-open:** if the check cannot complete (e.g. worklog query failure), +> the audit tier is skipped and dispatch falls through to the next tier — +> all dispatch is never blocked by an unanswerable check (a check failure +> with an otherwise empty backlog reports a wl-error strike, never a false +> no-candidate). Only the elected leader dispatches audits (unchanged — +> the check lives in the audit-tier dispatch path with no new +> instance-local state, so non-leader instances never dispatch audits). + > **Bounded audit fan-out (WL-0MSORQ1RG005DGUS):** dispatched panes run > with `AUDIT_PHASE2_PARALLELISM=1` in the pane environment (inherited by > the pi process via `send-to-pi.sh`). The audit skill's Phase 2 deep @@ -273,7 +415,11 @@ fresh worklog. > fitting cheap mode's full capacity (2 × 262144 ctx) where the default > fan-out of 2 would need 3 and spill children to remote. Wall-clock > tradeoff: child-heavy audits take longer — acceptable for overnight -> downtime work. Interactive (non-downtime) panes are unaffected. +> downtime work. Interactive (non-downtime) panes are unaffected. (Scope: +> this bounds parallelism WITHIN one audit pane; fan-out ACROSS audit +> dispatches is prevented outright by the single-active-audit guarantee +> above — WL-0MT3PHW4I002SNOV — so at most one `/skill:audit` pane can +> ever be dispatched at a time.) > **Audit-tier error channel (WL-0MSLWJ2KP0002SV0):** the audit lookup > resolves through the same `DowntimeNextResult` error channel as the @@ -286,25 +432,27 @@ fresh worklog. > tier (`{ok:true, candidate:null}`) falls through to the implement tier > exactly as before. -> **Audit-tier selection note (WL-0MSMAIP5F003WAGG):** the audit tier keeps +> **Audit-tier selection note (WL-0MSTLFW14000KPEC):** the audit tier keeps > its `wl list --status completed --stage in_review --json` selection rather -> than converting to `wl next --stage in_review`. `wl next` is strictly -> root-only, so converting would silently drop completed child items from -> audit dispatch (32 children in the completed/in_review queue as of the -> decision); the audit tier must audit the full completed/in_review set, -> including children. The conversion was scoped to the implement tier only -> (AC5 escape hatch — decision recorded). - -If none, it runs `wl next --stage intake_complete +> than converting to `wl next --stage in_review`, but now applies +> `--root-only` so only **parent** items are audit candidates — completed +> children are never dispatched for `/skill:audit` independently (the +> producer reviews deliverable units, and a parent audit covers its +> children). This reverses the earlier WL-0MSMAIP5F003WAGG decision to +> include completed children in the audit tier. `wl next` conversion remains +> scoped to the implement tier only (AC5 escape hatch — decision recorded). + +If no scheduled prompt is due, it runs the **critical-first tier** (see below); +if no critical candidate, it runs `wl next --stage intake_complete --json` and dispatches `/skill:plan `; if no such item it runs `wl next ---stage idea --json` and dispatches `/skill:intake `; if all four are +--stage idea --json` and dispatches `/skill:intake `; if all five are empty nothing is dispatched. A `wl`/CLI error on the `intake_complete` lookup does **not** skip the `idea` lookup — a tier-3 candidate can still dispatch. The item is claimed (`wl update --status in_progress`) *before* the pane spawns, so it appears in-progress immediately and a second pane's `wl next` cannot select it. Panes are named `Downtime audit` / `Downtime implement` / `Downtime plan` / -`Downtime intake`, opened +`Downtime intake` (scheduled prompts: `Downtime `), opened with `--no-focus` (visible, never steals focus), `--cwd ` and `--model `. @@ -322,9 +470,170 @@ named `Downtime implement`). A `wl`/CLI error or empty result at the implement tier is fail-closed (never a candidate) and does **not** short-circuit the plan/intake fallback (AC5/AC6). +**Critical-first tier (WL-0MT3FM8VA005XBHE)** — after the audit tier and +BEFORE the non-critical implement/plan/intake tiers, the worker looks up the +highest-priority open **critical** item at ANY stage via `wl list --priority +critical --status open --json` (which — unlike `wl next` — does NOT exclude +dependency-blocked items) and dispatches it with the stage-appropriate skill: +`idea` → `/skill:intake `, `intake_complete` → `/skill:plan `, and +`plan_complete` → `/skill:implement `. Selection is deterministic +(WL-0MSSRED76008LGB6 round-robin over the `critical` priority group). The +critical tier consults its lookup on EVERY dispatch — including during a +code freeze — and resolves through the same `DowntimeNextResult` error +channel as the audit/plan/intake tiers: `{ok:true, candidate:null}` is a +GENUINELY empty critical tier and falls through to the non-critical tiers; +`{ok:false}` is a `wl`/parse failure — a CLI-error strike, never a silent +fall-through (a broken critical lookup can never masquerade as "no critical +work" and quietly disable the tier). It needs **≥ 1 free slot** (single-pane +tier, parent WL-0MT32F90V008UAD2 AC3). + +Three decision points govern the critical tier (Q1/Q2/Q3): + +- **Freeze split-by-skill (Q1, WL-0MSQ0RPQP00636JY composition)** — while +the code-freeze marker is frozen OR ambiguous (fail-closed), a critical +`plan_complete` (implement-kind) candidate is SKIPPED — no new code changes +land mid-release — but critical `idea`/`intake_complete` (intake/plan-kind) +candidates STILL dispatch: prep work (intake/plan) is low-risk and allowed +during a freeze, exactly matching the non-critical plan/intake tiers. +- **Caps retention (Q2)** — a critical `plan_complete` item dispatches with +`/skill:implement` only when risk ≤ Medium AND effort ≤ Medium (the caps +from the F2 selection guard); an above-caps critical item is not a +valid candidate and the tier falls through. +- **Dependency-frontier dispatch (Q3)** — when the selected critical item is +dependency-blocked (`wl dep list ` outbound `depends-on` edges — a +candidate's blockers), the worker follows the blocking chain to the nearest +OPEN blocker and dispatches THAT blocker with its OWN stage-appropriate +skill (intake/plan/implement). The chain bottoms (blocker closed, +non-dispatchable stage, above-caps, or a cycle — bounded) → no frontier +candidate → the tier falls through to the non-critical order. + +The critical dispatch flows through the same `dispatchClaimedTier` pipeline +as every other tier — CAS claim (with the stage-appropriate `TIER_EXPECTED` +entry, stale-claim aborts), dispatched-marker write before spawn, then the +pane — so the claim-CAS, dispatched-marker change-guard, and single-flight +guards compose unchanged. The rolling-log kind is the skill-mapped +`implement`/`plan`/`intake` (never a distinct critical kind), keeping the +change-guard and observability consistent. + +**Round-robin tie-break (WL-0MSSRED76008LGB6)** — within each dispatch tier, +candidates sharing the **same priority level** (critical/high/medium/low — +the grouping key, not sortIndex) are rotated round-robin using a shared, +durable cursor persisted at `.worklog/downtime-round-robin.json`. The cursor +is advanced (and persisted) on each selection, so repeated dispatches of a +tied group never always pick the same item, and two herdr instances selecting +from the same group pick different items once the cursor has advanced. The +file is the source of truth for cross-instance coordination — each selection +re-reads it. Fail-open conventions: a missing, unreadable, or corrupt cursor +file degrades to the pre-rotation sortIndex order (no rotation), and a +cursor-write failure is tolerated (the dispatched-marker in +`.worklog/downtime-dispatches.log` remains the authoritative guard against +re-dispatch). A single-member priority group needs no rotation. + +**Probe jitter (WL-0MSSRED76008LGB6)** — the downtime task's effective poll +interval is jittered ±50% of the configured `downtimePollIntervalMs` on every +reschedule (the scheduler's `getIntervalMs` hook recomputes a fresh value per +tick), so two instances with identical configuration do not probe in +lockstep — other machines get a fair chance to win the dispatch race. The +jitter factor is clamped to `[0.5×, 1.5×]` of the configured interval. + +### Mode-switch worker (activity-gated proxy mode switching, WL-0MSN3FWV5008KQE9) + +The mode-switch worker automatically switches the llama-proxy between +**fast** (cloud) and **cheap** (local) modes based on operator presence and +proxy idle state: + +- **Fast on command (fire-and-forget, fail-open)** — an operator dispatching + an **agent-route** command (`/skill:*`, `/intake`, `/plan`, `/prompt:`) + records operator activity and triggers a switch to fast mode via `POST + {proxyUrl}/admin/set-mode` when the proxy is not already in fast mode. + The POST is fire-and-forget and cannot block command dispatch (fail-open), + so a slow or unresponsive proxy never delays the operator. +- **Cheap on idle (fail-closed)** — once the operator has been inactive for + `modeSwitchIdleThresholdMs` **and** the proxy reports idle (via backend + `GET {proxyUrl}/llama/local/status`; no local query, no model switch, no + active lease), the worker POSTs `mode=cheap`. Any ambiguity — an + unparseable status payload, a non-2xx admin response, or a fetch failure — + is treated as busy (fail-closed) so the proxy is never switched cheap + while real work might be in flight. The single-flight task means a hung + tick can never wedge the task; the proxy URL reuses `downtimeProxyUrl`. +- **Restart resets to active now** — on plugin/pane restart the idle clock + starts from the worker's construction time, so a fresh pane begins with a + full idle window before any cheap switch is eligible. + +New settings (all optional): + +- `modeSwitchEnabled` — Enable the mode-switch worker (default: `true`; + when `false` the scheduler registers no mode-switch task and the + agent-route hook is a no-op) +- `modeSwitchIdleThresholdMs` — Operator-inactivity window before a cheap + switch is considered (default: `900000` = 15 minutes, hard floor `60000`) +- `modeSwitchPollIntervalMs` — Poll interval for the proxy idle check when + evaluating the idle window (default: `10000`, clamped to `[5000, 60000]`) + +The proxy URL is **not** a new setting — it reuses `downtimeProxyUrl`. + +**Coexistence with the proxy's built-in time schedule** — the plugin's +switches are manual overrides (LP-0MSMF25V9002AY1J semantics): they set the +proxy mode immediately but do not change or disable the proxy's own +scheduled switching (e.g. cloud hours). The proxy's schedule reclaims control +at its next boundary — a documented behavior, not a conflict. The worker +deduplicates by querying the current mode before switching, so it never issues +redundant `set-mode` calls. + +**Scheduled prompts (WL-0MSS1Q5ER007QDKX)** — the FIRST dispatch stage: a +project-local config file `.worklog/scheduled-prompts.json` (provisioned by +`wl init` from `templates/scheduled-prompts.json`, create-if-absent) carries +periodic maintenance prompts with a best-effort frequency. The base set is a +single `/skill:refactor` entry (`intervalDays: 3`, `lastTriggeredAt: null`), +so refactoring runs automatically at most every three days during idle time +without manual triggering. Each entry has: + +- `id` — stable entry id (pane name `Downtime ` and rolling-log marker + `itemId`), +- `prompt` — any text the pi agent pane can run (e.g. `/skill:refactor`), +- `intervalDays` — best-effort frequency in whole days, +- `lastTriggeredAt` — ISO-8601 UTC datetime of the last dispatch + (`null` = never run; a missing field is treated as due). + +While the proxy is idle long enough, the dispatcher checks scheduled prompts +FIRST (before the audit/implement/plan/intake tiers). An entry is **due** iff +`lastTriggeredAt` is `null` or `now - lastTriggeredAt >= intervalDays` — best +effort: a dispatch may be delayed (no idle slot, cooldown, freeze) but never +happens more often than the frequency. A due prompt dispatches a pi agent +pane named `Downtime ` via the same send-to-pi.sh path as the tiers +(`--no-focus`, `--cwd `, `--model `) running the +prompt text. Multiple due entries dispatch one per idle slot in config order +(the single-flight guard prevents overlap). + +Fail-closed at every boundary: an **absent** config is an empty set (logged +notice — `wl init` is the provisioning path, no defaults are synthesized), +and a **malformed** config (unreadable, corrupt JSON, no `entries` list) is an +empty set (logged error) — in both cases no scheduled dispatch occurs and the +existing tiers are unaffected. Invalid entries (missing/empty id or prompt, +missing/non-finite/≤ 0 `intervalDays`, unparseable `lastTriggeredAt`) are +skipped with a logged warning — never a crash. + +Before the pane spawns, `lastTriggeredAt` is persisted to the config (atomic +tmp+rename write, re-reading the file first) and a `kind: scheduled` marker is +written to `.worklog/downtime-dispatches.log` (no work-item comment or claim +— there is no work item). If either write fails, the dispatch ABORTS before +the spawn (an unrecorded dispatch never runs) and the entry stays due for the +next idle slot. A failed pane spawn appends an `outcome: 'spawn-failed'` +trace like the other tiers (the marker + persisted trigger stand; the entry +is not re-selected until its frequency elapses). + +Scheduled prompts respect the same **code-freeze gate** as the audit/implement +tiers: while the marker is frozen **or ambiguous** (fail-closed), scheduled +prompts are skipped — no new code changes land mid-release — and dispatch +falls through to the plan/intake tiers unchanged. A due prompt dispatches +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. + **Empty-backlog cooldown** — when the implement, plan, and intake `wl next` -lookups genuinely return no candidate (the tab's project has nothing to -dispatch), the worker enters a full **pause** for `downtimeNoCandidateCooldownMs` +lookups and the critical-tier lookup genuinely return no candidate (the +tab's project has nothing to dispatch), the worker enters a full **pause** +for `downtimeNoCandidateCooldownMs` (default 60 minutes): no proxy polling, no idle tracking, and no dispatch until the pause expires — so it stops burning cycles (proxy polling + `wl` spawns) during empty periods. @@ -339,10 +648,15 @@ the next cooldown entry without a plugin restart. ship-it code-freeze marker (see [Code Freeze](#code-freeze)): the marker is re-read **fresh on every dispatch attempt** (never cached), so a freeze that starts or ends mid-idle-period is honored on the next dispatch. While the -marker is **frozen or ambiguous** (fail-closed), the audit and implement tiers -are skipped — no new audits or implementation work starts during a release — -and dispatch continues with the plan/intake tiers, which are low-risk prep -work. A freeze skip is never treated as an empty backlog: it reports reason +marker is **frozen or ambiguous** (fail-closed), the audit and non-critical +implement tiers are skipped — no new audits or implementation work starts +during a release — and dispatch continues with the plan/intake tiers, which +are low-risk prep work. The critical tier is **still consulted** during a +freeze, but with the split-by-skill rule (Q1): a critical `plan_complete` +(implement-kind) candidate is skipped while critical `idea`/`intake_complete` +(intake/plan-kind) candidates still dispatch — critical prep is as low-risk +as non-critical prep and stays allowed (see *Critical-first tier* above). A +freeze skip is never treated as an empty backlog: it reports reason `code-freeze` (never `no-candidate`), so it does **not** trigger the no-candidate cooldown — polling continues and implement/audit dispatch resumes immediately when the freeze lifts. The implement skill's own @@ -379,7 +693,8 @@ directory. if it cannot proceed because it needs answers, record the questions in a comment on the work item (`wl comment add --comment ...`) and mark the item as needing producer review (`wl update --needs-producer-review -true`), then stop — it never blocks indefinitely. +true`), repeat the questions in the final summary, then stop — it never +blocks indefinitely. **No lock file (cross-pane decision Q5)** — concurrent panes are serialized by the idle→busy cadence (a dispatch consumes the local slot, so the proxy @@ -396,7 +711,9 @@ SQLite layer (`BEGIN IMMEDIATE`). **Audit trail** — every successful dispatch records two traces: (1) a comment on the dispatched item (`wl comment add`, author `herdr-downtime`) stating the automatic dispatch, the skill run, and the -UTC timestamp — this survives `wl sync` and is the durable trail; and (2) a +UTC timestamp — this survives `wl sync` and is the durable trail (scheduled +prompts have no work item, so their trace is the rolling-log marker only — +AC4, WL-0MSS1Q5ER007QDKX); and (2) a bounded JSONL entry in `.worklog/downtime-dispatches.log` under the resolved worklog root (rolling — only the most recent 100 entries are kept). The marker is written **before** the pane spawns (fail-closed: an @@ -413,6 +730,8 @@ for the plan/intake change-guard — an item already dispatched for its tier is excluded while it is still at its dispatched-at stage, and a stage advancement releases it (RCA WL-0MSRBFFLN005W3VT design point 3). Plan / intake markers are scoped to their own tiers and never suppress audit +selection. `kind: scheduled` entries are log-only (no work item) and are +scoped to the scheduled-prompts tier — they never suppress any worklog-tier selection. A three-strike CLI-error pause additionally writes a JSONL entry to the same rolling log (with the `at` timestamp and an error message) so the persistent failure is auditable even though nothing was dispatched. The @@ -445,6 +764,12 @@ failed claim or spawn can no longer produce a false success record. These were closed by WL-0MSRDEWES0059TZN (implement RCA fix design): +- **Disable honesty + durability** (see + [docs/downtime-disable-incident-rca-2026-08-22.md](docs/downtime-disable-incident-rca-2026-08-22.md)) + — a `d`-disable now persists across restarts via the + `.herdr-downtime-disabled` marker (restored header notice `[Downtime Off (restored)]`), + and the toggle cycle can never force-enable (WL-0MT4BWUHW008LIFE). + - **Same-instant cross-pane race (closed)** — the pre-dispatch claim is now a compare-and-swap (`--if-status`/`--if-stage`) executed atomically at the SQLite layer, so exactly one pane wins; a losing pane aborts with no pane, @@ -470,8 +795,9 @@ The worker runs inside the plugin's single consolidated scheduler loop (one `setInterval`; no independent timers), uses unref'd timers, and is cleaned up when the pane exits. While the pane is open the list header shows the worker state, e.g. `[⏳ downtime idle 3:12]`, `[downtime busy]`, -`[⏳ downtime dispatching]`, `[downtime disabled]`, or `[downtime paused]` -(no-candidate cooldown). +`[⏳ downtime dispatching]`, `[Downtime Off]` (dispatch disabled for the +pane — toggled off via `d` or the global setting off), or +`[downtime paused]` (no-candidate cooldown). ### Pause-when-hidden (pane visibility gating) @@ -487,10 +813,19 @@ process churn and memory pressure (WL-0MSB1N0HB0007N6N). regardless of which pane in the tab holds keyboard focus (so a visible worklist pane in a multi-pane split keeps refreshing while an adjacent pane holds focus). +- **Event-driven path (herdr ≥ 0.7.5, WL-0MSHB7DHO004RHBJ)** — when the + herdr socket is reachable, the plugin subscribes to window events + (`pane.focused`, `pane.agent_status_changed`, `pane.agent_detected`, + `pane.closed`, `pane.exited`) via `events.ts`. A `pane_focused` event for + the current pane updates the visibility gate **immediately** (no `herdr + tab get` exec) and triggers an instant refresh on the hidden → visible + transition. The 2s resume-poll becomes a fallback that runs only while + the event path is unavailable. - **Fail-open** — when visibility cannot be determined (no `HERDR_TAB_ID` - env, herdr CLI missing/erroring, unparseable output) the pane is treated - as visible and polling proceeds exactly as before. Standalone runs (outside - Herdr) are unaffected. + env, herdr CLI missing/erroring, unparseable output, or the event + subscription unavailable) the pane is treated as visible and polling + proceeds exactly as before. Standalone runs (outside Herdr) are + unaffected. Socket errors never crash the TUI. - **Cadence unchanged when visible** — while the pane's tab is focused (or fail-open), auto-refresh/auto-sync keep their existing intervals (30s / 60s defaults). @@ -498,16 +833,18 @@ process churn and memory pressure (WL-0MSB1N0HB0007N6N). shows `[paused — hidden]` so operators can tell gating is active; the indicator clears as soon as the list refreshes after the tab regains focus. -- **Immediate refresh on resume** — while the pane's tab is hidden a - lightweight resume poll (2s interval, `herdr tab get` only — never `wl`) - watches for the hidden → visible transition; the moment the tab regains - focus the list re-fetches immediately (with a "Refreshed" notification) - instead of waiting for the next 30s tick, then the normal cadence resumes. +- **Immediate refresh on resume** — while the pane's tab is hidden and the + event path is unavailable, a lightweight resume poll (2s interval, + `herdr tab get` only — never `wl`) watches for the hidden → visible + transition; the moment the tab regains focus the list re-fetches + immediately (with a "Refreshed" notification) instead of waiting for the + next 30s tick, then the normal cadence resumes. - **Never gated** — manual actions (navigation, shortcut chords, the initial data load) work regardless of tab visibility. - **Shared visibility check** — the `PollGate` TTL memoizer (~2s) makes the refresh and sync ticks in one cycle share a single `herdr tab get` call - (≤1 visibility exec per cycle). + (≤1 visibility exec per cycle). Event-driven updates reuse the same gate + via `PollGate.setVisibleFromEvent()`. - **Zoom-over limitation** — a pane zoomed-over within a focused tab is treated as visible (tab focus is the sole signal; there is no pane-focus fallback) and keeps refreshing. Zoom-over detection is out of scope for @@ -536,16 +873,16 @@ Example: with `browseItemCount=15`, 2 critical + 3 completed/in_review + first 10 others (15 total). If there were 20 completed/in_review items instead of 3, all 22 mandatory items would be shown (22 > 15). -The **stage-filtered** views (press `f` + stage chord, or `/wl `) show -every root item in the selected stage matching the stage's status rule -(WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW): +The **stage-filtered** views (press `f` + `s` + stage chord, or `/wl +`) show every root item in the selected stage matching the stage's +status rule (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW): - Most stages show **every open** item (`status=open`) in the stage — no `browseItemCount` cap and no `wl next` selection omission, so items the priority algorithm deprioritises are still visible. Items with status `blocked`, `in-progress`, or `completed` are excluded even when their stage matches. -- The **in_review** stage (press `f` + `r`, or `/wl review`) is the +- The **in_review** stage (press `f` + `s` + `r`, or `/wl review`) is the exception: it shows items with status `completed`, `in-progress`, or `open`. Per the project workflow, advancing an item to `in_review` sets its status to `completed` (or leaves it `in-progress` while being @@ -556,24 +893,81 @@ every root item in the selected stage matching the stage's status rule - Results follow the standard list order (sortIndex), matching the unfiltered view. -The default (unfiltered) worklist is unaffected — `/wl` with no stage -argument keeps the smart-selection behaviour described above. +The **priority-filtered** views (press `f` + `p` + priority chord, or +`/wl --priority `) mirror the stage-filter semantics +(WL-0MSKC8T46006999S): every **open** root item (`status=open`) with that +priority — no `browseItemCount` cap, no `wl next` selection omission, +children hidden and reachable via expand, standard sortIndex order. The +stage and priority filters are mutually exclusive (replace semantics), so +only one axis is ever active; the header shows which one +(`(filtered: stage )` or `(filtered: priority )`). + +The default (unfiltered) worklist is unaffected — `/wl` with no +stage/priority argument keeps the smart-selection behaviour described +above. The "top N of M" header reflects the **actual displayed count** (N), which may exceed `browseItemCount` when the mandatory set is large. -## Metadata panel +### Fold indicators (below-the-fold / above-the-fold markers) + +When the display-rows list (headings + items) has more rows than fit in +the visible list area (header + rows + footer), the renderer shows two dim +markers so you can tell the list is truncated or scrolled +(WL-0MSG8YXYJ008PWJJ): + +- **`▼ more`** — shown at the bottom of the items region whenever items +exist below the fold (the visible window does not reach the end of the +list). At scroll position 0 with a long list, this marker always appears. +- **`▲ more`** — shown at the top of the items region whenever the list is +scrolled down (`scrollOffset > 0`), i.e. items exist above the current +viewport. + +Both markers are **display-only rows**: they are dim-styled (consistent +with other non-interactive chrome such as footer hints and the filter +indication), are never selectable, and do not consume navigation state, +selection index, or hierarchy scroll offset — `j`/`k` scrolling behaves +exactly as before. The marker wording is intentionally generic ("more") +rather than an exact count; each group heading shows its own per-group +count. + +Indicator rows are included in the render budget, so the `rows - 1` +line-count invariant (the notification line always fits, the header never +scrolls off the top) holds with indicators active. Edge case: when the +bottom indicator row reservation trims the window so the list fully fits, +the `▼ more` marker is omitted — the list then shows every remaining item +without an indicator. -The list view reserves the bottom rows of the pane for a metadata panel -showing the **selected** item's fields. The panel is always on: the list -area shrinks to `rows - 1 - panelHeight`, and `panelHeight` scales linearly -from 20% of the pane height (on short panes) up to 40% (on tall panes), -clamped to a minimum of 3 rows so it is always usable. +## Metadata panel -- The panel shows the item ID as a header separator, followed by its +The list view shows the **selected** row's metadata in a panel below the +list. The selection list takes as much vertical space as its content needs +— header + display rows (headings + items) + fold indicators + footer — +up to the full available pane height. The metadata panel fills whatever +space remains: when the list is short the panel expands into the leftover +space; when the list fills the pane the panel keeps a minimum of 3 rows at +the bottom (its content scrolls independently, see below) so it stays +usable. The view always starts at the top of the selection list (header + +first rows), so a long list never scrolls off the top. + +- Selecting a **heading row** renders the group's info — label, item + count, group number, and collapse state (with a hint that Tab toggles + it) — instead of item metadata; heading selections have no item, so the + detail view is never opened from them. +- Selecting an **item row** renders the item's fields: metadata (status, stage, priority, type, risk, effort, children/parent counts, tags, GitHub issue number, created/updated timestamps, and audit state). +- Metadata values are rendered as **icon + text label** using the same icon + helpers as the list rows, so the two sections can never diverge: e.g. + `🔄 in_progress`, `⭐ high`, `📥 intake_complete`. The Stage row mirrors + the list's audit-aware `in_review` icon (✅/❌/❓ when the audit is fresh, + ⏳ when stale-but-passed, 🔍 otherwise); the Type row shows the epic icon + (⊙) for `epic` items only (other types stay text-only); and the Audit and + Reviewed rows pair their icons with text labels (`✅ ready to close`, + `❌ needs review`, …). When icons are disabled (`showIcons: false`) every + metadata value falls back to **plain text** — no emoji and no bracketed + fallbacks (unlike the list rows, which use `[OPEN]`-style labels). - When the item's description has a `Key Files:` section containing one or more `.md` paths, the panel also shows a **`Related Docs`** row listing every markdown path (joined with `, `; long values are truncated to the @@ -604,8 +998,13 @@ lifecycle stages only — `idea`, `intake_complete`, `plan_complete`, `in_progress`, `in_review`, `done`. No custom stage values are required for grouping, so podcast episode items group exactly as their frontmatter `pipeline_stage` maps 1:1 onto the Worklog stages (PRD §7.2). Groups render -in the canonical order (Critical → Group N → Idea → Other → In Review) with -group separators in the list; stage changes re-group items on the next +in the canonical order (Critical → Group N → Idea → Other → In Review) as +**collapsible heading rows** (WL-0MSL5MPSZ003TG94): each heading shows the +group label plus its item count (`── Group 1 (4) ▼ ──`), and pressing **Tab** +while a heading is selected collapses/expands that group — collapsed groups +hide their items from both the render and navigation (the heading itself +stays). Counts reflect the top-level items in the group (post stage-filter) +regardless of collapse state. Stage changes re-group items on the next refresh. Non-critical `in_progress` items join the file-path-partitioned `Group N` lists alongside `plan_complete`/`intake_complete` items and sort ahead of them (actively-worked items first); "Other" remains only as a @@ -765,6 +1164,11 @@ When a chord shortcut resolves to a command that contains **unknown identifiers* - One labeled input field per unknown identifier; `Tab`/`↑`/`↓` navigate between fields, `Enter` submits, `Esc` cancels. - Identifiers may declare an inline default: ``. The field is pre-filled with the default (which is used verbatim if you submit without editing it), and you can clear it or type over it before submitting. - The active field shows a block cursor at the end of its value; the typed value is substituted into the command on submit (`` remains auto-substituted with the selected item's ID). +- **`Ctrl+V` pastes** the OS clipboard into the active field — newlines (`\n` / `\r\n`) are preserved verbatim and **do not** submit the form. Pasting reads the clipboard asynchronously (the TUI never freezes); if no clipboard reader is available the paste fails gracefully with a visible message and the form stays open. +- **`Ctrl+X` cuts** the whole active-field value to the OS clipboard and clears the field, with a confirmation toast. If the copy fails, the field value is restored so nothing is lost. +- **`Ctrl+Enter` inserts a newline** into the active field (for composing multi-line values) while plain `Enter` still submits the form. +- **Bracketed-paste** sequences (`ESC [ 200 ~ … ESC [ 201 ~`) arriving via any paste path are unwrapped so the inner content is inserted cleanly — no control-sequence characters, no accidental submit or navigation. The form tracks the open/close markers across delivery even when the terminal sends them character-by-character. +- Multi-line field values **wrap readably** within the full-pane page layout (wrapped display, page height honored, no layout breakage). - The form renders as a **full-pane page layout** — no border box, no centering: content starts at the top-left corner of the pane and wraps at the full pane width. - The description and field values **wrap at the full pane width**; as a value wraps to more lines the form **expands downward**, bounded by the terminal height so it never overflows the pane. @@ -796,9 +1200,10 @@ packages/herdr/ │ ├── auto-sync.ts # Background `wl sync` with configurable timer │ ├── shortcut-config.ts # Chord shortcut registry and config loader │ ├── shortcuts.json # Shortcut/chord definitions -│ ├── icons.ts # Icon and colour helpers +│ ├── (icons) # Icon & colour helpers via @worklog/shared/icons (packages/shared/src/icons.ts) │ ├── code-freeze.ts # Code Freeze marker detection (fail-open) -│ ├── form-dialog.ts # Form state + rendering for parameter input (unknown ) +│ ├── form-dialog.ts # Form state + rendering for parameter input (unknown , paste/cut/newline) +│ ├── clipboard.ts # OS clipboard read/paste + write/copy helpers (no tmux branch) │ ├── ship-it-dialog.ts # Ship It typed-confirmation dialog (bottom-anchored, S shortcut) │ ├── md-viewer.ts # Generic markdown viewer + inline [NOTE : ...] link rendering │ ├── md-note-edit.ts # Inline-note marker edit helpers (insert/update/remove, LOCAL-, §7.3 sync) @@ -807,7 +1212,7 @@ packages/herdr/ │ └── worklist.ts # List state, rendering, keyboard handling, command output ├── scripts/ │ ├── open.sh # Open the worklist pane -│ ├── open-podcast-editor-tab.sh # Open the worklist pane in a tab renamed "Podcast Editing" +│ ├── open-podcast-editor-tab.sh # Open the worklist pane in a tab renamed "Worklog" │ ├── toggle.sh # Toggle the worklist pane │ ├── send-to-pi.sh # Split pane to right, launch pi with agent command (via run-pi-agent.sh) │ ├── run-in-pane.sh # Run a shell command visibly in a new pane (stays open for inspection) @@ -819,27 +1224,29 @@ packages/herdr/ - **No direct database access** — The plugin uses the `wl` CLI as the backend data source, ensuring compatibility without duplicating data-access logic. - **Terminal UI via raw mode** — The TUI uses raw stdin mode and ANSI escape codes for rendering, making it compatible with any Herdr pane without additional dependencies. -- **Fixed-height pane rendering** — The list renderer budgets its output to `rows - 1` lines (header + items + group separators + fill + footer), reserving the last row for the transient notification line (e.g. `[Synced]`, `[Refresh failed]`). The active stage filter is shown in the header only (` (filtered: )`) — there is no standalone filter bar or blank chrome row. Group separator lines count against the budget, so the pane never scrolls the header or top items off the top of the view regardless of item/group count (see WL-0MSAAON63003N6LO, WL-0MSGTSPXK007POB1). +- **Fixed-height pane rendering** — The list renderer budgets its output to `rows - 1` lines (header + display rows (headings + items) + fill + footer), reserving the last row for the transient notification line (e.g. `[Synced]`, `[Refresh failed]`). The active stage filter is shown in the header only (` (filtered: )`) — there is no standalone filter bar or blank chrome row. Heading rows count against the budget, and the below/above-the-fold `▼ more`/`▲ more` indicator rows (WL-0MSG8YXYJ008PWJJ) are reserved in the budget before the visible window is trimmed, so the pane never scrolls the header or top items off the top of the view regardless of row count (see WL-0MSAAON63003N6LO, WL-0MSGTSPXK007POB1). - **Testable core** — All state management, formatting, and keyboard handling is pure logic in `worklist.ts`, fully testable without a terminal. - **Toast notifications instead of bottom-line status** — Transient status feedback (refresh outcomes, sync outcomes, sent/skipped command feedback, errors) is surfaced via Herdr toast notifications (`herdr notification show`) instead of being appended to the bottom of the pane output. This keeps the rendered pane within the terminal height budget, so the list header and top lines are never pushed off the top of the pane. Toast delivery requires `ui.toast.delivery = "herdr"` in `~/.config/herdr/config.toml`; toasts appear in the bottom-right corner by default. The helper lives in `notify.ts` and is fire-and-forget (failures are tolerated silently). - **Command routing via callback** — When a chord resolves to a non-`/wl` command, it is passed to an `onCommand` callback (set by the entry point) which routes it by prefix: - `!!`/`!` prefixed commands (shell-executed shortcuts such as audit approve/reject, priority updates, close/delete) are run **visibly in a new herdr pane** via `scripts/run-in-pane.sh` — the wrapper keeps the pane's process alive so the pane stays open (exit status reported; dismiss with Enter or close with `prefix+x`) so the user can inspect the command output. - Everything else is written to stdout with a `CMD:` prefix for the calling framework (Herdr) to execute. -- **Pi agent dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are intercepted by the entry point and routed to a new pi agent pane. The `send-to-pi.sh` script splits the current pane to the right, creates a new pane, runs `pi` with the command as the initial prompt, and renames the pane to "Pi Agent". Agent commands are routed before any prefix handling, so they are unaffected by `!!`/`!` processing. +- **Selection-list dispatch keeps focus** — Every pane spawned from the worklist selection list (pi agent panes via `send-to-pi.sh`, and command-output panes via `run-in-pane.sh`) opens **without moving focus** (WL-0MSHIA53D009DJOT): the dispatch passes `--no-focus` to both launchers, so the final zoom/focus step is skipped and the selection list keeps the keyboard focus. The user can read dispatch feedback via toasts and inspect the opened pane with herdr pane navigation (`prefix+o`, `prefix+x` to close). Out of scope and unchanged: the downtime worker (already `--no-focus`), the `open-pi-agent` unbound plugin action, and `open.sh`/`toggle.sh`. +- **Pi agent dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are intercepted by the entry point and routed to a new pi agent pane. The `send-to-pi.sh` script splits the current pane to the right, creates a new pane, runs `pi` with the command as the initial prompt, and renames the pane to "Pi Agent". The dispatch passes `--no-focus` (selection-list dispatch keeps focus, see above), so the new pi pane does not steal focus from the list. Agent commands are routed before any prefix handling, so they are unaffected by `!!`/`!` processing. +- **No-pane dispatch via `open_pane: false`** — A shortcut entry may carry an optional `open_pane: false` flag (WL-0MSJLD1I70045ZUL) to run its command **in the background without opening a pane**: shell (`!!`/`!`) commands execute via detached `bash -c` and agent commands run headless (`pi -p --mode json`, honoring the entry's `model`), with stdout/stderr captured to a per-run log file under `/herdr-background-logs/` (the path is written to stderr so it can be located for inspection). No pane is created, so the work-item ↔ pane association is skipped for agent commands. The bundled quiet state-change shortcuts use it: `a-y` audit approve, `a-r` audit reject, `u-p-*` priority updates, and `x-c`/`x-d` close/delete — they complete silently and the worklist refresh shows the updated state. Shortcuts without the flag (all other bundled entries) open a pane exactly as today. - **Model lease release on pane close** — Pi agent panes launched by `send-to-pi.sh` or `open-pi-agent.sh` run pi via `shared/run-pi-agent.sh`, which gives the session a deterministic id (`pi --session-id herdr---`) and registers EXIT/TERM/HUP/INT traps. When the pi session ends — normal exit or pane close (`prefix+x`) — the wrapper runs `shared/release-lease-on-exit.mjs`, which posts to the Local Proxy's `POST {baseUrl}/leases/release` using the **same shared implementation** as the Pi extension (`@worklog/shared/lease-release`), so the proxy's dispatch lease is reclaimed promptly instead of lingering until timeout. The release is strictly best-effort: failures (unreachable proxy, missing `~/.pi/agent/models.json`, unconfigured provider) are silently discarded, a 5s request timeout bounds the pane-close path, and the wrapper always propagates pi's exit status (WL-0MSGI7UIH008USVB). -- **Podcast Editing tab naming** — `herdr plugin pane open` creates tabs with generated numeric labels. The `open-podcast-editor-tab` action wraps the same pane-open command and renames the created tab to "Podcast Editing" via `herdr tab rename` (socket API, not session-state editing), so podcast production is instantly recognisable in the tab row. Each press still opens a new tab; only the label changes. +- **Worklog tab naming** — `herdr plugin pane open` creates tabs with generated numeric labels. The `open-podcast-editor-tab` action wraps the same pane-open command and renames the created tab to "Worklog" via `herdr tab rename` (socket API, not session-state editing), so the worklog pane is instantly recognisable in the tab row. Each press still opens a new tab; only the label changes. - **Model selection per shortcut** — Each LLM-bound shortcut entry in `shortcuts.json` may carry an optional `model` field (a pi model pattern such as `plan`, `code`, or `author`). When the command is dispatched to the agent channel, `--model ` is forwarded to the spawned `pi` CLI (e.g. `pi --model code '/skill:implement '`), so every workflow runs on an appropriately specialised model without manual model switching. Agent-bound entries without a `model` field default to `plan`; shell (`!!`) and `/wl` filter entries never carry a model and never receive a `--model` flag. The default mapping in `src/shortcuts.json`: `/plan`, `/intake`, `/skill:audit`, `/prompt:` → `plan`; `/skill:implement` → `code`. - **Free-form prompts via `/prompt:`** — Commands starting with `/prompt:` are also routed to the agent pane, but the `/prompt:` routing prefix is stripped before `send-to-pi.sh` runs, so pi receives only the bare prompt text (e.g. `pi "What are the audit gaps reported in the most recent audit for WL-123"`). This lets a chord shortcut open a new pi instance with an arbitrary injected prompt, not just a skill/workflow invocation. The `P-p` chord opens the command input form so you can type any free-form prompt, `P-a` opens pi with `What are the audit gaps reported in the most recent audit for ` (the selected item's ID is substituted automatically), and `P-n` opens a brand-new blank session (`/prompt:` with an empty prompt — no form dialog, no injected text, and no work-item association). Edit `src/shortcuts.json` to bind your own prompt text to any free chord. - **Correct project directory for new panes** — Panes created by `send-to-pi.sh`, `open-pi-agent.sh`, and `run-in-pane.sh` are started in the correct project root. Herdr's `follow` CWD policy would otherwise inherit the source pane's CWD (the plugin directory), so each script resolves a target CWD (`--cwd` arg > `HERDR_RESOLVED_CWD` > `$PWD`) and applies it in both launch modes: `--no-resize` passes it to `herdr pane split --cwd`, and the default resize mode forwards it to `grid.py --cwd` which includes it in the `pane.split` RPC params. The entry point passes the resolved worklog root (`wlRoot`) so skills, `wl` commands, and relative paths operate on the user's project rather than the plugin's installation directory. - **`` placeholder resolution** — Before output, any `` placeholders in the resolved command are replaced with the currently selected work item's ID. If no item is selected and the command requires ``, the command is silently dropped (graceful no-op). -- **Parameter input form** — Chord commands containing unknown `` placeholders open a modal input form (`form-dialog.ts`) before dispatch. The form renders as a **simple full-pane page**: no border or centering decorations, content starts at the top-left of the pane, and the description and field values wrap at the full pane width — bounded by the terminal height (see WL-0MSFZUS4Z006IRI3). -- **Chord shortcut system** — Multi-key chord sequences are defined in `shortcuts.json` and resolved via `ShortcutRegistry`. Chords can be filtered by view (list/detail), stage, and work-item issue type. Entries may carry an optional `model` field (see **Model selection per shortcut** above). +- **Parameter input form** — Chord commands containing unknown `` placeholders open a modal input form (`form-dialog.ts`) before dispatch. The form renders as a **simple full-pane page**: no border or centering decorations, content starts at the top-left of the pane, and the description and field values wrap at the full pane width — bounded by the terminal height (see WL-0MSFZUS4Z006IRI3). The form supports **OS-clipboard paste (`Ctrl+V`), whole-field cut (`Ctrl+X`), and newline insertion (`Ctrl+Enter`)** plus **bracketed-paste unwrapping**, all via the herdr-local `clipboard.ts` helper (no tmux branch) so pasted multi-line text never submits the form (see WL-0MSW6KCTA0092DCV). +- **Chord shortcut system** — Multi-key chord sequences are defined in `shortcuts.json` and resolved via `ShortcutRegistry`. Chords can be filtered by view (list/detail), stage, and work-item issue type. Entries may carry an optional `model` field (see **Model selection per shortcut** above) and an optional `open_pane` flag (see **No-pane dispatch via `open_pane: false`** above). - **Project-local shortcut overrides** — A consumer project can add chords or override bundled defaults **without editing the plugin bundle** by placing a `shortcuts.json` at its **worklog root** (the project root resolved via `configureWorklogTarget`; the plugin reads `/shortcuts.json` when it exists). Semantics: - The bundled `src/shortcuts.json` is loaded first and remains the base config; a local entry with the **same `chord` + `view`** replaces the bundled entry, while local entries with new chords are appended. - The merge is **deterministic and deduplicated** (dedup key = `view` + `chord`); within the local file, later entries win for the same `view`+`chord`. - Local entries are validated with the same rules as bundled entries: an invalid entry is **logged and skipped**, and a missing/malformed local file (bad JSON, non-array) falls back to bundled-only with an error logged — a broken local file never crashes the plugin. - When no worklog root is resolved (uninitialized project) or no local file exists, the registry is exactly the bundled defaults and all dispatch behaviour is unchanged. -- **Metadata panel** — The bottom of the list view is reserved for the selected item's metadata (`formatMetadataPanel` in `worklist.ts`). The panel height ramps linearly with pane height (20% → 40%, min 3 rows) via `computeMetadataPanelHeight`, and the panel scrolls independently (`m`/`M`) with a scroll indicator. Row building is shared with the detail view via `buildMetaRows`, so the two views never drift apart (see WL-0MSAYNVBY006LM9X). +- **Metadata panel** — The selected item's metadata is shown in a panel below the list (`formatMetadataPanel` in `worklist.ts`). The list takes up to the full pane height and the panel fills the remaining space (minimum 3 rows), computed via `computeDynamicLayout` (WL-0MSQ44MDX008U69J); the panel scrolls independently (`m`/`M`) with a scroll indicator. Row building is shared with the detail view via `buildMetaRows`, so the two views never drift apart (see WL-0MSAYNVBY006LM9X). - **Command log** — Dispatched commands targeting a work item are recorded in `command-log.ts` before execution; the panel surfaces the last command for `in_progress` items. Recording is fire-and-forget (never breaks dispatch) and the log file is written atomically (see WL-0MSAYNVBY006LM9X). ## Code Freeze @@ -883,7 +1290,7 @@ Fail-open is deliberate: a broken or missing marker must never block browsing th - **Implement shortcut hidden** — The `i` / `/skill:implement` shortcut in `shortcuts.json` carries `"code_freeze": "block"`, so while a freeze is active it is filtered out of the shortcut registry: it does not appear in the footer/chord help hints and pressing it does nothing (no dialog, no dispatch). See [Shortcut filtering during a freeze](#shortcut-filtering-during-a-freeze). - **Implement commands blocked** — Any implement command (`/skill:implement`, `/skill:implement-single`, `/skill:implementall`, via single-key `i`, chord, or typed dispatch) is **not** routed: no pi agent pane is spawned, no work item is claimed, and no `` substitution happens. The marker is re-read at dispatch time, so a freeze that starts between refreshes is still enforced. - **Notice dialog** — When an implement command is attempted during a freeze, a modal dialog explains that implementation is blocked until the release finishes. Dismiss with `Esc`, `Enter`, or `q` to return to the list. -- **Downtime dispatcher freeze gate** — While the marker is frozen **or ambiguous**, the downtime worker skips its audit and implement dispatch tiers (no new audits/implementations during a release); the plan/intake tiers continue. See [Downtime worker](#downtime-worker-local-llm-idle-dispatch). +- **Downtime dispatcher freeze gate** — While the marker is frozen **or ambiguous**, the downtime worker skips its audit and non-critical implement dispatch tiers (no new audits/implementations during a release); the plan/intake tiers continue, and the critical tier pauses only its implement-kind (plan_complete) candidates (Q1 split-by-skill — critical intake/plan prep continues). See [Downtime worker](#downtime-worker-local-llm-idle-dispatch). - **Other commands unaffected** — Audit, intake, plan, review, priority, search, and navigation continue to work normally during a freeze. The Ship It shortcut (`S`) also stays available during a freeze: the release @@ -951,6 +1358,31 @@ should additionally be stage-limited to the podcast lifecycle `w b`: `plan_complete`, `in_review`, `done` — the stages where a script exists). +### Background dispatch via `open_pane` + +Each entry in `shortcuts.json` may carry an optional `open_pane` boolean +controlling whether dispatching the shortcut opens a visible pane +(WL-0MSJLD1I70045ZUL): + +| `open_pane` value | Behaviour on dispatch | +|---|---| +| `false` | Command runs **in the background — no pane opens**. Shell (`!!`/`!`) commands run via detached `bash -c`; agent commands (`/skill:*`, `/intake`, `/plan`, `/prompt:`) run headless via `pi -p --mode json` (honoring the entry's `model`). stdout/stderr are captured to a per-run log file under `/herdr-background-logs/`; the path is written to stderr so it can be located for inspection. The work-item ↔ pane association is skipped when no pane is created. | +| omitted (or `true`) | A pane opens exactly as today — shell commands in a visible "Command Output" herdr pane, agent commands in a pi agent pane (backward compatible). | + +Semantics: + +- The JSON key is snake_case (`open_pane`); the parsed TS field is camelCase + (`openPane`) — matching the `code_freeze`→`codeFreeze` convention. +- Any non-boolean value is logged as invalid and treated as omitted (a pane + opens) — a bad value never hides or breaks a shortcut. +- Bundled quiet state-change shortcuts carry `"open_pane": false`: `a-y` + (audit approve), `a-r` (audit reject), `u-p-l/m/h/c` (priority updates), + and `x-c`/`x-d` (close/delete). They run to completion in the background + and the worklist refresh shows the updated state. All other bundled + shortcuts omit the field and keep today's pane-opening behaviour. +- Form-edited commands (unknown `` placeholders) always open a + pane — the flag applies to direct shortcut dispatch only. + ### Shortcut filtering during a freeze Each entry in `shortcuts.json` may carry an optional `code_freeze` field controlling its visibility while the project is frozen (WL-0MSD81VEL009XHWA): diff --git a/packages/herdr/docs/downtime-disable-incident-rca-2026-08-22.md b/packages/herdr/docs/downtime-disable-incident-rca-2026-08-22.md new file mode 100644 index 000000000..7f296f5c2 --- /dev/null +++ b/packages/herdr/docs/downtime-disable-incident-rca-2026-08-22.md @@ -0,0 +1,128 @@ +# RCA: downtime dispatch continued while the operator believed it disabled (2026-08-22) + +**Work item:** WL-0MT4BWUHW008LIFE · **Write-up item:** WL-0MT5SMVXS004T3BK (AC6) +**Date:** 2026-08-23 (analysis of 2026-08-22 incident evidence) + +## Incident summary + +- ContextHub audit `WL-0MSKSMRFP001Q3XU` was dispatched at + **2026-08-22T11:05:23.163Z** (`.worklog/downtime-dispatches.log` line 64, + cwd `/home/rgardler/projects/ContextHub`), while the operator believed + downtime dispatch was disabled (they had pressed `d` and seen + `[Downtime Off]`). +- A multi-root dispatch wave followed at 11:12Z, 11:15Z, 11:42Z, 11:47Z + (herdr-server.log pane spawns), and the operator's worklist header later + showed `[⏳ downtime idle 40+ min]` — the enabled-and-idle worker state. + +## Evidence used + +| Artifact | Path | What it shows | +|---|---|---| +| Dispatch audit log | `.worklog/downtime-dispatches.log` | `WL-0MSKSMRFP001Q3XU` audit dispatched 11:05:23.163Z (cwd ContextHub); intake of the same item at 08:33:47Z earlier | +| Herdr server log | `~/.config/herdr/herdr-server.log` | 4 Downtime panes spawned 11:05:09–11:05:23Z (pane 210 pid 302077, pane 211 pid 302156, pane 212 pid 302252, pane 213 pid 302464); pane 213 spawned 11:05:23.495Z — matches the audit dispatch to the second. Further spawns 11:12:48, 11:15:22/29, 11:42:03, 11:47:25. Worklist plugin panes opened at 10:25:55 and 10:28:11 (`plugin.pane.open`) with no worklist restart between 10:28 and 11:05 | +| Plugin config | `~/.config/herdr/worklog-plugin.json` | No `downtimeEnabled` key → every worker follows the default (**enabled**) | +| Worklog command log | `~/.config/herdr/worklog-command-log.json` | No `/downtime toggle` entries — the press is an internal action, unlogged (confirmed, intake brief) | + +## Hypotheses evaluated + +### H1 — override lost/cycled after the press — **CONFIRMED (mechanism present)** + +Two old-code failure modes both match the observed outcome: + +1. **Cycle force-enable.** The pre-fix `toggle()` cycled + `null → false → true → null` (downtime-worker.ts). A **second** `d` press — + an accidental double-press or a deliberate press-with-confusion — set + `override = true`, **force-enabling dispatch** even over a settings-level + disable. This alone explains the 11:05:23Z ContextHub audit dispatch. +2. **In-memory only, lost on any lifecycle event.** The override was never + persisted. The worklist panes were (re)opened at 10:25:55 and 10:28:11 + (`plugin.pane.open`); any press made in a pane that was closed/reopened + before 11:05 silently reverted to `null` → followed the default-enabled + setting. This also explains the later `[⏳ downtime idle 40+ min]` header: + it is the honest render of a **fresh enabled worker** whose override was + gone — the previously-disabled pane silently re-enabled. + +Both variants are now removed by the fix: the toggle cycle is +`null → false → null` (no force-enable) and the disable is persisted via the +`.herdr-downtime-disabled` marker with a visible `[Downtime Off (restored)]` +header notice. + +### H2 — in-flight dispatch completed after the press — **REJECTED** + +An in-flight dispatch at the moment of the press could explain at most ONE +late dispatch (the one already spawning), and it would still render the +disabled header afterward. The dispatch log shows the audit entry was +*written* at 11:05:23.163Z and the pane spawned at 11:05:23.495Z — a **new** +dispatch, not a completion of an earlier one. Multi-pane waves and the +enabled-idle header are not explained by H2. Verdict: not the cause. + +### H3 — scope mismatch: sibling panes kept dispatching — **CONFIRMED (contributor)** + +Leader-only dispatch (WL-0MSXHA1B0005G3E5) is blocked/unimplemented, so every +worklist pane dispatches independently. The 4-pane wave at 11:05:09–23Z (13 +seconds) is the signature of **multiple roots' workers each passing their idle +gate in the same idle stretch** — the operator's `d` press disables only the +pane it was pressed in (per-instance scope, WL-0MSZ4NSOE007AQEF), never its +siblings. That is why dispatch continued across roots at 11:12–11:47Z no +matter what one pane's header said. This part is by-design per-pane scoping — +now made explicit in the README — plus the operator's observed reality that +multiple panes were concurrently enabled by default. + +## Confirmed root cause (joint) + +**H1 + H3.** The ContextHub audit at 11:05:23Z is best explained by the old +toggle's force-enable cycle and/or the in-memory-only override being lost on +the 10:25–10:28 worklist pane (re)opens — either way a worker with +`override = null | true` followed the default **enabled** setting. The wider +11:05–11:47Z wave is explained by H3: every other root's worklist worker was +independently enabled by default and concurrently idle-dispatching. The +operator's disable intent was per-pane and non-durable on the old code — the +exact failure class the parent item's ACs target. + +## Verification steps recorded + +1. `grep WL-0MSKSMRFP001Q3XU .worklog/downtime-dispatches.log` → + dispatch at 11:05:23.163Z + earlier intake at 08:33:47Z. +2. `sed -n '21651,21720p' ~/.config/herdr/herdr-server.log` → + panes 210–213 spawned 11:05:09–23Z; pid 302464 pane 213 at 11:05:23.495Z + matches the audit dispatch (a new dispatch, killing H2). +3. `grep 2026-08-22T1[01]: … | grep plugin.pane.open` → + worklist panes opened 10:25:55 + 10:28:11; no worklist restart 10:28–11:05 + (partial H1-restart evidence: presses before 10:28 were already lost; + presses after 10:28 would have survived had no restart occurred). +4. `cat ~/.config/herdr/worklog-plugin.json` → no `downtimeEnabled` key → + defaults-enabled for every worker (H3). +5. Header-state observation: `[⏳ downtime idle 40+ min]` in a session the + operator disabled → consistent with a fresh enabled worker after override + loss (H1-restart) or an untoggled sibling pane (H3). + +## Open questions from intake — closure + +- **OPEN Q1 (pressed `d` once or more?)** — **Unresolvable from available + logs**: the press is an internal TUI action and is not recorded in + worklog-command-log.json. A double-press force-enable is the single most + economical explanation of the ContextHub audit dispatch, but it cannot be + proven. Acknowledged; the fix removes the failure mode regardless. +- **OPEN Q2 (same pane for `[Downtime Off]` and the idle header?)** — + **Unresolvable**: header states are TUI-rendered, not logged. The report is + consistent with two independent panes (one disabled, one enabled) or one + pane that re-enabled — both covered by H1/H3. +- **OPEN Q3 (worklist pane restarted between press and dispatch?)** — + **Partially answered**: no worklist restart is logged between 10:28 and + 11:05, but the worklist panes were *opened* at 10:25–10:28, so any press + before those opens was definitively lost, and an unlogged pane close/open + cannot be excluded. The fix's marker makes the disable durable across all + such events. + +## Fixes the parent item ships (failure-class coverage) + +1. **Toggle cycle `null → false → null`** (WL-0MT58VKOW0066XJ6 / + WL-0MT58WFJM001REH5) — a second press can never force-enable (AC3). +2. **Persisted disable marker** `.herdr-downtime-disabled` + (WL-0MT58WX3X0092PTK / WL-0MT5SFP990001FNW) — disable survives restarts, + still per worklog root (AC1/AC2). +3. **Header honesty** (WL-0MT5SFX1S002YUYZ / WL-0MT5SG0VU005ARUR) — + `[Downtime Off (restored)]` whenever the disable was restored from the + marker; the render always agrees with the effective gate (AC4). +4. **Scope/durability docs** (WL-0MT5SMS6Z0072P2L) — per-pane scope and + marker durability are explicit in the README (AC5). \ No newline at end of file diff --git a/packages/herdr/herdr-plugin.toml b/packages/herdr/herdr-plugin.toml index 566d5146a..5f9b80120 100644 --- a/packages/herdr/herdr-plugin.toml +++ b/packages/herdr/herdr-plugin.toml @@ -7,7 +7,7 @@ id = "worklog-selection-list" name = "Worklog Selection List" version = "0.1.0" -description = "Browse, filter, and select Worklog work items from a Herdr pane. Keyboard-navigable list with stage filtering and detail view." +description = "Browse, filter, and select Worklog work items from a Herdr pane. Keyboard-navigable list with stage/priority filtering and detail view." min_herdr_version = "0.7.0" platforms = ["linux", "macos", "windows"] @@ -35,11 +35,11 @@ title = "Open worklist" description = "Open the Worklog work item selection pane." command = ["bash", "scripts/open.sh"] -# Action: open the podcast editing tab (worklist pane renamed to "Podcast Editing") +# Action: open the worklog tab (worklist pane renamed to "Worklog") [[actions]] id = "open-podcast-editor-tab" -title = "Open Podcast Editing tab" -description = "Open the Worklog work item selection pane in a new tab renamed to 'Podcast Editing'." +title = "Open Worklog tab" +description = "Open the Worklog work item selection pane in a new tab renamed to 'Worklog'." command = ["bash", "scripts/open-podcast-editor-tab.sh"] # Action: open pi agent pane diff --git a/packages/herdr/scripts/open-podcast-editor-tab.sh b/packages/herdr/scripts/open-podcast-editor-tab.sh index d2952e8ce..f058308eb 100755 --- a/packages/herdr/scripts/open-podcast-editor-tab.sh +++ b/packages/herdr/scripts/open-podcast-editor-tab.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# open-podcast-editor-tab.sh — Open the podcast editing tab +# open-podcast-editor-tab.sh — Open the Worklog tab # -# Opens the Worklog work item selection pane (the podcast production -# workflow) in a NEW TAB and renames that tab to "Podcast Editing" so it -# is instantly recognisable in the tab row among several open tabs. +# Opens the Worklog work item selection pane in a NEW TAB and renames +# that tab to "Worklog" so it is instantly recognisable in the tab row +# among several open tabs. # # `herdr plugin pane open` (herdr 0.7.5) has no tab-title option — the # created tab gets a generated numeric label. This script therefore opens @@ -19,7 +19,7 @@ # # Environment variables: # HERDR_BIN_PATH Path to the herdr CLI binary (default: herdr on PATH) -# TAB_LABEL Tab label to apply (default: "Podcast Editing") +# TAB_LABEL Tab label to apply (default: "Worklog") # # Returns: # 0 on success (tab opened and renamed) @@ -31,7 +31,7 @@ set -uo pipefail herdr_bin="${HERDR_BIN_PATH:-herdr}" -TAB_LABEL="${TAB_LABEL:-Podcast Editing}" +TAB_LABEL="${TAB_LABEL:-Worklog}" fail() { echo "error: $*" >&2 @@ -156,4 +156,4 @@ $open_output" "$herdr_bin" tab rename "$tab_id" "$TAB_LABEL" >/dev/null 2>&1 \ || fail "'herdr tab rename $tab_id \"$TAB_LABEL\"' failed. Is the herdr server running?" -echo "Opened podcast editing tab '$TAB_LABEL' ($tab_id)." >&2 +echo "Opened worklog tab '$TAB_LABEL' ($tab_id)." >&2 diff --git a/packages/herdr/scripts/run-in-pane.sh b/packages/herdr/scripts/run-in-pane.sh index 8c4b30a80..169086143 100755 --- a/packages/herdr/scripts/run-in-pane.sh +++ b/packages/herdr/scripts/run-in-pane.sh @@ -10,12 +10,24 @@ # with Enter or herdr `close_pane` (default `prefix+x`) # # Usage: -# run-in-pane.sh +# run-in-pane.sh [--cwd ] [--no-focus] # # The command is executed via `bash -c`, so compound commands (`&&`), # single-quoted arguments (e.g. `--summary 'Approved by manual review'`), # and shell constructs all work. # +# Options (parsed only at the head of argv): +# --cwd Working directory for the new pane +# (default: $HERDR_RESOLVED_CWD, then $PWD) +# --no-focus Skip the final pane zoom so the current pane (e.g. the +# worklist selection list) keeps focus while the command +# pane opens in the background (WL-0MSHIA53D009DJOT). +# Without the flag the new pane is focused as before +# (backward compatible). +# +# Everything after the options — including commands whose first token +# starts with `--` — is treated as the command to run. +# # Environment variables: # HERDR_BIN_PATH Path to the herdr CLI binary (default: herdr on PATH) # RUN_IN_PANE_NAME Pane title (default: "Command Output") @@ -79,14 +91,26 @@ fi # ── Main mode: split, run, rename ──────────────────────────────────────── pane_name="${RUN_IN_PANE_NAME:-Command Output}" -# The command is everything after the leading options. Currently the only -# supported option is --cwd (target project root for the new pane); -# everything else is treated as the command to run. +# The command is everything after the leading options. Only --cwd and +# --no-focus are parsed as options at the head of argv; everything else +# (including commands whose first token begins with --) is the command. target_cwd="" -if [ "${1:-}" = "--cwd" ]; then - target_cwd="$2" - shift 2 -fi +no_focus=false +while [ $# -gt 0 ]; do + case "${1:-}" in + --cwd) + target_cwd="$2" + shift 2 + ;; + --no-focus) + no_focus=true + shift + ;; + *) + break + ;; + esac +done COMMAND="$*" if [ -z "$COMMAND" ]; then @@ -133,6 +157,11 @@ quoted_pane="$(printf '%q' "$np")" # Rename the pane "$herdr_bin" pane rename "$np" "$pane_name" >/dev/null 2>&1 || true -# Focus the new pane so the user sees the command run -"$herdr_bin" pane zoom "$np" --on >/dev/null 2>&1 || true -"$herdr_bin" pane zoom "$np" --off >/dev/null 2>&1 || true +# Focus the new pane so the user sees the command run — unless --no-focus +# (WL-0MSHIA53D009DJOT): selection-list dispatches pass --no-focus so the +# command-output pane opens without stealing focus; the user can focus it +# manually with herdr pane navigation. +if [ "$no_focus" = false ]; then + "$herdr_bin" pane zoom "$np" --on >/dev/null 2>&1 || true + "$herdr_bin" pane zoom "$np" --off >/dev/null 2>&1 || true +fi diff --git a/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh b/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh index d2432e69d..7524ea474 100755 --- a/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh +++ b/packages/herdr/scripts/tests/test_open_podcast_editor_tab.sh @@ -3,7 +3,7 @@ # # Covers the parse-and-rename wiring with a mock herdr CLI: # - happy path: pane opened, tab_id parsed from the pane-open JSON, -# tab renamed to "Podcast Editing" +# tab renamed to "Worklog" # - herdr CLI unavailable → clear error, non-zero exit # - unparseable tab_id → clear error, non-zero exit, rename never called # - `plugin pane open` failure → clear error, non-zero exit @@ -144,14 +144,14 @@ else echo " log: $(cat "$HERDR_LOG" 2>/dev/null)" fi -if grep -q 'tab rename w9:tPE Podcast Editing' "$HERDR_LOG" 2>/dev/null; then - pass "tab renamed to 'Podcast Editing' with the parsed tab_id" +if grep -q 'tab rename w9:tPE Worklog' "$HERDR_LOG" 2>/dev/null; then + pass "tab renamed to 'Worklog' with the parsed tab_id" else - fail "tab rename command missing or wrong (expected 'tab rename w9:tPE Podcast Editing')" + fail "tab rename command missing or wrong (expected 'tab rename w9:tPE Worklog')" echo " log: $(cat "$HERDR_LOG" 2>/dev/null)" fi -if echo "$out" | grep -q 'Opened podcast editing tab'; then +if echo "$out" | grep -q 'Opened worklog tab'; then pass "success message printed" else fail "success message missing" diff --git a/packages/herdr/scripts/tests/test_run_in_pane.sh b/packages/herdr/scripts/tests/test_run_in_pane.sh index 78f0f090a..40d99ef25 100755 --- a/packages/herdr/scripts/tests/test_run_in_pane.sh +++ b/packages/herdr/scripts/tests/test_run_in_pane.sh @@ -4,6 +4,10 @@ # Covers the --exec wrapper mode: # - non-TTY + HERDR_PANE_ID: stays alive with ~0% CPU (not busy-looping) # - non-TTY + no HERDR_PANE_ID: exits immediately with command status +# Covers the --no-focus main-mode flag (WL-0MSHIA53D009DJOT): +# - --no-focus skips the final pane zoom (selection list keeps focus) +# - omitting --no-focus preserves the current focus/zoom behavior +# - --no-focus does not affect pane creation / command execution # # Run from the repo root (or anywhere): # bash packages/herdr/scripts/tests/test_run_in_pane.sh @@ -83,6 +87,72 @@ else fail "should exit non-zero with no command" fi +# ── Main-mode tests: zoom/focus behavior (WL-0MSHIA53D009DJOT) ─────┐ +# └── Uses a mock herdr CLI so no live herdr session is required ─────┘ +echo "" +echo "=== Test: no-focus flag (main mode, mock herdr CLI) ===" + +SANDBOX="$(mktemp -d)" +HERDR_LOG="$SANDBOX/herdr-log.txt" + +MOCK_HERDR="$SANDBOX/mock-herdr" +cat > "$MOCK_HERDR" <> "$HERDR_LOG" +case "\$1" in + pane) + case "\$2" in + split) + echo '{"pane_id":"test-pane-main","success":true}' + ;; + run) + echo "mock: ran" + ;; + rename|zoom) + ;; + *) + echo "mock: unknown pane subcommand \$2" >&2 + exit 1 + ;; + esac + ;; + *) + echo "mock: unknown command \$1" >&2 + exit 1 + ;; +esac +MOCK +chmod +x "$MOCK_HERDR" + +# --no-focus skips the final zoom entirely +rm -f "$HERDR_LOG" +HERDR_BIN_PATH="$MOCK_HERDR" bash "$RUN_IN_PANE" --no-focus --cwd /tmp "echo hi" < /dev/null >/dev/null 2>&1 +if [ -f "$HERDR_LOG" ] && ! grep -q "zoom" "$HERDR_LOG"; then + pass "--no-focus skips the final pane zoom" +else + fail "--no-focus should skip the final pane zoom (log: $(cat "$HERDR_LOG" 2>/dev/null))" +fi + +# Without the flag the zoom still happens (backward compatible) +rm -f "$HERDR_LOG" +HERDR_BIN_PATH="$MOCK_HERDR" bash "$RUN_IN_PANE" --cwd /tmp "echo hi" < /dev/null >/dev/null 2>&1 +if grep -q "zoom" "$HERDR_LOG" 2>/dev/null; then + pass "omitting --no-focus keeps the focus/zoom behavior" +else + fail "without --no-focus the zoom must still run (log: $(cat "$HERDR_LOG" 2>/dev/null))" +fi + +# --cwd is still honoured alongside --no-focus (the command still runs) +rm -f "$HERDR_LOG" +HERDR_BIN_PATH="$MOCK_HERDR" bash "$RUN_IN_PANE" --no-focus --cwd /tmp "echo hi" < /dev/null >/dev/null 2>&1 +if grep -q "pane run" "$HERDR_LOG" 2>/dev/null; then + pass "--no-focus does not affect pane creation / command execution" +else + fail "--no-focus must not skip pane run (log: $(cat "$HERDR_LOG" 2>/dev/null))" +fi + +rm -rf "$SANDBOX" + echo "" echo "========================================" echo "Results: $PASS passed, $FAIL failed" diff --git a/packages/herdr/shared/grid.py b/packages/herdr/shared/grid.py old mode 100755 new mode 100644 diff --git a/packages/herdr/shared/herdr-agent-state-protocol.md b/packages/herdr/shared/herdr-agent-state-protocol.md index 2d9945008..80a294a1f 100644 --- a/packages/herdr/shared/herdr-agent-state-protocol.md +++ b/packages/herdr/shared/herdr-agent-state-protocol.md @@ -261,6 +261,77 @@ The planned ContextHub Rust migration should follow the same protocol. The TypeScript equivalent (if needed) would use `net.Socket` or equivalent for Unix socket communication. +## Inbound event subscription (events.subscribe) + +The outbound reporting protocol above is complemented by an **inbound** +event subscription (herdr ≥ 0.7.5, WL-0MSHB7DHO004RHBJ): the worklist +plugin keeps a long-lived JSON-RPC connection to the same Unix socket and +receives window/agent events pushed by herdr. Events drive the per-item +agent-status icons and pane focus/visibility gating **immediately**, keeping +polling (`herdr agent list` / `herdr tab get`) only as a fail-open fallback. + +### Subscription request + +`events.subscribe` accepts an array of subscriptions; multiple subscriptions +in one call are supported: + +```json +{ + "jsonrpc": "2.0", + "id": "sub-1", + "method": "events.subscribe", + "params": { + "subscriptions": [ + { "type": "pane.focused" }, + { "type": "pane.agent_detected" }, + { "type": "pane.closed" }, + { "type": "pane.exited" }, + { "type": "pane.agent_status_changed", "pane_id": "w1:p41" } + ] + } +} +``` + +- `pane.focused`, `pane.agent_detected`, `pane.closed`, `pane.exited` are + **global** (no `pane_id`). +- `pane.agent_status_changed` **requires** a `pane_id` — per-pane + subscriptions are (re)created as tracked panes appear/close. Multiple + subscriptions in one `events.subscribe` call work. +- Successful subscription replies `{ "result": { "type": + "subscription_started" } }`. + +### Pushed event frames + +Events are pushed as newline-delimited frames: + +```json +{"data": { "pane_id": "w1:p41", "agent_status": "working" }, "event": "pane_agent_status_changed"} +{"data": { "pane_id": "w1:p41" }, "event": "pane_focused"} +{"data": { "pane_id": "w1:p99" }, "event": "pane_agent_detected"} +{"data": { "pane_id": "w1:p41" }, "event": "pane_closed"} +{"data": { "pane_id": "w1:p41" }, "event": "pane_exited"} +``` + +Event types include `pane_agent_status_changed`, `pane_focused`, +`pane_agent_detected`, `pane_created`, `pane_closed`, `pane_exited`, +`pane_output_changed`, `pane_updated`, `tab_focused`, `workspace_focused`. + +### Subscriber lifecycle + +- The subscriber (`packages/herdr/src/events.ts`) connects at plugin start, + reconnects with exponential backoff (500ms base, capped at 10s) on socket + errors, and closes/unsubscribes on TUI exit (no leaked connections). +- Per-pane subscriptions are synced from the shared + `.worklog/agent-panes.json` pane set and updated on `pane_agent_detected` + (add) and `pane_closed`/`pane_exited` (drop). +- **Session scope (limitation):** events are scoped to the herdr **session** + the client attaches to. Multiple herdr instances attached to the same + session share events (covers the multi-instance requirement); a project + split across **different** sessions does not share events. +- **Fail-open:** an unreachable socket / older herdr (pre-0.7.5) / transient + reconnect keeps today's polling (`herdr agent list`, `herdr tab get`); + socket errors never crash the TUI. + ## Related - Reference Rust implementation: `herdr.rs` in `herdr-podcast-editor` diff --git a/packages/herdr/src/agent-tracker.test.ts b/packages/herdr/src/agent-tracker.test.ts index 495d61b7e..ea9dc221e 100644 --- a/packages/herdr/src/agent-tracker.test.ts +++ b/packages/herdr/src/agent-tracker.test.ts @@ -21,6 +21,7 @@ import { AgentTracker, normalizeAgentState, parseAgentListOutput, + mergeAgentStatesCached, type AgentState, } from './agent-tracker.js'; import { @@ -323,3 +324,181 @@ describe('AgentTracker — refreshStates', () => { expect(states.get('WL-DEF')).toBe('blocked'); }); }); + +describe('AgentTracker — event-applied path (WL-0MSHB7DHO004RHBJ F5)', () => { + beforeEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_BIN_PATH; + }); + + afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_BIN_PATH; + }); + + it('applyAgentStatusChanged updates the cached state without any herdr exec', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile, ttlMs: 60_000 }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + await tracker.recordAgentForWorkItem('WL-DEF', 'p2'); + + // Seed the cache via a normal refresh (one herdr exec). + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([ + { pane_id: 'p1', agent_status: 'idle' }, + { pane_id: 'p2', agent_status: 'idle' }, + ]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + const states = await tracker.refreshStates(); + expect(states.get('WL-ABC')).toBe('idle'); + expect(mockFn).toHaveBeenCalledTimes(1); + + // A pane_agent_status_changed event flips the state with NO exec. + const affected = tracker.applyAgentStatusChanged('p1', 'working'); + expect(affected).toEqual(['WL-ABC']); + expect(tracker.snapshotStates().get('WL-ABC')).toBe('working'); + expect(tracker.snapshotStates().get('WL-DEF')).toBe('idle'); + expect(mockFn).toHaveBeenCalledTimes(1); + + // The memo window was extended: refreshStates returns the event-applied + // states without re-polling within the TTL. + const refreshed = await tracker.refreshStates(); + expect(refreshed.get('WL-ABC')).toBe('working'); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('applyAgentStatusChanged with done prunes the entry (icon disappears)', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile, ttlMs: 60_000 }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const affected = tracker.applyAgentStatusChanged('p1', 'done'); + expect(affected).toEqual(['WL-ABC']); + expect(tracker.getPaneId('WL-ABC')).toBeUndefined(); + expect(tracker.snapshotStates().has('WL-ABC')).toBe(false); + }); + + it('applyAgentStatusChanged for an untracked pane is a no-op (empty affected)', () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + expect(tracker.applyAgentStatusChanged('unknown-pane', 'working')).toEqual([]); + }); + + it('applyAgentDetected re-reads the shared file (cross-instance, late-spawned agents)', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile, ttlMs: 60_000 }); + + // Another plugin instance records an association after we started. + const other = new AgentTracker({ stateFile }); + await other.recordAgentForWorkItem('WL-LATE', 'p9'); + + // Our tracker has no in-memory knowledge of the new association. + expect(tracker.getPaneId('WL-LATE')).toBeUndefined(); + + // A pane_agent_detected event triggers a re-read → the late entry is + // picked up and the pane set grew. + const grew = tracker.applyAgentDetected(); + expect(grew).toBe(true); + expect(tracker.getPaneId('WL-LATE')).toBe('p9'); + }); + + it('applyPaneGone prunes associations for the closed pane and persists', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile, ttlMs: 60_000 }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + await tracker.recordAgentForWorkItem('WL-DEF', 'p2'); + + const affected = tracker.applyPaneGone('p1'); + expect(affected).toEqual(['WL-ABC']); + expect(tracker.getPaneId('WL-ABC')).toBeUndefined(); + expect(tracker.getPaneId('WL-DEF')).toBe('p2'); + + // Persisted: a fresh tracker instance sees the prune. + const reloaded = new AgentTracker({ stateFile }); + expect(reloaded.getPaneId('WL-ABC')).toBeUndefined(); + expect(reloaded.getPaneId('WL-DEF')).toBe('p2'); + }); + + it('snapshotStates returns a copy (mutating it does not affect the tracker)', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + tracker.applyAgentStatusChanged('p1', 'blocked'); + + const snap = tracker.snapshotStates(); + expect(snap.get('WL-ABC')).toBe('blocked'); + snap.delete('WL-ABC'); + expect(tracker.snapshotStates().get('WL-ABC')).toBe('blocked'); + }); + + it('normalizes unknown statuses to unknown on the event path', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + tracker.applyAgentStatusChanged('p1', 'weird-status'); + expect(tracker.snapshotStates().get('WL-ABC')).toBe('unknown'); + }); +}); + +describe('mergeAgentStatesCached (WL-0MSHB7DHO004RHBJ F5)', () => { + beforeEach(() => resetExecFileAsync()); + afterEach(() => resetExecFileAsync()); + + it('stamps icon-eligible states and clears no-longer-eligible ones without exec', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + await tracker.recordAgentForWorkItem('WL-DEF', 'p2'); + + // First apply an event state, then merge into items. + tracker.applyAgentStatusChanged('p1', 'working'); + tracker.applyAgentStatusChanged('p2', 'blocked'); + + const items = [ + { id: 'WL-ABC', title: 'a', status: 'open' } as any, + { id: 'WL-DEF', title: 'b', status: 'open' } as any, + { id: 'WL-GHI', title: 'c', status: 'open' } as any, + ]; + mergeAgentStatesCached(items, tracker); + expect(items[0].agentState).toBe('working'); + expect(items[1].agentState).toBe('blocked'); + expect(items[2].agentState).toBeUndefined(); + }); + + it('clears a previously stamped icon when the state becomes ineligible', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + tracker.applyAgentStatusChanged('p1', 'working'); + + const items = [{ id: 'WL-ABC', title: 'a', status: 'open', agentState: 'working' } as any]; + mergeAgentStatesCached(items, tracker); + expect(items[0].agentState).toBe('working'); + + // Agent reports done → prune → icon must clear. + tracker.applyAgentStatusChanged('p1', 'done'); + mergeAgentStatesCached(items, tracker); + expect(items[0].agentState).toBeUndefined(); + }); + + it('applies states recursively into children', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-CHILD', 'p1'); + tracker.applyAgentStatusChanged('p1', 'blocked'); + + const items = [ + { + id: 'WL-PARENT', + title: 'p', + status: 'open', + children: [{ id: 'WL-CHILD', title: 'c', status: 'open' }], + }, + ] as any; + mergeAgentStatesCached(items, tracker); + expect(items[0].children[0].agentState).toBe('blocked'); + }); + + it('never throws when the tracker has no states', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + const items = [{ id: 'WL-X', title: 'x', status: 'open' } as any]; + expect(() => mergeAgentStatesCached(items, tracker)).not.toThrow(); + expect(items[0].agentState).toBeUndefined(); + }); +}); diff --git a/packages/herdr/src/agent-tracker.ts b/packages/herdr/src/agent-tracker.ts index 4b1cefbef..60a676fb9 100644 --- a/packages/herdr/src/agent-tracker.ts +++ b/packages/herdr/src/agent-tracker.ts @@ -253,6 +253,84 @@ export class AgentTracker { } } + // ── Event-applied path (WL-0MSHB7DHO004RHBJ F5) ──────────────────── + + /** + * Snapshot the CURRENT cached agent states WITHOUT the TTL/memo check + * and without any herdr exec. Used by the event path to re-render icons + * immediately after an event updates the cache. + */ + snapshotStates(): Map { + return new Map(this.cachedStates); + } + + /** + * Apply a `pane_agent_status_changed` event to the cached state map. + * + * Finds every tracked association whose pane matches the event's + * pane_id and updates its state immediately (no `herdr agent list` + * exec). A `done` status prunes the entry (mirrors refreshStates). + * Extends the memo window so the next refreshStates() call returns the + * event-applied states instead of re-polling. + * + * Returns the affected work-item ids (empty when the pane is untracked). + */ + applyAgentStatusChanged(paneId: string, status: string): string[] { + const affected: string[] = []; + const state = normalizeAgentState(status); + for (const [workItemId, entry] of [...this.entries]) { + if (entry.paneId !== paneId) continue; + if (state === 'done') { + this.entries.delete(workItemId); + this.cachedStates.delete(workItemId); + } else { + this.cachedStates.set(workItemId, state); + } + affected.push(workItemId); + } + if (affected.length > 0) { + this.persist(); + this.cachedAt = Date.now(); + } + return affected; + } + + /** + * Apply a `pane_agent_detected` event: re-read the shared + * `.worklog/agent-panes.json` so associations recorded by OTHER plugin + * instances (cross-instance coverage, AC3) and late-spawned agents are + * picked up without waiting for the next refresh cycle. + * + * Returns true when the pane set grew (callers should add per-pane + * subscriptions for the newly tracked panes). + */ + applyAgentDetected(): boolean { + const before = this.entries.size; + this.loadFromDisk(); + this.cachedAt = Date.now(); + return this.entries.size > before; + } + + /** + * Apply a `pane_closed` / `pane_exited` event: prune every association + * whose pane is gone so its icon disappears immediately (no poll wait). + * Returns the affected work-item ids. + */ + applyPaneGone(paneId: string): string[] { + const affected: string[] = []; + for (const [workItemId, entry] of [...this.entries]) { + if (entry.paneId !== paneId) continue; + this.entries.delete(workItemId); + this.cachedStates.delete(workItemId); + affected.push(workItemId); + } + if (affected.length > 0) { + this.persist(); + this.cachedAt = Date.now(); + } + return affected; + } + // ── Persistence ──────────────────────────────────────────────────── /** @@ -336,3 +414,44 @@ export async function mergeAgentStates(items: WorkItem[], tracker: AgentTracker) }; walk(items); } + +/** + * Merge the CURRENT cached agent states into a list of work items + * (recursively into children) WITHOUT the TTL/memo check and without any + * herdr exec (WL-0MSHB7DHO004RHBJ F5). + * + * Used by the event path: after a `pane_agent_status_changed` event + * updates the tracker's cache, this re-applies the updated states to the + * currently rendered items so their icons flip immediately. Same + * icon-eligibility rules as mergeAgentStates (`idle`/`working`/`blocked` + * render; `done`/`unknown`/absent do not). Never throws. + */ +export function mergeAgentStatesCached(items: WorkItem[], tracker: AgentTracker): void { + try { + const states = tracker.snapshotStates(); + const apply = (item: WorkItem): void => { + const state = states.get(item.id); + if (state === 'idle' || state === 'working' || state === 'blocked') { + item.agentState = state; + } else { + // A state that is no longer icon-eligible (done/unknown/gone) + // must clear a previously rendered icon. Unlike mergeAgentStates + // (fresh items never carry stale icons), the event path re-applies + // to LIVE items, so the walk must always run — even when the + // tracker currently has no states (all pruned). + delete item.agentState; + } + }; + const walk = (list: WorkItem[]): void => { + for (const item of list) { + apply(item); + if (item.children && item.children.length > 0) { + walk(item.children); + } + } + }; + walk(items); + } catch { + // Fail-open: no icon updates this cycle. + } +} diff --git a/packages/herdr/src/clipboard.test.ts b/packages/herdr/src/clipboard.test.ts new file mode 100644 index 000000000..30ece8b7a --- /dev/null +++ b/packages/herdr/src/clipboard.test.ts @@ -0,0 +1,197 @@ +/** + * Unit tests for the herdr clipboard read/write helpers. + * + * Run: npx vitest run packages/herdr/src/clipboard.test.ts + * + * Covers `readFromClipboard` (platform-ordered paste readers, graceful + * degradation when no reader is available) and `writeToClipboard` (copy to + * OS clipboard) via injectable spawn fakes. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { + readFromClipboard, + writeToClipboard, + type SpawnLike, +} from './clipboard.js'; + +/** A tiny fake child process backed by an EventEmitter, shaped like the real + * `ChildProcess` API used by the clipboard helpers (stdout/stderr data + * streams, close/error events, stdin write/end). */ +function fakeChild(opts: { + stdoutData?: string; + stderrData?: string; + exitCode?: number; + error?: Error; +}): EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + stdin: { write: ReturnType; end: ReturnType }; + kill: ReturnType; +} { + const child = new EventEmitter() as any; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { write: vi.fn(), end: vi.fn() }; + child.kill = vi.fn(); + // Defer stdout/stderr/close on the macrotask queue so the clipboard + // helper's data/close listeners (attached after spawn returns) are in + // place before anything is emitted. + setImmediate(() => { + if (opts.stdoutData) child.stdout.emit('data', Buffer.from(opts.stdoutData)); + if (opts.stderrData) child.stderr.emit('data', Buffer.from(opts.stderrData)); + if (opts.error) { + child.emit('error', opts.error); + } else { + child.emit('close', opts.exitCode ?? 0); + } + }); + return child; +} + +/** Build a spawn-like that returns a fake child and records invocations. */ +function fakeSpawn(behaviors: Array<{ command?: string; stdoutData?: string; exitCode?: number; error?: Error }>): { spawn: SpawnLike; calls: Array<[string, string[]]> } { + const calls: Array<[string, string[]]> = []; + let idx = 0; + const spawn: SpawnLike = ((command: string, args?: readonly string[]) => { + calls.push([command, args ? [...args] : []]); + const behavior = behaviors[Math.min(idx, behaviors.length - 1)]; + idx += 1; + return fakeChild({ + stdoutData: behavior.stdoutData, + exitCode: behavior.exitCode, + error: behavior.error, + }) as any; + }) as SpawnLike; + return { spawn, calls }; +} + +describe('readFromClipboard', () => { + it('reads text via pbpaste on macOS', async () => { + const { spawn, calls } = fakeSpawn([{ command: 'pbpaste', stdoutData: 'hello from clipboard' }]); + // Force the darwin branch. + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + try { + const res = await readFromClipboard({ spawn, env: {} }); + expect(res.success).toBe(true); + expect(res.text).toBe('hello from clipboard'); + expect(calls.length).toBe(1); + expect(calls[0][0]).toBe('pbpaste'); + } finally { + Object.defineProperty(process, 'platform', { value: orig }); + } + }); + + it('gracefully reports failure when pbpaste has no output', async () => { + const { spawn } = fakeSpawn([{ command: 'pbpaste', stdoutData: '', exitCode: 0 }]); + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + try { + const res = await readFromClipboard({ spawn, env: {} }); + expect(res.success).toBe(false); + expect(res.error).toBeTruthy(); + } finally { + Object.defineProperty(process, 'platform', { value: orig }); + } + }); + + it('tries wl-paste then xclip on Linux (Wayland set)', async () => { + const { spawn, calls } = fakeSpawn([ + { command: 'wl-paste', stdoutData: 'wayland text' }, + ]); + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + try { + const res = await readFromClipboard({ spawn, env: { WAYLAND_DISPLAY: ':0' } }); + expect(res.success).toBe(true); + expect(res.text).toBe('wayland text'); + expect(calls[0][0]).toBe('wl-paste'); + } finally { + Object.defineProperty(process, 'platform', { value: orig }); + } + }); + + it('falls through to xsel when wl-paste and xclip fail', async () => { + const { spawn, calls } = fakeSpawn([ + { command: 'wl-paste', stdoutData: '', exitCode: 1 }, + { command: 'xclip', stdoutData: '', exitCode: 1 }, + { command: 'xsel', stdoutData: 'fallback text' }, + ]); + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + try { + const res = await readFromClipboard({ spawn, env: { WAYLAND_DISPLAY: ':0', DISPLAY: ':0' } }); + expect(res.success).toBe(true); + expect(res.text).toBe('fallback text'); + expect(calls.map((c) => c[0])).toEqual(['wl-paste', 'xclip', 'xsel']); + } finally { + Object.defineProperty(process, 'platform', { value: orig }); + } + }); + + it('reports failure when no reader succeeds', async () => { + const { spawn } = fakeSpawn([ + { command: 'wl-paste', stdoutData: '', exitCode: 1 }, + { command: 'xclip', stdoutData: '', exitCode: 1 }, + { command: 'xsel', stdoutData: '', exitCode: 1 }, + ]); + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + try { + const res = await readFromClipboard({ spawn, env: { WAYLAND_DISPLAY: ':0', DISPLAY: ':0' } }); + expect(res.success).toBe(false); + expect(res.error).toBeTruthy(); + } finally { + Object.defineProperty(process, 'platform', { value: orig }); + } + }); + + it('reports failure when the reader binary is missing (spawn error)', async () => { + const spawn: SpawnLike = (() => { + throw new Error('ENOENT'); + }) as SpawnLike; + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + try { + const res = await readFromClipboard({ spawn, env: {} }); + expect(res.success).toBe(false); + expect(res.error).toBeTruthy(); + } finally { + Object.defineProperty(process, 'platform', { value: orig }); + } + }); +}); + +describe('writeToClipboard', () => { + it('writes text via pbcopy on macOS', async () => { + const { spawn, calls } = fakeSpawn([{ command: 'pbcopy' }]); + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + try { + const res = await writeToClipboard('value to copy', { spawn, env: {} }); + expect(res.success).toBe(true); + expect(calls.length).toBe(1); + expect(calls[0][0]).toBe('pbcopy'); + } finally { + Object.defineProperty(process, 'platform', { value: orig }); + } + }); + + it('reports failure when every write tool fails', async () => { + const { spawn } = fakeSpawn([ + { command: 'xclip', exitCode: 1 }, + { command: 'xsel', exitCode: 1 }, + ]); + const orig = process.platform; + Object.defineProperty(process, 'platform', { value: 'linux' }); + try { + const res = await writeToClipboard('x', { spawn, env: {} }); + expect(res.success).toBe(false); + expect(res.error).toBeTruthy(); + } finally { + Object.defineProperty(process, 'platform', { value: orig }); + } + }); +}); diff --git a/packages/herdr/src/clipboard.ts b/packages/herdr/src/clipboard.ts new file mode 100644 index 000000000..3842d83c5 --- /dev/null +++ b/packages/herdr/src/clipboard.ts @@ -0,0 +1,202 @@ +/** + * packages/herdr/src/clipboard.ts — OS clipboard read/write helpers + * + * The herdr plugin targets the OS clipboard directly (it runs inside herdr, + * not tmux), so clipboard operations must use platform tools / OSC 52 — there + * is deliberately NO tmux-buffer branch here. + * + * Reads (`readFromClipboard`) are async and are wired into the command-input + * form so `Ctrl+V` / `Ctrl+X` never freeze the TUI (non-blocking UX). + */ + +import { spawn, type ChildProcess } from 'child_process'; + +/** Injectable spawn signature so tests can substitute a fake (mirrors the + * root `src/clipboard.ts` injectable-spawn pattern). */ +export type SpawnLike = ( + command: string, + args?: readonly string[], + options?: unknown, +) => ChildProcess; + +/** + * Read text from the OS clipboard. + * + * Platform-ordered like the root `copyToClipboard`, in reverse (readers): + * 1. macOS: `pbpaste` + * 2. Windows: PowerShell `Get-Clipboard` (`powershell -NoProfile -Command`) + * 3. Linux (Wayland): `wl-paste` (when WAYLAND_DISPLAY is set) + * 4. Linux (X11): `xclip -o -selection clipboard`, then `xsel --clipboard --output` + * + * Each candidate's stdout and exit status are captured with a bounded timeout; + * the first tool that exits 0 and produces output wins. Failures degrade + * gracefully: if no reader is available the result is `{ success: false, + * error }` so the caller can surface a visible message and keep the form open. + * + * @returns The clipboard text when a reader succeeds, plus an error message + * describing why paste failed when it did not. + */ +export async function readFromClipboard( + opts?: { spawn?: SpawnLike; env?: Record }, +): Promise<{ success: boolean; text?: string; error?: string }> { + const spawnImpl = opts?.spawn ?? spawn; + const env = opts?.env ?? process.env; + const errors: string[] = []; + + // Run a single candidate, capturing its stdout and exit status. Never + // throws — a missing binary or spawn error resolves as a failed attempt so + // the next candidate is tried. + const run = (cmd: string, args: string[]): Promise<{ code: number | null; out: string; error?: Error }> => + new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let cp: ChildProcess; + try { + cp = spawnImpl(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err: any) { + resolve({ code: null, out: '', error: err }); + return; + } + cp.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); }); + cp.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); + // Bound the wait so a hung reader never wedges the input path. + const timer = setTimeout(() => { + try { cp.kill('SIGKILL'); } catch { /* ignore */ } + }, 2000); + cp.on('error', (err: Error) => { + clearTimeout(timer); + resolve({ code: null, out: stdout, error: err }); + }); + cp.on('close', (code: number) => { + clearTimeout(timer); + resolve({ code, out: stdout, error: stderr ? new Error(stderr) : undefined }); + }); + }); + + const plat = process.platform; + try { + if (plat === 'darwin') { + const res = await run('pbpaste', []); + if (res.code === 0 && res.out) return { success: true, text: res.out }; + errors.push(res.error?.message || 'pbpaste failed'); + } else if (plat === 'win32') { + const res = await run('powershell', [ + '-NoProfile', + '-Command', + 'Get-Clipboard', + ]); + if (res.code === 0 && res.out) return { success: true, text: res.out }; + errors.push(res.error?.message || 'Get-Clipboard failed'); + } else { + // Linux / other. + let ok = false; + if (env.WAYLAND_DISPLAY) { + const wl = await run('wl-paste', []); + if (wl.code === 0 && wl.out) { return { success: true, text: wl.out }; } + errors.push(wl.error?.message || 'wl-paste failed'); + } + if (!ok) { + if (env.DISPLAY || env.WAYLAND_DISPLAY) { + const xclip = await run('xclip', ['-o', '-selection', 'clipboard']); + if (xclip.code === 0 && xclip.out) { return { success: true, text: xclip.out }; } + errors.push(xclip.error?.message || 'xclip -o failed'); + } + const xsel = await run('xsel', ['--clipboard', '--output']); + if (xsel.code === 0 && xsel.out) { return { success: true, text: xsel.out }; } + errors.push(xsel.error?.message || 'xsel --output failed'); + } + } + + if (errors.length === 0) { + errors.push('no clipboard reader available (install pbpaste, wl-paste, xclip, or xsel)'); + } + return { success: false, error: errors.join('; ') }; + } catch (err: any) { + return { success: false, error: err?.message || String(err) }; + } +} + +/** + * Write text to the OS clipboard. + * + * Thin herdr-local wrapper around the platform-tool copy strategy (no tmux + * branch). We cannot reuse the root CLI `src/clipboard.ts` here because the + * herdr package is an independent TS project; this mirrors the platform-tool + * portion of that strategy so `Ctrl+X` can copy field text to the OS + * clipboard from within the form. + * + * @returns `{ success: true }` when at least one method succeeds, otherwise + * `{ success: false, error }`. + */ +export async function writeToClipboard( + text: string, + opts?: { spawn?: SpawnLike; env?: Record }, +): Promise<{ success: boolean; error?: string }> { + const spawnImpl = opts?.spawn ?? spawn; + const env = opts?.env ?? process.env; + let success = false; + const errors: string[] = []; + + const run = (cmd: string, args: string[]): Promise<{ code: number | null; error?: Error }> => + new Promise((resolve) => { + let cp: ChildProcess; + try { + cp = spawnImpl(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] }); + } catch (err: any) { + resolve({ code: null, error: err }); + return; + } + let handled = false; + cp.on('error', (err: Error) => { + if (handled) return; + handled = true; + resolve({ code: null, error: err }); + }); + cp.on('close', (code: number) => { + if (handled) return; + handled = true; + resolve({ code }); + }); + try { + cp.stdin?.write(String(text)); + cp.stdin?.end(); + } catch (err: any) { + if (!handled) { + handled = true; + resolve({ code: null, error: err }); + } + } + }); + + const plat = process.platform; + if (plat === 'darwin') { + const res = await run('pbcopy', []); + success = res.code === 0; + if (!success) errors.push(res.error?.message || 'pbcopy failed'); + } else if (plat === 'win32') { + const res = await run('cmd', ['/c', 'clip']); + success = res.code === 0; + if (!success) errors.push(res.error?.message || 'clip failed'); + } else { + let ok = false; + if (env.WAYLAND_DISPLAY) { + const wl = await run('wl-copy', []); + if (wl.code === 0) { ok = true; } + else errors.push(wl.error?.message || 'wl-copy failed'); + } + if (!ok) { + const xclip = await run('xclip', ['-selection', 'clipboard']); + if (xclip.code === 0) { ok = true; } + else errors.push(xclip.error?.message || 'xclip failed'); + } + if (!ok) { + const xsel = await run('xsel', ['--clipboard', '--input']); + if (xsel.code === 0) { ok = true; } + else errors.push(xsel.error?.message || 'xsel failed'); + } + success = ok; + } + + if (success) return { success: true }; + return { success: false, error: errors.join('; ') || 'no clipboard method available' }; +} diff --git a/packages/herdr/src/coordination-dispatch.test.ts b/packages/herdr/src/coordination-dispatch.test.ts new file mode 100644 index 000000000..96993fe06 --- /dev/null +++ b/packages/herdr/src/coordination-dispatch.test.ts @@ -0,0 +1,584 @@ +/** + * Tests for the leader-election coordination dispatch (children + * WL-0MSXHA1B0005G3E5 leader-only dispatch, WL-0MSXHA7LP000QNGP scheduler + * refactoring, WL-0MSXHAKDT005H6VQ integration — parent + * WL-0MST3OJ8S0001ROL). + * + * Covers: + * - classifyItemForDispatch: tier classification from a fetched item + * - dispatchFromCoordination: tier priority (audit → implement → plan → + * intake), highest-priority entry dispatch, entry removal, guards + * - runCoordinationCheckIn: most-important upsert / re-queue after + * dispatch / empty-backlog removal / fail-open on wl errors + * - Worker integration: leader polls + dispatches from coordination; + * non-leader skips proxy polling and dispatches nothing; stale-leader + * takeover; 30-min check-in cadence + */ + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createDowntimeWorker, + createDowntimePoller, + classifyItemForDispatch, + dispatchFromCoordination, + runCoordinationCheckIn, + coordinationTierRank, + DOWNTIME_AUDIT_RECENCY_WINDOW_MS, + type DowntimeWorker, + type DowntimeWorkerDeps, + type DowntimeItemInfo, + type ScheduledPrompt, +} from './downtime-worker.js'; +import { createLeaderElectionManager, LEASE_FILE } from './leader-election.js'; +import { + writeCoordinationFile, + readCoordinationFile, + getEntry, + COORDINATION_FILE, + type CoordinationEntry, + type CoordinationData, +} from './coordination.js'; + +// ── Fixtures ────────────────────────────────────────────────────────── + +let testDir: string; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'herdr-coord-worker-')); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +const coordPath = () => join(testDir, COORDINATION_FILE); + +function makeEntry(instanceId: string, workItemId: string, directory?: string): CoordinationEntry { + const now = new Date().toISOString(); + return { + instanceId, + workItemId, + directory: directory ?? '/worklog/root', + assignedAt: now, + lastUpdated: now, + }; +} + +function itemInfo(overrides: Partial & { id: string }): DowntimeItemInfo { + return { + id: overrides.id, + title: overrides.title ?? `Item ${overrides.id}`, + status: overrides.status ?? 'open', + stage: overrides.stage ?? 'idea', + priority: overrides.priority, + risk: overrides.risk, + effort: overrides.effort, + auditedAt: overrides.auditedAt, + updatedAt: overrides.updatedAt, + }; +} + +/** Minimal deps mock for the coordination dispatch path. */ +function makeCoordinationDeps(overrides: Partial = {}): DowntimeWorkerDeps { + return { + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + recordDispatchFailure: vi.fn().mockResolvedValue(undefined), + recordError: vi.fn().mockResolvedValue(undefined), + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + recordScheduledPromptTrigger: vi.fn().mockResolvedValue(true), + readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo({ id: 'WL-X', stage: 'idea' }) }), + ...overrides, + } as DowntimeWorkerDeps; +} + +// ── classifyItemForDispatch ─────────────────────────────────────────── + +describe('classifyItemForDispatch', () => { + it('maps open idea → intake', () => { + expect(classifyItemForDispatch(itemInfo({ id: 'WL-1', status: 'open', stage: 'idea' }))).toBe('intake'); + }); + it('maps open intake_complete → plan', () => { + expect(classifyItemForDispatch(itemInfo({ id: 'WL-1', status: 'open', stage: 'intake_complete' }))).toBe('plan'); + }); + it('maps open plan_complete within caps → implement', () => { + expect(classifyItemForDispatch(itemInfo({ id: 'WL-1', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'S' }))).toBe('implement'); + }); + it('rejects plan_complete above the implement caps (effort Large)', () => { + expect(classifyItemForDispatch(itemInfo({ id: 'WL-1', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'L' }))).toBeNull(); + }); + it('rejects plan_complete with unknown risk (fail-closed)', () => { + expect(classifyItemForDispatch(itemInfo({ id: 'WL-1', status: 'open', stage: 'plan_complete' }))).toBeNull(); + }); + it('maps completed/in_review without a fresh audit → audit', () => { + const now = Date.now(); + expect(classifyItemForDispatch( + itemInfo({ id: 'WL-1', status: 'completed', stage: 'in_review', updatedAt: new Date(now - 60_000).toISOString() }), + now, + )).toBe('audit'); + }); + it('rejects completed/in_review WITH a fresh audit', () => { + const now = Date.now(); + expect(classifyItemForDispatch( + itemInfo({ + id: 'WL-1', + status: 'completed', + stage: 'in_review', + updatedAt: new Date(now - 60_000).toISOString(), + auditedAt: new Date(now - 30_000).toISOString(), + }), + now, + )).toBeNull(); + }); + it('rejects completed items past the 7-day recency window', () => { + const now = Date.now(); + expect(classifyItemForDispatch( + itemInfo({ id: 'WL-1', status: 'completed', stage: 'in_review', updatedAt: new Date(now - DOWNTIME_AUDIT_RECENCY_WINDOW_MS - 60_000).toISOString() }), + now, + )).toBeNull(); + }); + it('rejects in_progress items (already claimed)', () => { + expect(classifyItemForDispatch(itemInfo({ id: 'WL-1', status: 'in_progress', stage: 'in_progress' }))).toBeNull(); + }); + it('rejects unknown states (fail-closed)', () => { + expect(classifyItemForDispatch(itemInfo({ id: 'WL-1', status: 'weird', stage: 'idea' }))).toBeNull(); + expect(classifyItemForDispatch({ id: 'WL-1' })).toBeNull(); // no status at all + }); +}); + +describe('coordinationTierRank', () => { + it('orders audit > implement > plan > intake', () => { + expect(coordinationTierRank('audit')).toBeLessThan(coordinationTierRank('implement')); + expect(coordinationTierRank('implement')).toBeLessThan(coordinationTierRank('plan')); + expect(coordinationTierRank('plan')).toBeLessThan(coordinationTierRank('intake')); + }); + it('ranks null below every tier', () => { + expect(coordinationTierRank(null)).toBeLessThan(coordinationTierRank('intake')); + }); +}); + +// ── dispatchFromCoordination ────────────────────────────────────────── + +describe('dispatchFromCoordination', () => { + it('dispatches the highest-priority tier first (implement over intake)', async () => { + const deps = makeCoordinationDeps({ + fetchItem: vi.fn() + .mockResolvedValueOnce({ ok: true, info: itemInfo({ id: 'WL-IMPL', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'S' }) }) + .mockResolvedValueOnce({ ok: true, info: itemInfo({ id: 'WL-INTAKE', status: 'open', stage: 'idea' }) }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + }); + const entries = [makeEntry('inst-intake', 'WL-INTAKE'), makeEntry('inst-impl', 'WL-IMPL')]; + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + const spawnCall = (deps.spawnAgentPane as ReturnType).mock.calls[0][0] as string; + expect(spawnCall).toContain('/skill:implement WL-IMPL'); + }); + + it('removes the dispatched entry from the coordination file (owner re-queues later)', async () => { + const entry = makeEntry('inst-1', 'WL-IMPL', '/worklog/root'); + writeCoordinationFile(testDir, { version: 1, entries: [entry] }); + const deps = makeCoordinationDeps({ + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo({ id: 'WL-IMPL', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'S' }) }), + }); + const outcome = await dispatchFromCoordination(deps, [entry], { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + expect(outcome.dispatched).toBe(true); + expect(getEntry(testDir, 'inst-1')).toBe(null); + }); + + it('skips entries whose item is not currently dispatchable (stale/in_progress)', async () => { + const deps = makeCoordinationDeps({ + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo({ id: 'WL-OLD', status: 'in_progress', stage: 'in_progress' }) }), + spawnAgentPane: vi.fn(), + }); + const outcome = await dispatchFromCoordination(deps, [makeEntry('inst-1', 'WL-OLD')], { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + expect(outcome.dispatched).toBe(false); + expect((deps.spawnAgentPane as ReturnType)).not.toHaveBeenCalled(); + expect(outcome.reason).toBe('no-candidate'); + }); + + it('preserves the CAS claim: a lost claim moves to the next entry', async () => { + const deps = makeCoordinationDeps({ + fetchItem: vi.fn() + .mockResolvedValueOnce({ ok: true, info: itemInfo({ id: 'WL-A', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'S' }) }) + .mockResolvedValueOnce({ ok: true, info: itemInfo({ id: 'WL-B', status: 'open', stage: 'intake_complete' }) }), + claimItem: vi.fn() + .mockResolvedValueOnce({ ok: false, reason: 'stale' }) // WL-A lost the race + .mockResolvedValueOnce({ ok: true }), // WL-B wins + }); + const entries = [makeEntry('inst-a', 'WL-A'), makeEntry('inst-b', 'WL-B')]; + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + const spawnCall = (deps.spawnAgentPane as ReturnType).mock.calls[0][0] as string; + expect(spawnCall).toContain('/skill:plan WL-B'); + }); + + it('gates the audit tier by the code-freeze marker (frozen → plan still runs)', async () => { + const deps = makeCoordinationDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + fetchItem: vi.fn() + .mockResolvedValueOnce({ ok: true, info: itemInfo({ id: 'WL-AUD', status: 'completed', stage: 'in_review', updatedAt: new Date(Date.now() - 60_000).toISOString() }) }) + .mockResolvedValueOnce({ ok: true, info: itemInfo({ id: 'WL-PLAN', status: 'open', stage: 'intake_complete' }) }), + }); + const entries = [makeEntry('inst-a', 'WL-AUD'), makeEntry('inst-b', 'WL-PLAN')]; + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + it('requires the audit tier to have ≥ 2 free slots (spare-capacity guard)', async () => { + const deps = makeCoordinationDeps({ + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo({ id: 'WL-AUD', status: 'completed', stage: 'in_review', updatedAt: new Date(Date.now() - 60_000).toISOString() }) }), + spawnAgentPane: vi.fn(), + }); + const entries = [makeEntry('inst-a', 'WL-AUD')]; + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir, freeSlots: 1 }); + expect(outcome.dispatched).toBe(false); + expect((deps.spawnAgentPane as ReturnType)).not.toHaveBeenCalled(); + }); + + it('fails closed to wl-error when every entry fetch fails', async () => { + const deps = makeCoordinationDeps({ + fetchItem: vi.fn().mockResolvedValue({ ok: false }), + }); + const entries = [makeEntry('inst-a', 'WL-A'), makeEntry('inst-b', 'WL-B')]; + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + expect(outcome.reason).toBe('wl-error'); + }); + + it('tolerates a per-entry fetch failure (fail-open for one broken worklog)', async () => { + const deps = makeCoordinationDeps({ + fetchItem: vi.fn() + .mockResolvedValueOnce({ ok: false }) // broken instance + .mockResolvedValueOnce({ ok: true, info: itemInfo({ id: 'WL-OK', status: 'open', stage: 'idea' }) }), + }); + const entries = [makeEntry('inst-bad', 'WL-BAD'), makeEntry('inst-ok', 'WL-OK')]; + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('intake'); + }); + + // ── Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX) ──────────────────── + // The coordination (leader) path must check due scheduled prompts FIRST, + // before ANY coordination-tier work — a due prompt dispatches instead of + // reaching the backlog tiers (AC3/AC6), gated by the same code-freeze + // marker as the audit/implement tiers (AC5). + + it('dispatches a due scheduled prompt FIRST in the coordination path', async () => { + const duePrompt: ScheduledPrompt = { id: 'refactor', prompt: '/skill:refactor', intervalDays: 3, lastTriggeredAt: null }; + const deps = makeCoordinationDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + // The coordination tiers must never be reached — the prompt dispatches + // instead (AC3 first-stage + AC6 no-cooldown). + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo({ id: 'WL-IMPL', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'S' }) }), + }); + const entries = [makeEntry('inst-impl', 'WL-IMPL')]; + + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + + expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('scheduled'); + // The prompt text is the dispatch payload — no work item wrap, pane + // named `Downtime ` (AC3 spawn path). + expect(deps.spawnAgentPane).toHaveBeenCalledWith('/skill:refactor', { + model: 'plan', + cwd: '/repo', + paneName: 'Downtime refactor', + }); + // No pre-dispatch claim and NO coordination-tier work (AC3/AC4/AC6). + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.fetchItem).not.toHaveBeenCalled(); + }); + + it('persists lastTriggeredAt and writes the scheduled log marker before the spawn (AC4)', async () => { + const duePrompt: ScheduledPrompt = { id: 'refactor', prompt: '/skill:refactor', intervalDays: 3, lastTriggeredAt: null }; + const deps = makeCoordinationDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + }); + + const outcome = await dispatchFromCoordination(deps, [], { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('scheduled'); + // The trigger timestamp is persisted (atomic config update) and the + // rolling log marker is written with kind scheduled + noItemComment. + expect(deps.recordScheduledPromptTrigger).toHaveBeenCalledWith( + '/repo', + 'refactor', + expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + ); + expect(deps.recordDispatch).toHaveBeenCalledWith( + expect.objectContaining({ itemId: 'refactor', kind: 'scheduled', cwd: '/repo', noItemComment: true }), + ); + // Marker + persist before spawn: the dispatch is recorded before the + // pane opens (fail-closed: an unrecorded dispatch never runs). + const persistOrder = (deps.recordScheduledPromptTrigger as ReturnType).mock.invocationCallOrder[0]; + const markerOrder = (deps.recordDispatch as ReturnType).mock.invocationCallOrder[0]; + const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(persistOrder).toBeLessThan(markerOrder); + expect(markerOrder).toBeLessThan(spawnOrder); + }); + + it('aborts the coordination-path spawn when the marker write fails (fail-closed, AC4)', async () => { + const duePrompt: ScheduledPrompt = { id: 'refactor', prompt: '/skill:refactor', intervalDays: 3, lastTriggeredAt: null }; + const deps = makeCoordinationDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + recordScheduledPromptTrigger: vi.fn().mockResolvedValue(false), + spawnAgentPane: vi.fn(), + }); + + const outcome = await dispatchFromCoordination(deps, [], { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + + // Fail-closed: an unrecorded dispatch never runs — the prompt stays due + // for the next idle slot and the backlog tiers are NOT reached either. + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('marker-write-failed'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('gates the coordination-path scheduled tier by the code-freeze marker (frozen → tiers still run)', async () => { + const duePrompt: ScheduledPrompt = { id: 'refactor', prompt: '/skill:refactor', intervalDays: 3, lastTriggeredAt: null }; + const deps = makeCoordinationDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo({ id: 'WL-PLAN', status: 'open', stage: 'intake_complete' }) }), + }); + const entries = [makeEntry('inst-plan', 'WL-PLAN')]; + + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + + // Scheduled prompts are held during a freeze (AC5); the audit/implement + // coordination tiers are skipped but plan/intake still run unchanged. + expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + it('falls through to the coordination tiers when no prompt is due (AC6)', async () => { + const deps = makeCoordinationDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo({ id: 'WL-IMPL', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'S' }) }), + }); + const entries = [makeEntry('inst-impl', 'WL-IMPL')]; + + const outcome = await dispatchFromCoordination(deps, entries, { model: 'plan', cwd: '/repo', coordinationDir: testDir }); + + expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); + // No prompt due → the existing tier pipeline runs unchanged. + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + const spawnCall = (deps.spawnAgentPane as ReturnType).mock.calls[0][0] as string; + expect(spawnCall).toContain('/skill:implement WL-IMPL'); + }); +}); + +// ── runCoordinationCheckIn ──────────────────────────────────────────── + +describe('runCoordinationCheckIn', () => { + it('upserts the instance most-important item on first check-in', async () => { + const deps = makeCoordinationDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-IMP', title: 'Most important', stage: 'idea', status: 'open' }, + }), + }); + const result = await runCoordinationCheckIn(deps, { cwd: '/repo', coordinationDir: testDir, instanceId: 'inst-1' }); + expect(result.offered).toBe('WL-IMP'); + const entry = getEntry(testDir, 'inst-1'); + expect(entry?.workItemId).toBe('WL-IMP'); + expect(entry?.directory).toBe('/repo'); + }); + + it('updates the entry when the most-important item changes', async () => { + const deps = makeCoordinationDeps({ + getNextImplementCandidate: vi.fn() + .mockResolvedValueOnce({ id: 'WL-A', title: 'A', stage: 'implement', status: 'open' }) + .mockResolvedValueOnce({ id: 'WL-B', title: 'B', stage: 'implement', status: 'open' }), + }); + await runCoordinationCheckIn(deps, { cwd: '/repo', coordinationDir: testDir, instanceId: 'inst-1' }); + await runCoordinationCheckIn(deps, { cwd: '/repo', coordinationDir: testDir, instanceId: 'inst-1' }); + expect(getEntry(testDir, 'inst-1')?.workItemId).toBe('WL-B'); + }); + + it('removes the own entry on a genuine empty backlog', async () => { + // First offer something, then the backlog empties. + const deps = makeCoordinationDeps({ + getNextImplementCandidate: vi.fn() + .mockResolvedValueOnce({ id: 'WL-A', title: 'A', stage: 'implement', status: 'open' }) + .mockResolvedValueOnce(null), + }); + await runCoordinationCheckIn(deps, { cwd: '/repo', coordinationDir: testDir, instanceId: 'inst-1' }); + expect(getEntry(testDir, 'inst-1')).not.toBe(null); + await runCoordinationCheckIn(deps, { cwd: '/repo', coordinationDir: testDir, instanceId: 'inst-1' }); + expect(getEntry(testDir, 'inst-1')).toBe(null); + }); + + it('keeps the existing entry when the computation hits a wl error (fail-open)', async () => { + const deps = makeCoordinationDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: false }), + getNextItem: vi.fn().mockResolvedValue({ ok: false }), + }); + writeCoordinationFile(testDir, { version: 1, entries: [makeEntry('inst-1', 'WL-EXISTING', '/repo')] }); + const result = await runCoordinationCheckIn(deps, { cwd: '/repo', coordinationDir: testDir, instanceId: 'inst-1' }); + expect(result.updated).toBe(false); + expect(getEntry(testDir, 'inst-1')?.workItemId).toBe('WL-EXISTING'); + }); +}); + +// ── Worker integration: leader vs non-leader (AC4) ──────────────────── + +function makeStatusPayload(overrides: Record = {}): Record { + return { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + ...overrides, + }; +} + +/** A poller stub whose fetcher returns the given payloads in order (last repeats). */ +function makeFetcher(payloads: Array | null>): ReturnType { + let idx = 0; + return vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => payloads[Math.min(idx++, payloads.length - 1)] ?? null, + })); +} + +/** Build a real worker wired to the coordination dir + a stub poller. */ +function makeCoordWorker(opts: { + coordinationDir: string; + instanceId: string; + depsOverrides?: Partial; + pollPayloads?: Array | null>; + thresholdMs?: number; +}): { worker: DowntimeWorker; deps: DowntimeWorkerDeps } { + const deps = makeCoordinationDeps(opts.depsOverrides); + const fetcher = makeFetcher(opts.pollPayloads ?? [makeStatusPayload()]); + const worker = createDowntimeWorker({ + coordinationDir: opts.coordinationDir, + instanceId: opts.instanceId, + poller: createDowntimePoller('http://localhost:8000', fetcher as never, 5000), + deps, + config: () => ({ + enabled: true, + thresholdMs: opts.thresholdMs ?? 60_000, + requiredFreeSlots: 1, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }), + leaseTtlSeconds: 300, + checkInIntervalMs: 30 * 60 * 1000, + }); + return { worker, deps }; +} + +describe('downtime worker leader/non-leader orchestration (coordination mode)', () => { + it('the first instance wins the election and becomes the leader', async () => { + const a = makeCoordWorker({ coordinationDir: testDir, instanceId: 'inst-a' }); + const b = makeCoordWorker({ coordinationDir: testDir, instanceId: 'inst-b' }); + // A ticks first → wins the file lock. + await a.worker.tick(); + expect(a.worker.isLeader).toBe(true); + // B ticks → sees the lease held by A → non-leader, no polling, no dispatch. + await b.worker.tick(); + expect(b.worker.isLeader).toBe(false); + // The lease file records A as the leader. + const lease = JSON.parse(readFileSync(join(testDir, LEASE_FILE), 'utf-8')) as { leaderId: string }; + expect(lease.leaderId).toBe('inst-a'); + }); + + it('only the leader polls the proxy and dispatches (AC4)', async () => { + const entries = [makeEntry('inst-a', 'WL-IMPL', '/repo')]; + writeCoordinationFile(testDir, { version: 1, entries }); + // A's own most-important item IS WL-IMPL (a plan_complete critical), so + // its startup check-in re-offers the same entry instead of clearing it. + const a = makeCoordWorker({ + coordinationDir: testDir, + instanceId: 'inst-a', + depsOverrides: { + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-IMPL', title: 'Impl me', stage: 'plan_complete', status: 'open' }, + }), + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo({ id: 'WL-IMPL', status: 'open', stage: 'plan_complete', risk: 'Low', effort: 'S' }) }), + }, + }); + const b = makeCoordWorker({ coordinationDir: testDir, instanceId: 'inst-b' }); + // A leader, B non-leader. All ticks under fake timers so the idle tracker + // and lease timestamps stay consistent. + vi.useFakeTimers(); + vi.setSystemTime(10_000_000); + try { + await a.worker.tick(); + await b.worker.tick(); + expect(a.worker.isLeader).toBe(true); + expect(b.worker.isLeader).toBe(false); + + // Leader A polls and (after the idle threshold) dispatches from the + // coordination file; non-leader B never polls. + await a.worker.tick(); // starts idle run (second poll — still idle) + vi.setSystemTime(10_000_000 + 60_001); + await a.worker.tick(); // threshold met → dispatch + expect(a.deps.spawnAgentPane).toHaveBeenCalled(); + // The dispatched entry was REMOVED from the coordination file. + expect(getEntry(testDir, 'inst-a')).toBe(null); + // B never dispatched. + expect(b.deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('a stale leader lease triggers takeover by another instance (AC2)', async () => { + // Pre-seed a STALE lease for a dead leader (expired > 5 min ago). + const deadLeader = createLeaderElectionManager({ worklogDir: testDir, instanceId: 'dead-leader', leaseTtlSeconds: 300 }); + expect(deadLeader.attemptElection()).toBe(true); + deadLeader.releaseLeadership(); // drop the lock; keep only the lease + const leasePath = join(testDir, LEASE_FILE); + const stale = { + leaderId: 'dead-leader', + acquiredAt: new Date(Date.now() - 400_000).toISOString(), + ttlSeconds: 300, + }; + writeFileSync(leasePath, JSON.stringify(stale), 'utf-8'); + + const a = makeCoordWorker({ coordinationDir: testDir, instanceId: 'inst-a' }); + await a.worker.tick(); + expect(a.worker.isLeader).toBe(true); + }); + + it('non-leader instances perform the first check-in but never dispatch', async () => { + const deps = makeCoordinationDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-MOST', title: 'Most important', stage: 'intake_complete', status: 'open' }, + }), + }); + const a = makeCoordWorker({ coordinationDir: testDir, instanceId: 'inst-a', depsOverrides: deps }); + const b = makeCoordWorker({ coordinationDir: testDir, instanceId: 'inst-b', depsOverrides: deps }); + await a.worker.tick(); // a is leader + await b.worker.tick(); // b non-leader + // The non-leader's first check-in offers its most-important item. + const entry = getEntry(testDir, 'inst-b'); + expect(entry?.workItemId).toBe('WL-MOST'); + // The non-leader does NOT dispatch. + expect(b.deps.spawnAgentPane).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/packages/herdr/src/coordination.test.ts b/packages/herdr/src/coordination.test.ts new file mode 100644 index 000000000..bae55ae42 --- /dev/null +++ b/packages/herdr/src/coordination.test.ts @@ -0,0 +1,245 @@ +/** + * Unit tests for coordination.ts — shared downtime coordination file + * (WL-0MSXH9UT6008151F, parent WL-0MST3OJ8S0001ROL). + * + * Tests cover: + * - File format (version + per-instance entries) + * - Entry upsert / update / removal + * - Exclusive-lock serialization (concurrent write contention) + * - Stale-entry pruning (5-minute lease bound) + * - Fail-safe behavior (missing / corrupt file, lock contention) + * - Atomic write (tmp+rename — no partial JSON) + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + readCoordinationFile, + writeCoordinationFile, + upsertEntry, + removeEntry, + getEntry, + pruneStaleEntries, + mergeEntries, + tryAcquireCoordLock, + COORDINATION_FILE, + COORDINATION_LOCK_FILE, + COORDINATION_FILE_VERSION, + type CoordinationEntry, + type CoordinationData, +} from './coordination.js'; + +// ── Test fixtures ────────────────────────────────────────────────────── + +let testDir: string; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'herdr-coordination-')); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +/** Build a valid entry for the test directory. */ +function makeEntry( + instanceId: string, + workItemId: string, + opts?: Partial, +): CoordinationEntry { + const now = new Date().toISOString(); + return { + instanceId, + workItemId, + directory: testDir, + assignedAt: opts?.assignedAt ?? now, + lastUpdated: opts?.lastUpdated ?? now, + }; +} + +const COORD_PATH = () => join(testDir, COORDINATION_FILE); + +// ── read/write ───────────────────────────────────────────────────────── + +describe('readCoordinationFile / writeCoordinationFile', () => { + it('returns null when the file does not exist', () => { + expect(readCoordinationFile(testDir)).toBe(null); + }); + + it('round-trips a written file', () => { + const data: CoordinationData = { + version: COORDINATION_FILE_VERSION, + entries: [makeEntry('inst-1', 'WL-ABC')], + }; + expect(writeCoordinationFile(testDir, data)).toBe(true); + expect(readCoordinationFile(testDir)).toEqual(data); + }); + + it('returns null for corrupt JSON (fail-safe)', () => { + writeFileSync(COORD_PATH(), '{ not valid json !!!', 'utf-8'); + expect(readCoordinationFile(testDir)).toBe(null); + }); + + it('returns null for an empty file (fail-safe)', () => { + writeFileSync(COORD_PATH(), '', 'utf-8'); + expect(readCoordinationFile(testDir)).toBe(null); + }); + + it('returns null when entries is not an array (fail-safe)', () => { + writeFileSync(COORD_PATH(), JSON.stringify({ version: 1, entries: 'oops' }), 'utf-8'); + expect(readCoordinationFile(testDir)).toBe(null); + }); + + it('drops malformed entries but keeps valid ones (fail-safe)', () => { + writeFileSync( + COORD_PATH(), + JSON.stringify({ + version: 1, + entries: [ + 'garbage', + { noInstanceId: true }, + makeEntry('inst-ok', 'WL-OK'), + ], + }), + 'utf-8', + ); + const data = readCoordinationFile(testDir); + expect(data).not.toBe(null); + expect(data!.entries).toHaveLength(1); + expect(data!.entries[0].instanceId).toBe('inst-ok'); + }); +}); + +// ── upsert / get / remove ────────────────────────────────────────────── + +describe('upsertEntry / getEntry / removeEntry', () => { + it('adds a new entry on first write', () => { + expect(upsertEntry(testDir, makeEntry('inst-1', 'WL-ONE'))).toBe(true); + const entry = getEntry(testDir, 'inst-1'); + expect(entry).not.toBe(null); + expect(entry!.workItemId).toBe('WL-ONE'); + expect(entry!.directory).toBe(testDir); + }); + + it('updates an existing entry in place (changed most-important item)', () => { + upsertEntry(testDir, makeEntry('inst-1', 'WL-ONE')); + upsertEntry(testDir, makeEntry('inst-1', 'WL-TWO', { lastUpdated: new Date().toISOString() })); + const data = readCoordinationFile(testDir); + expect(data!.entries).toHaveLength(1); // replaced, not duplicated + expect(data!.entries[0].workItemId).toBe('WL-TWO'); + }); + + it('keeps multiple instances distinct', () => { + upsertEntry(testDir, makeEntry('inst-1', 'WL-ONE')); + upsertEntry(testDir, makeEntry('inst-2', 'WL-TWO')); + const data = readCoordinationFile(testDir); + expect(data!.entries).toHaveLength(2); + expect(getEntry(testDir, 'inst-1')!.workItemId).toBe('WL-ONE'); + expect(getEntry(testDir, 'inst-2')!.workItemId).toBe('WL-TWO'); + }); + + it('removes an entry and returns it', () => { + upsertEntry(testDir, makeEntry('inst-1', 'WL-ONE')); + const removed = removeEntry(testDir, 'inst-1'); + expect(removed?.workItemId).toBe('WL-ONE'); + expect(getEntry(testDir, 'inst-1')).toBe(null); + expect(existsSync(COORD_PATH())).toBe(true); // file remains (empty), owner re-checkins + }); + + it('returns null when removing an absent instance', () => { + expect(removeEntry(testDir, 'nobody')).toBe(null); + }); + + it('rejects an empty instanceId', () => { + expect(upsertEntry(testDir, makeEntry('', 'WL-NOPE'))).toBe(false); + }); +}); + +// ── pruning ──────────────────────────────────────────────────────────── + +describe('pruneStaleEntries', () => { + it('removes only entries older than the lease bound', () => { + const now = 1_000_000; + const old = new Date(now - 400_000).toISOString(); // > 5 min stale + const fresh = new Date(now - 60_000).toISOString(); // < 5 min + upsertEntry(testDir, makeEntry('stale', 'WL-STALE', { lastUpdated: old })); + upsertEntry(testDir, makeEntry('fresh', 'WL-FRESH', { lastUpdated: fresh })); + const removed = pruneStaleEntries(testDir, 300_000, now); + expect(removed).toBe(1); + expect(getEntry(testDir, 'stale')).toBe(null); + expect(getEntry(testDir, 'fresh')).not.toBe(null); + }); + + it('returns 0 when nothing is stale', () => { + upsertEntry(testDir, makeEntry('inst-1', 'WL-ONE')); + expect(pruneStaleEntries(testDir, 300_000, Date.now())).toBe(0); + }); + + it('returns 0 when the file is missing (fail-safe)', () => { + expect(pruneStaleEntries(testDir, 300_000)).toBe(0); + }); +}); + +// ── mergeEntries (batch check-in) ────────────────────────────────────── + +describe('mergeEntries', () => { + it('writes a batch of entries', () => { + const n = mergeEntries(testDir, [ + makeEntry('inst-1', 'WL-A'), + makeEntry('inst-2', 'WL-B'), + ]); + expect(n).toBe(2); + expect(readCoordinationFile(testDir)!.entries).toHaveLength(2); + }); + + it('overwrites existing entries for the same instance', () => { + upsertEntry(testDir, makeEntry('inst-1', 'WL-OLD')); + mergeEntries(testDir, [makeEntry('inst-1', 'WL-NEW')]); + expect(readCoordinationFile(testDir)!.entries).toHaveLength(1); + expect(getEntry(testDir, 'inst-1')!.workItemId).toBe('WL-NEW'); + }); + + it('returns 0 for an empty batch', () => { + expect(mergeEntries(testDir, [])).toBe(0); + }); +}); + +// ── exclusive lock (flock-equivalent) ───────────────────────────────── + +describe('tryAcquireCoordLock', () => { + it('serializes concurrent writers: second holder is refused until release', () => { + const release = tryAcquireCoordLock(testDir); + expect(release).not.toBe(null); + + // Second concurrent writer cannot acquire while the first holds it + expect(tryAcquireCoordLock(testDir)).toBe(null); + // Its upsert fails open (skipped this cycle) + expect(upsertEntry(testDir, makeEntry('inst-2', 'WL-TWO'))).toBe(false); + + release!(); + // After release, a new writer can proceed (no lock held by us now) + expect(upsertEntry(testDir, makeEntry('inst-2', 'WL-TWO'))).toBe(true); + }); + + it('cleans up the lock file on release', () => { + const release = tryAcquireCoordLock(testDir); + expect(existsSync(join(testDir, COORDINATION_LOCK_FILE))).toBe(true); + release!(); + expect(existsSync(join(testDir, COORDINATION_LOCK_FILE))).toBe(false); + }); +}); + +// ── atomicity ────────────────────────────────────────────────────────── + +describe('atomic write', () => { + it('never leaves a tmp file behind after a successful write', () => { + upsertEntry(testDir, makeEntry('inst-1', 'WL-ONE')); + const leftovers = readFileSync(COORD_PATH(), 'utf-8'); + expect(leftovers).toContain('WL-ONE'); // real content, not a tmp artifact + // tmp file cleaned up by rename (the atomic swap) + const tmpPath = `${join(testDir, COORDINATION_FILE)}.tmp`; + expect(existsSync(tmpPath)).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/herdr/src/coordination.ts b/packages/herdr/src/coordination.ts new file mode 100644 index 000000000..ff36cabd1 --- /dev/null +++ b/packages/herdr/src/coordination.ts @@ -0,0 +1,295 @@ +/** + * packages/herdr/src/coordination.ts — Shared downtime coordination file + * + * Parent: WL-0MSXH9UT6008151F (Coordination File Module), + * parent of WL-0MST3OJ8S0001ROL (Refactor Downtime Dispatcher). + * + * Manages the shared coordination JSON file at + * `/.worklog/downtime-coordination.json`: + * + * - One entry per herdr instance: `{instanceId, workItemId, directory, + * assignedAt, lastUpdated}`. + * - Safe concurrent access: writes happen under an exclusive lock file + * (`O_CREAT|O_EXCL` — the POSIX atomic create, equivalent to flock for + * the single-machine v1 scope) followed by an atomic tmp+rename write, + * so two instances updating simultaneously never corrupt the file and a + * crash never leaves a half-written JSON. + * - The leader prunes stale entries (lastUpdated older than the 5-minute + * lease) on each dispatch cycle so crashed instances' items do not + * starve the queue. + * + * Fail-safe by contract (parent constraint): a missing or unreadable + * coordination file, or a failed lock acquisition, degrades to the + * pre-refactor behavior — the operation is skipped for that cycle, never + * throwing and never removing another instance's entry. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// ── Constants ────────────────────────────────────────────────────────── + +/** Coordination file name (inside `.worklog/`). */ +export const COORDINATION_FILE = 'downtime-coordination.json'; + +/** Exclusive lock file name used to serialize read-modify-write cycles. */ +export const COORDINATION_LOCK_FILE = 'downtime-coordination.lock'; + +/** Current coordination file format version. */ +export const COORDINATION_FILE_VERSION = 1; + +// ── Types ────────────────────────────────────────────────────────────── + +/** + * One instance's contribution to the shared coordination list. + * + * - `instanceId` — unique herdr instance identifier. + * - `workItemId` — the instance's most-important work item id. + * - `directory` — the worklog ROOT (parent of `.worklog`) this instance + * operates on; the leader dispatches work in that directory. + * - `assignedAt` / `lastUpdated` — ISO-8601 UTC timestamps (created at / + * last verified at). + */ +export interface CoordinationEntry { + instanceId: string; + workItemId: string; + directory: string; + assignedAt: string; + lastUpdated: string; +} + +/** Full coordination file shape. */ +export interface CoordinationData { + version: number; + entries: CoordinationEntry[]; +} + +// ── Low-level read/write (lockless, atomic) ─────────────────────────── + +/** Resolve the coordination file path. */ +function coordinationPath(worklogDir: string): string { + return path.join(worklogDir, COORDINATION_FILE); +} + +/** Resolve the coordination lock file path. */ +function lockFilePath(worklogDir: string): string { + return path.join(worklogDir, COORDINATION_LOCK_FILE); +} + +/** + * Read and parse the coordination file. + * + * FAIL-SAFE: a missing file returns `null` (the first-run state — the + * caller creates it on the next write); an unreadable or corrupt file also + * returns `null` so a broken coordination file can never crash an instance + * or silently mislead a reader. Never throws. + */ +export function readCoordinationFile(worklogDir: string): CoordinationData | null { + try { + const content = fs.readFileSync(coordinationPath(worklogDir), 'utf-8'); + if (!content.trim()) return null; + const parsed = JSON.parse(content) as unknown; + if (typeof parsed !== 'object' || parsed === null) return null; + const data = parsed as Partial; + if (!Array.isArray(data.entries)) return null; + // Validate each entry minimally: it must be an object carrying an + // instanceId string. Malformed entries are dropped (fail-safe). + const entries: CoordinationEntry[] = []; + for (const e of data.entries) { + if (typeof e !== 'object' || e === null) continue; + const entry = e as Partial; + if (typeof entry.instanceId !== 'string' || entry.instanceId.length === 0) continue; + entries.push({ + instanceId: entry.instanceId, + workItemId: typeof entry.workItemId === 'string' ? entry.workItemId : '', + directory: typeof entry.directory === 'string' ? entry.directory : '', + assignedAt: typeof entry.assignedAt === 'string' ? entry.assignedAt : new Date(0).toISOString(), + lastUpdated: typeof entry.lastUpdated === 'string' ? entry.lastUpdated : new Date(0).toISOString(), + }); + } + return { version: typeof data.version === 'number' ? data.version : COORDINATION_FILE_VERSION, entries }; + } catch { + return null; // missing / unreadable / corrupt → fail-safe + } +} + +/** + * Atomically write the coordination file (tmp + rename). A concurrent + * writer can never observe a partially-written JSON, and a crash between + * the tmp write and the rename leaves the previous file intact. + * Returns false on I/O failure (fail-safe, never throws). + */ +export function writeCoordinationFile(worklogDir: string, data: CoordinationData): boolean { + try { + const fp = coordinationPath(worklogDir); + const tmpPath = `${fp}.tmp`; + fs.mkdirSync(worklogDir, { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(data), 'utf-8'); + fs.renameSync(tmpPath, fp); + return true; + } catch { + return false; + } +} + +// ── Exclusive lock (flock-equivalent for v1) ────────────────────────── + +/** + * Try to acquire the coordination lock (atomic `O_CREAT|O_EXCL`). + * Returns a release function, or null when another writer holds the lock. + * The lock is a placeholder file; it never carries coordination data. + */ +export function tryAcquireCoordLock(worklogDir: string): (() => void) | null { + try { + const fp = lockFilePath(worklogDir); + // Atomic exclusive create — fails (EEXIST) while another holder owns it. + const fd = fs.openSync(fp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o644); + fs.closeSync(fd); + return () => { + try { + fs.unlinkSync(fp); + } catch { + // already gone — ignore + } + }; + } catch { + return null; + } +} + +/** + * Run `fn` under the coordination lock. Reads and writes are serialized: + * whichever instance acquires the lock first completes its read-modify-write + * before the next instance proceeds. When the lock is held elsewhere + * (concurrent update), the operation fails open — `fn` is never invoked and + * `null` is returned (the caller skips this cycle). + */ +export function withCoordLock( + worklogDir: string, + fn: () => T, +): T | null { + const release = tryAcquireCoordLock(worklogDir); + if (release === null) return null; + try { + return fn(); + } finally { + release(); + } +} + +// ── Entry management ────────────────────────────────────────────────── + +/** + * Add or update one instance's entry (read-modify-write under the lock). + * When the entry already exists it is replaced with the new values + * (the callers pass the fresh `workItemId`/`directory`/timestamps), so a + * changed most-important item updates in place. + * + * Returns true when the entry was persisted, false on lock contention or + * I/O failure (fail-safe — the instance retries at its next check-in). + */ +export function upsertEntry( + worklogDir: string, + entry: CoordinationEntry, +): boolean { + if (entry.instanceId.length === 0) return false; + return withCoordLock(worklogDir, () => { + const data: CoordinationData = readCoordinationFile(worklogDir) ?? { + version: COORDINATION_FILE_VERSION, + entries: [], + }; + const next = data.entries.filter((e) => e.instanceId !== entry.instanceId); + next.push(entry); + return writeCoordinationFile(worklogDir, { ...data, entries: next }); + }) ?? false; +} + +/** + * Remove one instance's entry (after the leader dispatched its item). + * Returns the removed entry, or null when it was not present / the + * operation failed (lock contention, I/O error — fail-safe). + */ +export function removeEntry( + worklogDir: string, + instanceId: string, +): CoordinationEntry | null { + return withCoordLock(worklogDir, () => { + const data = readCoordinationFile(worklogDir); + if (data === null) return null; + let removed: CoordinationEntry | null = null; + const next = data.entries.filter((e) => { + if (e.instanceId === instanceId) { + removed = e; + return false; + } + return true; + }); + if (removed === null) return null; + if (!writeCoordinationFile(worklogDir, { ...data, entries: next })) return null; + return removed; + }) ?? null; +} + +/** + * Read one instance's entry. Returns null when absent. + * Fail-safe: missing/corrupt file → null. + */ +export function getEntry( + worklogDir: string, + instanceId: string, +): CoordinationEntry | null { + const data = readCoordinationFile(worklogDir); + if (data === null) return null; + return data.entries.find((e) => e.instanceId === instanceId) ?? null; +} + +/** + * Prune entries whose `lastUpdated` is older than `maxAgeMs` (the leader + * runs this on each dispatch cycle — the 5-minute lease bound). Crashed or + * idle instances' stale items stop blocking the queue; their owners + * re-add on their next check-in. Returns the number of entries removed. + * Fail-safe: lock contention or I/O failure → 0 removed, never throws. + */ +export function pruneStaleEntries( + worklogDir: string, + maxAgeMs: number, + nowMs: number = Date.now(), +): number { + return withCoordLock(worklogDir, () => { + const data = readCoordinationFile(worklogDir); + if (data === null) return 0; + const before = data.entries.length; + const next = data.entries.filter((e) => { + const updated = new Date(e.lastUpdated).getTime(); + return Number.isFinite(updated) && nowMs - updated < maxAgeMs; + }); + if (next.length === before) return 0; + if (!writeCoordinationFile(worklogDir, { ...data, entries: next })) return 0; + return before - next.length; + }) ?? 0; +} + +/** + * Merge a set of instance entries into the file (batch upsert). Used at + * startup / scheduled check-ins when multiple instances may need their + * entries refreshed in one cycle. Returns the number of entries written. + * Fail-safe: lock contention or I/O failure → 0. + */ +export function mergeEntries( + worklogDir: string, + entries: CoordinationEntry[], +): number { + if (entries.length === 0) return 0; + return withCoordLock(worklogDir, () => { + const data: CoordinationData = readCoordinationFile(worklogDir) ?? { + version: COORDINATION_FILE_VERSION, + entries: [], + }; + const byId = new Map(data.entries.map((e) => [e.instanceId, e])); + for (const entry of entries) { + byId.set(entry.instanceId, entry); + } + const next = Array.from(byId.values()); + return writeCoordinationFile(worklogDir, { ...data, entries: next }) ? next.length : 0; + }) ?? 0; +} \ No newline at end of file diff --git a/packages/herdr/src/downtime-disable-marker.ts b/packages/herdr/src/downtime-disable-marker.ts new file mode 100644 index 000000000..491940426 --- /dev/null +++ b/packages/herdr/src/downtime-disable-marker.ts @@ -0,0 +1,65 @@ +/** + * Durable per-worklog-root downtime-disable marker (parent WL-0MT4BWUHW008LIFE). + * + * The `d` shortcut's per-instance override is in-memory and resets on plugin + * restart (per-instance scoping, WL-0MSZ4NSOE007AQEF). To make a disable + * survive a pane/plugin restart without touching the shared settings file, + * toggle() writes a small marker file at the worklog root: + * + * `/.herdr-downtime-disabled` + * + * On worker construction the marker is read back: its presence restores + * `override = false` (disabled) unless an explicit `override` is provided to + * createDowntimeWorker (explicit wins, marker is the fallback). + * + * All IO here is best-effort and non-fatal: toggle() must never crash the TUI + * because the marker could not be written (e.g. read-only FS), and an absent + * marker must never throw on removal. Synchronous fs is used deliberately so + * the toggle() contract stays synchronous {void} and the marker state is + * deterministic for callers and tests. + */ +import { existsSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Marker file name, placed directly in the worklog root (cwd). */ +export const DOWNTIME_DISABLE_MARKER_FILE = '.herdr-downtime-disabled'; + +/** Absolute path of the disable marker for a given worklog root. */ +export function disableMarkerPath(cwd: string): string { + return join(cwd, DOWNTIME_DISABLE_MARKER_FILE); +} + +/** True when the disable marker exists in the worklog root (fail-closed on fs errors). */ +export function disableMarkerExists(cwd: string): boolean { + try { + return existsSync(disableMarkerPath(cwd)); + } catch { + return false; + } +} + +/** + * Write the disable marker (no-op when it already exists). Best-effort: + * a failed write (e.g. cwd missing, read-only FS) is swallowed — the + * in-memory override still applies for this process lifetime. + */ +export function writeDisableMarker(cwd: string): void { + try { + const path = disableMarkerPath(cwd); + if (!existsSync(path)) writeFileSync(path, '', 'utf8'); + } catch { + // Non-fatal: toggle must never crash on marker IO. + } +} + +/** + * Remove the disable marker (no-op when absent). Best-effort: a failed + * removal never throws. + */ +export function removeDisableMarker(cwd: string): void { + try { + rmSync(disableMarkerPath(cwd), { force: true }); + } catch { + // Non-fatal: toggle must never crash on marker IO. + } +} \ No newline at end of file diff --git a/packages/herdr/src/downtime-log.test.ts b/packages/herdr/src/downtime-log.test.ts index 6b7e56bae..b1012b675 100644 --- a/packages/herdr/src/downtime-log.test.ts +++ b/packages/herdr/src/downtime-log.test.ts @@ -5,6 +5,15 @@ * The log lives at `/.worklog/downtime-dispatches.log` and is bounded * (rolling): the file keeps only the most recent DOWNTIME_LOG_MAX_ENTRIES * entries so it cannot grow unbounded over a long-lived plugin pane. + * + * Stale-window marker filter tests (WL-0MT47BMR7003ZQ66, parent + * WL-0MT3PHW4I002SNOV): recentAuditDispatchedItemIds pins the 2h stale + * window that powers the single-active-audit guard — an audit dispatch + * marker older than the window is treated as stale (the audit pane may + * have crashed without updating the work item) and ignored, so a new audit + * dispatch can proceed. Red phase: the helper does not exist yet — the new + * tests fail and the existing suite stays green until the implementation + * slice lands (WL-0MT47BQAT00375VB). */ import { describe, it, expect, afterEach } from 'vitest'; @@ -13,6 +22,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { appendDowntimeLogEntry, + appendCoordinationLogEntry, auditDispatchedItemIds, implementDispatchedItemIds, planDispatchedItemStages, @@ -20,7 +30,9 @@ import { dispatchedItemStages, readDowntimeLogEntries, DOWNTIME_LOG_FILE, + COORDINATION_LOG_FILE, DOWNTIME_LOG_MAX_ENTRIES, + recentAuditDispatchedItemIds, } from './downtime-log.js'; const tempDirs: string[] = []; @@ -44,6 +56,40 @@ function readLog(cwd: string): string[] { .filter((line) => line.trim() !== ''); } +describe('coordination log (WL-0MSXHAE290067VAL)', () => { + it('appends coordination operations to a SEPARATE rolling file', async () => { + const cwd = makeTempCwd(); + await appendCoordinationLogEntry(cwd, { + kind: 'coordination', + operation: 'checkin', + instanceId: 'inst-1', + workItemId: 'WL-A', + at: '2026-01-01T00:00:00.000Z', + }); + // The DISPATCH log is untouched by coordination entries (the marker + // readers that scan it must never see coordination records). + expect(existsInLog(cwd, DOWNTIME_LOG_FILE)).toBe(false); + // The COORDINATION log carries the entry. + const raw = readFileSync(join(cwd, '.worklog', COORDINATION_LOG_FILE), 'utf8'); + expect(raw).toContain('"operation":"checkin"'); + expect(raw).toContain('"workItemId":"WL-A"'); + }); + + it('never throws when the worklog dir is unwritable (fail-closed)', async () => { + const cwd = '/nonexistent/path/' + Math.random().toString(36).slice(2); + await expect(appendCoordinationLogEntry(cwd, { kind: 'coordination', operation: 'election', at: 'x' })).resolves.toBeUndefined(); + }); +}); + +function existsInLog(cwd: string, file: string): boolean { + try { + readFileSync(join(cwd, '.worklog', file), 'utf8'); + return true; + } catch { + return false; + } +} + describe('downtime rolling log', () => { it('creates .worklog and appends a JSONL entry under the given cwd', async () => { const cwd = makeTempCwd(); @@ -136,6 +182,73 @@ describe('auditDispatchedItemIds (audit-tier-only scope guard)', () => { }); }); +describe('recentAuditDispatchedItemIds (stale-window marker filter)', () => { + // Stale-audit window shared with the dispatcher (WL-0MT3PHW4I002SNOV): an + // audit dispatch marker older than 2h is treated as stale — the audit + // pane may have crashed without updating the work item — so it must not + // block a new audit dispatch. + const WINDOW_MS = 2 * 60 * 60 * 1000; // 2h + const NOW = new Date('2026-01-01T12:00:00.000Z').getTime(); + const freshAudit = { + itemId: 'WL-FRESH', + kind: 'audit', + dispatchedAt: new Date(NOW - 10 * 60 * 1000).toISOString(), // 10m ago + }; + const staleAudit = { + itemId: 'WL-STALE', + kind: 'audit', + dispatchedAt: new Date(NOW - WINDOW_MS - 60 * 1000).toISOString(), // >2h ago + }; + + it('keeps only audit-kind markers whose dispatchedAt is within the stale window', () => { + const ids = recentAuditDispatchedItemIds( + [ + freshAudit, + staleAudit, + { itemId: 'WL-PLAN', kind: 'plan', dispatchedAt: freshAudit.dispatchedAt }, // other kind → scoped out + { kind: 'audit' }, // error-style entry without itemId → ignored + ], + WINDOW_MS, + NOW, + ); + expect([...ids]).toEqual(['WL-FRESH']); + }); + + it('boundary inclusive: a marker dispatched exactly windowMs ago is kept', () => { + const ids = recentAuditDispatchedItemIds( + [{ itemId: 'WL-EDGE', kind: 'audit', dispatchedAt: new Date(NOW - WINDOW_MS).toISOString() }], + WINDOW_MS, + NOW, + ); + expect([...ids]).toEqual(['WL-EDGE']); + }); + + it('boundary: a marker just past the window is dropped (stale)', () => { + const ids = recentAuditDispatchedItemIds( + [{ itemId: 'WL-PAST', kind: 'audit', dispatchedAt: new Date(NOW - WINDOW_MS - 1).toISOString() }], + WINDOW_MS, + NOW, + ); + expect([...ids]).toEqual([]); + }); + + it('a marker without a parseable dispatchedAt is excluded (fail-closed: no active evidence)', () => { + const ids = recentAuditDispatchedItemIds( + [ + { itemId: 'WL-NODATE', kind: 'audit' }, // missing dispatchedAt + { itemId: 'WL-BADDATE', kind: 'audit', dispatchedAt: 'not-a-date' }, + ], + WINDOW_MS, + NOW, + ); + expect([...ids]).toEqual([]); + }); + + it('returns an empty set for empty input', () => { + expect([...recentAuditDispatchedItemIds([], WINDOW_MS, NOW)]).toEqual([]); + }); +}); + describe('implementDispatchedItemIds (implement-tier-only scope guard)', () => { it('collects only implement-kind entries that carry an itemId', () => { const ids = implementDispatchedItemIds([ diff --git a/packages/herdr/src/downtime-log.ts b/packages/herdr/src/downtime-log.ts index 1815e8b24..493db992e 100644 --- a/packages/herdr/src/downtime-log.ts +++ b/packages/herdr/src/downtime-log.ts @@ -23,6 +23,51 @@ import { join } from 'node:path'; /** File name of the downtime dispatch audit log inside `.worklog/`. */ export const DOWNTIME_LOG_FILE = 'downtime-dispatches.log'; +/** + * File name of the coordination ops log inside `.worklog/` — a separate + * rolling log for leader-election / coordination-file operations + * (WL-0MSXHAE290067VAL, parent WL-0MST3OJ8S0001ROL): check-ins, + * elections/re-elections, and stale-entry pruning. Kept SEPARATE from the + * dispatch log so the dispatch-marker readers (which scan + * `downtime-dispatches.log` by kind) never see coordination entries. + */ +export const COORDINATION_LOG_FILE = 'downtime-coordination.log'; + +/** + * One parsed coordination-log entry: a `kind: 'coordination'` record of a + * check-in (`operation: 'checkin'`), an election win / leadership takeover + * (`operation: 'election'`), or stale-entry pruning (`operation: 'prune'`). + * All fields optional so malformed or foreign lines never break parsing. + */ +export interface CoordinationLogEntry { + kind?: string; + operation?: string; + instanceId?: string; + /** Work item id offered at a check-in (or null/absent when emptied). */ + workItemId?: string | null; + /** Number of entries removed by a prune operation. */ + prunedCount?: number; + at?: string; +} + +/** + * Append one coordination operation to `.worklog/downtime-coordination.log` + * (same bounded-rolling behavior as the dispatch log — most recent + * DOWNTIME_LOG_MAX_ENTRIES). Fail-closed: never throws; a write failure + * is swallowed (audit logging must never crash the worker). + */ +export async function appendCoordinationLogEntry( + cwd: string, + entry: CoordinationLogEntry, +): Promise { + try { + // Reuse the bounded JSONL append machinery with a different filename. + await appendRollingJsonl(cwd, COORDINATION_LOG_FILE, JSON.stringify(entry)); + } catch { + // fail-closed: coordination logging must never crash the worker + } +} + /** Rolling bound: keep at most this many entries in the log file. */ export const DOWNTIME_LOG_MAX_ENTRIES = 100; @@ -123,6 +168,48 @@ export function auditDispatchedItemIds(entries: DowntimeLogEntry[]): Set return dispatchedItemIds(entries, 'audit'); } +/** + * Build the set of itemIds with a `kind` marker whose `dispatchedAt` falls + * within `windowMs` of `now` (fresh markers only, WL-0MT3PHW4I002SNOV). + * Powers the active-audit single-flight check: an audit dispatch marker + * older than the stale window (`DOWNTIME_AUDIT_STALE_WINDOW_MS`, 2h) is + * treated as stale — the audit pane may have crashed without updating the + * work item — and ignored, so a NEW audit dispatch can proceed. Entries + * without an itemId, without a parseable `dispatchedAt`, or of another + * kind are ignored (fail-closed: missing evidence never claims an active + * audit). + */ +export function recentDispatchedItemIds( + entries: DowntimeLogEntry[], + kind: string, + windowMs: number, + now: number = Date.now(), +): Set { + const cutoff = now - windowMs; + const ids = new Set(); + for (const e of entries) { + if (e.kind !== kind || typeof e.itemId !== 'string' || e.itemId.length === 0) continue; + if (typeof e.dispatchedAt !== 'string') continue; // unrecognized timestamp → no active evidence + const t = Date.parse(e.dispatchedAt); + if (Number.isNaN(t) || t < cutoff) continue; // unparseable or stale + ids.add(e.itemId); + } + return ids; +} + +/** + * Build the set of itemIds with a NON-STALE audit dispatch marker + * (`kind === 'audit'`, dispatched within `windowMs` of `now`). Plan/intake/ + * implement markers are scoped out (audit-tier-only, WL-0MT3PHW4I002SNOV). + */ +export function recentAuditDispatchedItemIds( + entries: DowntimeLogEntry[], + windowMs: number, + now: number = Date.now(), +): Set { + return recentDispatchedItemIds(entries, 'audit', windowMs, now); +} + /** * Build the set of itemIds the downtime worker has already dispatched for * `/skill:implement` (`kind === 'implement'` entries only). Audit/plan/ @@ -180,21 +267,31 @@ export function intakeDispatchedItemStages(entries: DowntimeLogEntry[]): Map { + await appendRollingJsonl(cwd, DOWNTIME_LOG_FILE, entry); +} + +/** + * Bounded JSONL append shared by the dispatch and coordination logs: + * write `line` to `/.worklog/`, creating `.worklog` if needed + * and trimming to the most recent DOWNTIME_LOG_MAX_ENTRIES lines. Throws + * on I/O failure — callers must catch (fail-closed). + */ +async function appendRollingJsonl(cwd: string, file: string, line: string): Promise { const dir = join(cwd, '.worklog'); - const file = join(dir, DOWNTIME_LOG_FILE); + const filePath = join(dir, file); await mkdir(dir, { recursive: true }); let lines: string[] = []; try { - const existing = await readFile(file, 'utf8'); - lines = existing.split('\n').filter((line) => line.trim() !== ''); + const existing = await readFile(filePath, 'utf8'); + lines = existing.split('\n').filter((ln) => ln.trim() !== ''); } catch { lines = []; // first entry or unreadable file → start fresh } - lines.push(entry); + lines.push(line); if (lines.length > DOWNTIME_LOG_MAX_ENTRIES) { lines = lines.slice(lines.length - DOWNTIME_LOG_MAX_ENTRIES); } - await writeFile(file, lines.join('\n') + '\n', 'utf8'); + await writeFile(filePath, lines.join('\n') + '\n', 'utf8'); } diff --git a/packages/herdr/src/downtime-round-robin.test.ts b/packages/herdr/src/downtime-round-robin.test.ts new file mode 100644 index 000000000..a4fd62c05 --- /dev/null +++ b/packages/herdr/src/downtime-round-robin.test.ts @@ -0,0 +1,289 @@ +/** + * packages/herdr/src/downtime-round-robin.test.ts — Round-robin registry tests + * + * Parent: WL-0MSSRED76008LGB6 (Downtime dispatcher: priority-first selection + * with round-robin tie-break across instances + jittered probes) + * Child: WL-0MSW6CACJ0035UGK (Test-first: round-robin + rotation + jitter test suite) + * + * Tests cover: + * 1. Cursor persistence: save/load JSON round-trip + * 2. Fail-open: corrupt file → default behavior (no rotation) + * 3. Rotation selection: repeated selections cycle through items + * 4. Priority-first: higher priority always selected before lower + * 5. Jitter bounds: jitter ∈ [0.5*interval, 1.5*interval] + * 6. Cross-instance isolation: different workers produce different jitter values + */ + +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; +import { + createRoundRobinRegistry, + type RoundRobinRegistry, + DEFAULT_JITTER_FRACTION, +} from './downtime-round-robin'; + +// ── Helper: in-memory tmp directory ────────────────────────────────────── + +function mkTmpDir(): string { + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rr-test-')); + return dir; +} + +// ── Test 1: Cursor persistence (save/load round-trip) ─────────────────── + +describe('cursor persistence', () => { + let tmpDir: string; + let registry: RoundRobinRegistry; + + beforeEach(() => { + tmpDir = mkTmpDir(); + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + }); + + afterAll(() => { + const fs = require('fs'); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('saves and loads cursor via JSON round-trip', async () => { + // Advance cursor (first selection of a 3-item group) + const next = registry.advanceCursor('high', 3); + expect(next).toBe(0); // cursor 0 % 3 = item 0 + + // Create a new registry pointing to same dir (simulates restart) + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + const loaded = registry.loadCursor('high'); + expect(loaded).toBe(1); // cursor persisted after advance + }); + + it('persists version number and increments on save', async () => { + registry.advanceCursor('high', 2); + registry.advanceCursor('high', 3); + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + const loaded = registry.loadCursor('high'); + expect(loaded).toBe(2); // two advances → cursor 2 + }); +}); + +// ── Test 2: Fail-open on corrupt file ─────────────────────────────────── + +describe('fail-open on corrupt file', () => { + let tmpDir: string; + let registry: RoundRobinRegistry; + + beforeEach(() => { + tmpDir = mkTmpDir(); + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + }); + + afterAll(() => { + const fs = require('fs'); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('returns default cursor (0) when file does not exist', () => { + // No save has been done — cursor should be 0 + expect(registry.loadCursor('high')).toBe(0); + }); + + it('returns default cursor when file contains corrupt JSON', () => { + const fs = require('fs'); + const path = require('path'); + const filePath = path.join(tmpDir, 'downtime-round-robin.json'); + fs.writeFileSync(filePath, 'not valid json{{{'); + + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + expect(registry.loadCursor('high')).toBe(0); + }); + + it('returns default cursor when file is empty', () => { + const fs = require('fs'); + const path = require('path'); + const filePath = path.join(tmpDir, 'downtime-round-robin.json'); + fs.writeFileSync(filePath, ''); + + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + expect(registry.loadCursor('high')).toBe(0); + }); + + it('does not throw on any corrupt file scenario', () => { + const fs = require('fs'); + const path = require('path'); + const filePath = path.join(tmpDir, 'downtime-round-robin.json'); + fs.writeFileSync(filePath, '\0\xff\xfe'); + + expect(() => { + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + registry.loadCursor('high'); + }).not.toThrow(); + }); +}); + +// ── Test 3: Rotation selection ────────────────────────────────────────── + +describe('rotation selection', () => { + let tmpDir: string; + let registry: RoundRobinRegistry; + + beforeEach(() => { + tmpDir = mkTmpDir(); + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + }); + + afterAll(() => { + const fs = require('fs'); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('cycles through N items correctly', () => { + const groupSize = 5; + const results: number[] = []; + for (let i = 0; i < groupSize * 2; i++) { + results.push(registry.advanceCursor('high', groupSize)); + } + // Should cycle: 0, 1, 2, 3, 4, 0, 1, 2, 3, 4 + expect(results).toEqual([0, 1, 2, 3, 4, 0, 1, 2, 3, 4]); + }); + + it('two instances selecting from same group pick different items after cursor advances', () => { + // Instance 1 reads cursor = 0, selects item 0 (0 % 3 = 0), advances cursor to 1 + const cursor1 = registry.loadCursor('high'); + expect(cursor1).toBe(0); + const next1 = registry.advanceCursor('high', 3); + expect(next1).toBe(0); // item 0 selected + + // Instance 2 reads cursor = 1, selects item 1 (1 % 3 = 1), advances cursor to 2 + const cursor2 = registry.loadCursor('high'); + expect(cursor2).toBe(1); + const next2 = registry.advanceCursor('high', 3); + expect(next2).toBe(1); // item 1 selected + }); +}); + +// ── Test 4: Priority-first ordering ───────────────────────────────────── + +describe('priority-first ordering', () => { + let tmpDir: string; + let registry: RoundRobinRegistry; + + beforeEach(() => { + tmpDir = mkTmpDir(); + registry = createRoundRobinRegistry({ worklogDir: tmpDir, rng: () => 0.5 }); + }); + + afterAll(() => { + const fs = require('fs'); + try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } + }); + + it('maintains separate cursors per priority tier', () => { + registry.advanceCursor('audit', 2); + registry.advanceCursor('implement', 3); + registry.advanceCursor('plan', 4); + + expect(registry.loadCursor('audit')).toBe(1); + expect(registry.loadCursor('implement')).toBe(1); + expect(registry.loadCursor('plan')).toBe(1); + }); + + it('higher-priority tier cursor is independent of lower-tier cursor', () => { + // Simulate: audit tier selects item, implement tier selects item + registry.advanceCursor('audit', 1); + const auditCursor = registry.loadCursor('audit'); + const implementCursor = registry.loadCursor('implement'); + + expect(auditCursor).toBe(1); + expect(implementCursor).toBe(0); // implement cursor hasn't advanced + }); +}); + +// ── Test 5: Jitter bounds ─────────────────────────────────────────────── + +describe('jitter bounds', () => { + it('jitter value is within ±50% of poll interval', () => { + const pollInterval = 30_000; + const registry = createRoundRobinRegistry({ worklogDir: mkTmpDir(), rng: () => 1.0 }); + + // rng returns 1.0 → max jitter (+50%) + const effectiveInterval = registry.getEffectivePollInterval(pollInterval); + expect(effectiveInterval).toBe(45_000); // 30_000 * 1.5 + + registry.close(); + }); + + it('jitter can be negative (−50%)', () => { + const pollInterval = 30_000; + const rng = vi.fn().mockReturnValue(0.0); // min jitter (-50%) + const registry = createRoundRobinRegistry({ worklogDir: mkTmpDir(), rng }); + + const effectiveInterval = registry.getEffectivePollInterval(pollInterval); + expect(effectiveInterval).toBe(15_000); // 30_000 * 0.5 + + registry.close(); + }); + + it('zero jitter (rng returns 0.5) gives exact poll interval', () => { + const pollInterval = 30_000; + const rng = vi.fn().mockReturnValue(0.5); // center jitter (0%) + const registry = createRoundRobinRegistry({ worklogDir: mkTmpDir(), rng }); + + const effectiveInterval = registry.getEffectivePollInterval(pollInterval); + expect(effectiveInterval).toBe(30_000); // 30_000 * 1.0 + + registry.close(); + }); + + it('bounds are clamped at exactly 0.5 and 1.5 regardless of rng', () => { + const pollInterval = 30_000; + // rng returns values outside [0, 1] — should be clamped + const rng = vi.fn().mockReturnValue(1.5); // would give 2.0x if not clamped + const registry = createRoundRobinRegistry({ worklogDir: mkTmpDir(), rng }); + + const effectiveInterval = registry.getEffectivePollInterval(pollInterval); + expect(effectiveInterval).toBe(45_000); // clamped to max 1.5 * 30_000 + + registry.close(); + }); +}); + +// ── Test 6: Cross-instance isolation ──────────────────────────────────── + +describe('cross-instance isolation', () => { + it('two workers with same config produce different jitter values', () => { + const pollInterval = 30_000; + const rng1 = vi.fn().mockReturnValue(0.3); + const rng2 = vi.fn().mockReturnValue(0.7); + + const registry1 = createRoundRobinRegistry({ worklogDir: mkTmpDir(), rng: rng1 }); + const registry2 = createRoundRobinRegistry({ worklogDir: mkTmpDir(), rng: rng2 }); + + const interval1 = registry1.getEffectivePollInterval(pollInterval); + const interval2 = registry2.getEffectivePollInterval(pollInterval); + + expect(interval1).toBe(24_000); // rng 0.3 → factor 0.8 → 30_000 * 0.8 + expect(interval2).toBe(36_000); // rng 0.7 → factor 1.2 → 30_000 * 1.2 + expect(interval1).not.toBe(interval2); + + registry1.close(); + registry2.close(); + }); + + it('two workers with identical RNG produce the same jitter', () => { + const pollInterval = 30_000; + const rng = vi.fn().mockReturnValue(0.5); + + const registry1 = createRoundRobinRegistry({ worklogDir: mkTmpDir(), rng }); + const registry2 = createRoundRobinRegistry({ worklogDir: mkTmpDir(), rng }); + + const interval1 = registry1.getEffectivePollInterval(pollInterval); + const interval2 = registry2.getEffectivePollInterval(pollInterval); + + expect(interval1).toBe(30_000); + expect(interval2).toBe(30_000); + + registry1.close(); + registry2.close(); + }); +}); diff --git a/packages/herdr/src/downtime-round-robin.ts b/packages/herdr/src/downtime-round-robin.ts new file mode 100644 index 000000000..9d98d4c4c --- /dev/null +++ b/packages/herdr/src/downtime-round-robin.ts @@ -0,0 +1,179 @@ +/** + * packages/herdr/src/downtime-round-robin.ts — Shared round-robin cursor registry + * + * Parent: WL-0MSSRED76008LGB6 (Downtime dispatcher: priority-first selection + * with round-robin tie-break across instances + jittered probes) + * + * Provides a shared, durable round-robin cursor for downtime dispatch + * selection. Fail-open read/write helpers for `.worklog/downtime-round-robin.json`. + * + * All operations are fail-open: missing file, unreadable file, or corrupt + * JSON → default to no rotation (cursor=0). + * + * File format: `{ "audit": { "cursor": 0, "version": 1 }, "implement": { ... }, ... }` + * + * Fail-closed conventions preserved: the round-robin cursor never weakens + * the existing dispatched-marker exclusion or the CAS claim. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// ── Constants ─────────────────────────────────────────────────────────── + +/** Round-robin cursor file name (inside the worklog directory). */ +export const ROUND_ROBIN_FILE_NAME = 'downtime-round-robin.json'; + +/** Jitter fraction range: rng output 0.0 → −50% (0.5×), 1.0 → +50% (1.5×). */ +const JITTER_FRACTION_MIN = 0.0; +const JITTER_FRACTION_MAX = 1.0; + +/** Nominal jitter center (rng = 0.5 → 0% jitter → exact poll interval). */ +export const DEFAULT_JITTER_FRACTION = 0.5; + +// ── Types ────────────────────────────────────────────────────────────── + +/** A tier name for round-robin grouping. */ +export type PriorityTier = 'audit' | 'implement' | 'plan' | 'intake' | string; + +/** Internal cursor entry. */ +interface CursorEntry { + cursor: number; + version: number; +} + +/** Full round-robin file content. */ +interface RoundRobinFile { + [tier: string]: CursorEntry; +} + +/** RNG function: returns a value in [0, 1]. */ +export type RngFn = () => number; + +/** Round-robin registry interface. */ +export interface RoundRobinRegistry { + /** Load cursor for a tier. Returns 0 on missing/corrupt file. */ + loadCursor(tier: PriorityTier): number; + + /** Advance cursor: returns next cursor value and persists it. */ + advanceCursor(tier: PriorityTier, groupSize: number): number; + + /** Calculate effective poll interval with jitter. */ + getEffectivePollInterval(baseIntervalMs: number, rngOverride?: RngFn): number; + + /** Close the registry (cleanup resources). */ + close(): void; +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +/** Default RNG: Math.random(). */ +function defaultRng(): number { + return Math.random(); +} + +/** Read and parse the round-robin file. Returns empty object on failure. */ +function loadRoundRobinFile(worklogDir: string): RoundRobinFile { + try { + const filePath = path.join(worklogDir, ROUND_ROBIN_FILE_NAME); + const content = fs.readFileSync(filePath, 'utf-8'); + if (!content.trim()) return {}; + return JSON.parse(content) as RoundRobinFile; + } catch { + // Fail-open: missing, unreadable, or corrupt file → empty + return {}; + } +} + +/** Atomically write the round-robin file (write to temp → rename). */ +function saveRoundRobinFile(worklogDir: string, data: RoundRobinFile): void { + try { + const filePath = path.join(worklogDir, ROUND_ROBIN_FILE_NAME); + const tmpPath = filePath + '.tmp'; + fs.writeFileSync(tmpPath, JSON.stringify(data), 'utf-8'); + fs.renameSync(tmpPath, filePath); + } catch { + // Fail-open: write failure is tolerated + } +} + +/** Get the cursor for a tier, creating it if missing. */ +function getOrCreateCursor(data: RoundRobinFile, tier: PriorityTier): CursorEntry { + if (!data[tier]) { + data[tier] = { cursor: 0, version: 1 }; + } + return data[tier]; +} + +// ── Public API ───────────────────────────────────────────────────────── + +/** + * Create a round-robin registry. + * + * @param options - Registry configuration + * @param options.worklogDir - Path to the worklog directory + * @param options.rng - RNG function (default: Math.random()) + * @returns A RoundRobinRegistry instance + */ +export function createRoundRobinRegistry(options: { + worklogDir: string; + rng?: RngFn; +}): RoundRobinRegistry { + const { worklogDir, rng = defaultRng } = options; + + // Load current state + const fileData = loadRoundRobinFile(worklogDir); + + let closed = false; + + return { + /** + * Load cursor for a tier. + * Returns 0 when file is missing, unreadable, or cursor missing. + */ + loadCursor(tier: PriorityTier): number { + if (closed) return 0; + const entry = getOrCreateCursor(fileData, tier); + return entry.cursor; + }, + + /** + * Advance cursor for a tier. Returns the index of the selected item (cursor % groupSize). + * The internal cursor is incremented by 1 and persisted. + * @param tier - Priority tier (audit/implement/plan/intake) + * @param groupSize - Number of items in the priority group + * @returns The index of the selected item (0 to groupSize-1) + */ + advanceCursor(tier: PriorityTier, groupSize: number): number { + if (closed) return 0; + const entry = getOrCreateCursor(fileData, tier); + const selected = entry.cursor % groupSize; + entry.cursor += 1; + entry.version += 1; + saveRoundRobinFile(worklogDir, fileData); + return selected; + }, + + /** + * Calculate effective poll interval with jitter. + * Jitter is ±50% of the base interval. + * @param baseIntervalMs - Base poll interval in milliseconds + * @param rngOverride - Optional RNG override for testing + * @returns Effective poll interval (clamped to [0.5×, 1.5×] base) + */ + getEffectivePollInterval(baseIntervalMs: number, rngOverride?: RngFn): number { + if (closed) return baseIntervalMs; + const r = (rngOverride ?? rng)(); + // Clamp to [0, 1] + const clampedRng = Math.max(JITTER_FRACTION_MIN, Math.min(JITTER_FRACTION_MAX, r)); + // Jitter fraction: 0.0 → 0.5×, 0.5 → 1.0×, 1.0 → 1.5× + const factor = 0.5 + clampedRng; + return Math.round(baseIntervalMs * factor); + }, + + /** Close the registry. */ + close(): void { + closed = true; + }, + }; +} diff --git a/packages/herdr/src/downtime-worker.fixtures.ts b/packages/herdr/src/downtime-worker.fixtures.ts index 77639c13f..20d734fa8 100644 --- a/packages/herdr/src/downtime-worker.fixtures.ts +++ b/packages/herdr/src/downtime-worker.fixtures.ts @@ -75,6 +75,34 @@ export const perSlotOneProcessing: LlamaStatus = { ], }; +/** + * 3 of 4 slots free — only slot-1 processing, with the GLOBAL query/lease + * signals active (the processing slot IS the operator's active session; + * spare-capacity dispatch must still fire into the 3 free slots). + */ +export const perSlotThreeOfFourFree: LlamaStatus = { + ...perSlotOneProcessing, + active_query: true, + local_active_query: true, + local_lease_active: true, +}; + +/** + * 1 of 3 slots free (slot-3 free — the 3-slot proxy with an operator + * session occupying a slot). Spare-capacity dispatch must NOT fire: N=2 of + * 3 requires TWO slots continuously free (default N=2, parent AC1). + */ +export const perSlotOneOfThreeFree: LlamaStatus = { + ...idleAllSlotsFree, + available_slots: 1, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: true }, + { slot_id: 'slot-3', is_processing: false }, + ], +}; + /** Raw (unparsed) response missing the numeric slot fields → ambiguous. */ export const ambiguousMissingFieldsRaw: Record = { llama_server_running: true, diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 40b4ab999..29d2f6965 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -28,8 +28,36 @@ * global-busy resets all slot timers; all-slots-free fallback without * per-slot data). Red phase: these fail until the implementation lands * (WL-0MSP28LSY007NDYX). + * + * Critical-first tier tests (WL-0MT3I9QJU002S34W, parent + * WL-0MT3FM8VA005XBHE): test-first matrix for the critical-first pre-tier — + * an open critical item at ANY stage (idea/intake_complete/plan_complete) + * wins over every non-critical candidate. Pins the F2/F3/F4 contract: + * parseCriticalCandidatesOutput / selectCriticalCandidate / criticalSkillKind + * (critical lookup & selection), resolveDependencyFrontier (dependency + * frontier, Q3), the getNextCriticalCandidate dep, and the tier order + * (scheduled-prompts → audit → critical → implement → plan → intake) with + * freeze split-by-skill (Q1) and cap retention (Q2). Red phase: the new + * exports do not exist yet — the new tests fail and the existing suite + * stays green (zero regression) until the implementation slices land + * (WL-0MT3I9UVU004722X, WL-0MT3I9YNZ007IC5V, WL-0MT3IA1UB005TLVJ). + * + * Single-active-audit tests (WL-0MT47BMR7003ZQ66, parent + * WL-0MT3PHW4I002SNOV): test-first matrix for the audit-tier single-flight + * guard — audits stay strictly sequential across idle periods (never + * fan-out). Pins the getActiveAudit dep contract ({ok:true, active:boolean} + * | {ok:false}), the audit-in-flight tier skip with implement fall-through, + * the 2h stale-window expiry (recentAuditDispatchedItemIds coverage in + * downtime-log.test.ts), fail-open on check failure, the 'audit-in-flight' + * outcome reason (never 'no-candidate'), and the guard composition with + * code-freeze + free-slot minimums. Red phase: the new exports do not + * exist yet — the new tests fail and the existing suite stays green (zero + * regression) until the implementation slice lands (WL-0MT47BQAT00375VB). */ +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { isIdleStatus, @@ -51,8 +79,16 @@ import { parseImplementCandidatesOutput, selectImplementCandidate, selectNextCandidate, + parseCriticalCandidatesOutput, + parseDepListBlockersOutput, + parseShownWorkItem, + selectCriticalCandidate, + criticalSkillKind, + resolveDependencyFrontier, parseAuditCandidatesOutput, + parseInProgressOutput, selectAuditCandidate, + selectWithRotation, toDowntimeCandidate, skillKindFromPrompt, buildDowntimeDispatchComment, @@ -65,6 +101,7 @@ import { DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS, + DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, type LlamaStatus, type LlamaSlot, type LlamaStatusFetcher, @@ -74,7 +111,15 @@ import { type DowntimeStage, type AuditCandidate, type ImplementCandidate, + type ScheduledPrompt, + type DowntimeActiveAuditResult, } from './downtime-worker.js'; +import { + DOWNTIME_DISABLE_MARKER_FILE, + disableMarkerPath, + writeDisableMarker, +} from './downtime-disable-marker.js'; +import { createRoundRobinRegistry } from './downtime-round-robin.js'; import { statusFixtures, ambiguousMissingFieldsRaw, @@ -82,6 +127,8 @@ import { perSlotAllFree, perSlotTwoFree, perSlotOneProcessing, + perSlotThreeOfFourFree, + perSlotOneOfThreeFree, networkErrorFixture, timeoutErrorFixture, httpErrorResponseFixture, @@ -96,12 +143,28 @@ function makeDeps(overrides: Partial = {}): DowntimeWorkerDe // Audit tier answers a GENUINELY empty tier by default ({ok:true, // candidate:null}); a wl/parse failure is {ok:false} (WL-0MSLWJ2KP0002SV0). getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + // Active-audit single-flight (WL-0MT3PHW4I002SNOV): no active audit by + // default ({ok:true, active:false}), so the existing tier tests exercise + // the unchanged audit tier (backward-compatible default — the audit + // tier proceeds). The dispatcher consults this dep only after F2 lands; + // existing tests are unaffected either way. + getActiveAudit: vi.fn().mockResolvedValue({ ok: true, active: false }), getNextImplementCandidate: vi.fn().mockResolvedValue(null), claimItem: vi.fn().mockResolvedValue({ ok: true }), spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), recordDispatch: vi.fn().mockResolvedValue(true), recordDispatchFailure: vi.fn().mockResolvedValue(undefined), recordError: vi.fn().mockResolvedValue(undefined), + // Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX): no due prompt by + // default, so existing tier tests exercise the unchanged backlog tiers. + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + recordScheduledPromptTrigger: vi.fn().mockResolvedValue(true), + // Critical-first tier (WL-0MT3FM8VA005XBHE): no critical candidate by + // default, so the existing tier tests exercise the unchanged backlog + // tiers (regression guard — after F4 the critical tier must fall + // through to implement → plan → intake when the critical tier is + // genuinely empty). + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), // Code-freeze gate (WL-0MSQ0RPQP00636JY): not frozen by default, so // existing dispatch tests exercise the unchanged audit/implement tiers. readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), @@ -196,7 +259,7 @@ describe('idle detection (isIdleStatus)', () => { // ── Threshold timing (AC1/AC3) ──────────────────────────────────────── describe('threshold timing (idle-duration tracker)', () => { - const thresholdMs = 240_000; + const thresholdMs = DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS; it('dispatches only after idle has lasted the full threshold continuously', () => { const tracker = createIdleTracker(); @@ -222,10 +285,10 @@ describe('threshold timing (idle-duration tracker)', () => { const tracker = createIdleTracker(); const start = 1_000_000; tracker.record(true, start); - tracker.record(true, start + 30_000); - tracker.record(true, start + 60_000); + tracker.record(true, start + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + tracker.record(true, start + 2 * DEFAULT_DOWNTIME_POLL_INTERVAL_MS); expect(tracker.idleSince).toBe(start); - expect(tracker.isThresholdMet(thresholdMs, start + 60_000)).toBe(false); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs - 1_000)).toBe(false); expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); }); }); @@ -492,6 +555,369 @@ describe('dispatch audit tier', () => { }); }); +// ── Single-active-audit enforcement (WL-0MT3PHW4I002SNOV) ───────────── + +describe('dispatch active-audit single-flight (parent WL-0MT3PHW4I002SNOV)', () => { + const auditCandidate: DowntimeCandidate = { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }; + const implementCandidate: DowntimeCandidate = { + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }; + + // Contract of the injected active-audit check (implemented in F2, + // WL-0MT47BQAT00375VB): {ok:true, active:true} = a non-stale kind=audit + // dispatch marker maps to an item still in_progress (an audit is running + // — the audit tier must be skipped); {ok:true, active:false} = none + // (audit tier proceeds); {ok:false} = the check could not complete + // (fail-open — skip the audit tier, never block all dispatch). + const noActiveAudit: DowntimeActiveAuditResult = { ok: true, active: false }; + const activeAudit: DowntimeActiveAuditResult = { ok: true, active: true }; + const checkFailed: DowntimeActiveAuditResult = { ok: false }; + + it('dispatches /skill:audit when no non-stale audit marker maps to an in_progress item (AC1/AC3)', async () => { + // No active audit — including a marker older than the 2h stale window + // (treated as expired: the audit pane may have crashed without updating + // the work item) — the audit tier proceeds as today. The stale-window + // arithmetic itself is pinned in downtime-log.test.ts + // (recentAuditDispatchedItemIds); here the dep reports the resolved + // state (active:false). + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(noActiveAudit), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // The single-flight check runs first, then the candidate lookup. + expect(deps.getActiveAudit).toHaveBeenCalledWith('/repo'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(outcome.candidate?.id).toBe('WL-AUD'); + }); + + it('one active audit skips the audit tier and falls through to the implement tier (AC2/AC4)', async () => { + // A non-stale kind=audit marker maps to an in_progress item: an audit is + // running. The audit tier is skipped (audits strictly sequential — no + // fan-out), the candidate lookup is never consulted, and dispatch falls + // through to the implement tier. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getActiveAudit).toHaveBeenCalledTimes(1); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); + + it('an active-audit-check failure fails open: audit tier skipped, dispatch falls through to implement', async () => { + // Fail-safe (parent constraint): if the active-audit check cannot + // complete (e.g. worklog query fails) the audit tier is skipped and + // dispatch falls through to the next tier — it never blocks all + // dispatch. No audit candidate is consulted this cycle. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(checkFailed), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextImplementCandidate: vi.fn().mockResolvedValue(implementCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); + + it('an audit-in-flight skip with an empty remaining backlog reports reason audit-in-flight (never no-candidate)', async () => { + // Mirrors the code-freeze precedent (WL-0MSQ0RPQP00636JY): a skip that + // is NOT a genuine empty backlog must never enter the no-candidate + // cooldown — polling continues and the next idle tick re-checks while + // the audit is still running. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + // plan (intake_complete) and intake (idea) tiers answer genuinely empty. + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('audit-in-flight'); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('a failed active-audit check with an empty remaining backlog is a wl-error strike, never no-candidate', async () => { + // Partial information must not pause the worker: when the check failed + // and every fallback tier answered empty, the backlog is NOT provably + // empty (an audit may be in flight we could not see) — fail closed to + // busy (a strike, three-strike rule), never the no-candidate cooldown. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(checkFailed), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('runs the active-audit check BEFORE the audit candidate lookup (single check per tick)', async () => { + // Ordering guard: the single-flight check gates the tier — a candidate + // is never selected while an active audit exists, and the check is + // consulted exactly once per tick. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(noActiveAudit), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const checkOrder = (deps.getActiveAudit as ReturnType).mock.invocationCallOrder[0]; + const lookupOrder = (deps.getNextAuditCandidate as ReturnType).mock.invocationCallOrder[0]; + expect(checkOrder).toBeLessThan(lookupOrder); + expect(deps.getActiveAudit).toHaveBeenCalledTimes(1); + }); + + it('during a code freeze the audit block is skipped without consulting the active-audit check (guard composition)', async () => { + // Existing guards preserved (AC6): the code-freeze gate (WL-0MSQ0RPQP00636JY) + // short-circuits the entire audit tier before the single-flight check — + // no audits run during a release, active or not. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getActiveAudit).not.toHaveBeenCalled(); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + // Empty plan/intake backlog during a freeze reports code-freeze (the + // freeze never enters the no-candidate cooldown — unchanged). + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); + }); + + it('is not consulted when the audit tier is ineligible (1 free slot < the 2-slot audit minimum)', async () => { + // Free-slot minimum preserved (parent WL-0MT32F90V008UAD2 AC3): below the + // audit tier's 2-slot minimum the whole audit block is skipped — the + // single-flight check is not even run; plan (≥1 slot) dispatches. + const deps = makeDeps({ + getActiveAudit: vi.fn().mockResolvedValue(activeAudit), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete', status: 'open' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(deps.getActiveAudit).not.toHaveBeenCalled(); + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); +}); + +describe('dispatch tier free-slot minimums (parent WL-0MT32F90V008UAD2 AC3)', () => { + const auditCandidate: DowntimeCandidate = { + id: 'WL-AUD', + title: 'Audit me', + stage: 'audit', + }; + const planCandidate: DowntimeCandidate = { + id: 'WL-PLAN', + title: 'Prep task', + stage: 'intake_complete', + status: 'open', + }; + + it('audit tier is skipped below 2 free slots: with 1 free slot an audit candidate is NOT dispatched (AC3)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: planCandidate, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + // 1 free slot: audit needs ≥2 → skipped WITHOUT consulting the lookup + // (ineligible, not a strike); plan needs ≥1 → dispatches. + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-PLAN'); + }); + + it('audit tier dispatches with ≥2 free slots (AC3)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 2 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + expect(deps.getNextAuditCandidate).toHaveBeenCalledTimes(1); + }); + + it('with exactly 1 free slot the audit skip is ineligible — a genuinely empty plan tier is no-candidate, not a strike', async () => { + // Mirror of the code-freeze skip: an unmet tier minimum never produces + // a wl-error strike and never short-circuits the fallback tiers. + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); // plan tier answered empty + }); + + it('pane tiers (plan/intake) still dispatch with exactly 1 free slot (AC2)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + }); + + it('free-slots gate is independent of the idle gate: no freeSlots arg means no gating (backward compatible)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: auditCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Legacy direct callers (undefined freeSlots) are not gated — existing + // dispatch behaviour unchanged. + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + }); + + it('scheduled-prompts tier requires the pane minimum ≥1 free slot: 0 free skips it and falls through', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: 'SP-1', + prompt: 'Run /skill:refactor', + frequency: '3d', + }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: planCandidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); + + // 0 free slots: the scheduled prompt (a pane) is ineligible; the plan + // tier also needs ≥1 — the outcome is a neutral no-candidate (defensive + // path — via the worker the idle gate already guarantees ≥1 free). + expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + }); + + it('scheduled-prompts tier dispatches with ≥1 free slot (AC3)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: 'SP-1', + prompt: 'Run /skill:refactor', + frequency: '3d', + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('scheduled'); + expect(deps.recordScheduledPromptTrigger).toHaveBeenCalled(); + }); + + it('implement tier is NOT dispatched with 0 free slots: pane minimum ≥1 honored at selection time (F3-fix AC1/AC4)', async () => { + // Regression for the audit finding (F3 AC2 partial): the implement tier + // had a 'Pane minimum' comment but no panesEligible guard — a direct + // dispatchDowntimeWork({freeSlots:0}) dispatched implement. It must be + // skipped exactly like the other pane tiers (ineligible, never a + // strike), falling through to the plan tier's defensive no-candidate. + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 0 }); + + // The implement lookup is never consulted at 0 free slots — ineligible, + // not a strike, and falls through to the plan tier's defensive + // no-candidate (via the worker the idle gate already guarantees ≥1). + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('implement tier still dispatches with ≥1 free slot (F3-fix AC2)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', freeSlots: 1 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); + + it('implement tier is not gated when freeSlots is undefined: direct legacy callers unaffected (F3-fix AC3)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-IMP'); + }); +}); + // ── Implement dispatch tier (WL-0MSMAYIKX005LLO4) ──────────────────── describe('dispatch implement tier', () => { @@ -770,33 +1196,255 @@ describe('dispatch code-freeze gate', () => { }); }); -// ── Implement candidate selection (WL-0MSMAYIKX005LLO4) ────────────── +// ── Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX) ───────────────────── -describe('implement selection (selectImplementCandidate)', () => { - const open: ImplementCandidate = { - id: 'WL-OPEN', - title: 'open item', - status: 'open', - risk: 'low', - effort: 'small', - sortIndex: 100, - }; - const completed: ImplementCandidate = { - id: 'WL-DONE', - title: 'completed item', - status: 'completed', - risk: 'low', - effort: 'small', - sortIndex: 200, +describe('dispatch scheduled-prompts tier', () => { + const duePrompt: ScheduledPrompt = { + id: 'refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, }; - it('selects the first open candidate in wl next priority order (ascending sortIndex)', () => { - expect(selectImplementCandidate([open, completed])?.id).toBe('WL-OPEN'); - }); + it('dispatches a due scheduled prompt FIRST (before audit/implement/plan/intake)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + // The backlog tiers must never be consulted — the prompt dispatches + // instead of reaching them (AC6). + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, + }), + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + status: 'open', + risk: 'low', + effort: 'small', + } as ImplementCandidate), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, + }), + }); - it('excludes completed candidates (status=open client-side filter, AC2)', () => { - expect(selectImplementCandidate([completed, { ...open, id: 'WL-OPEN2', sortIndex: 300 }])?.id).toBe('WL-OPEN2'); - }); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('scheduled'); + // The prompt text is the dispatch payload — not a skill+work-item wrap. + expect(deps.spawnAgentPane).toHaveBeenCalledWith('/skill:refactor', { + model: 'plan', + cwd: '/repo', + paneName: 'Downtime refactor', + }); + // No pre-dispatch claim — there is no work item (AC4). + expect(deps.claimItem).not.toHaveBeenCalled(); + // The backlog tiers are never reached (AC6). + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(deps.getNextItem).not.toHaveBeenCalled(); + }); + + it('persists lastTriggeredAt and writes the scheduled log marker before the spawn (AC4)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // The trigger timestamp is persisted (atomic config update) and the + // rolling log marker is written with kind scheduled + noItemComment. + expect(deps.recordScheduledPromptTrigger).toHaveBeenCalledWith( + '/repo', + 'refactor', + expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + ); + expect(deps.recordDispatch).toHaveBeenCalledWith( + expect.objectContaining({ + itemId: 'refactor', + kind: 'scheduled', + cwd: '/repo', + noItemComment: true, + }), + ); + // Marker + persist before spawn: the dispatch is recorded before the + // pane opens (fail-closed: an unrecorded dispatch never runs). + const persistOrder = + (deps.recordScheduledPromptTrigger as ReturnType).mock.invocationCallOrder[0]; + const markerOrder = + (deps.recordDispatch as ReturnType).mock.invocationCallOrder[0]; + const spawnOrder = + (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(persistOrder).toBeLessThan(markerOrder); + expect(markerOrder).toBeLessThan(spawnOrder); + }); + + it('falls through to the existing tiers when no prompt is due', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getDueScheduledPrompt).toHaveBeenCalledWith('/repo'); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('audit'); + }); + + it('skips the scheduled tier while frozen (frozen OR ambiguous are fail-closed, AC5)', async () => { + for (const freezeStatus of ['frozen', 'ambiguous'] as const) { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue(freezeStatus), + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Scheduled prompts are held by the same freeze gate as audit/implement + // (no new code changes during a release) — the tier is never consulted. + expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-PLAN'), + expect.anything(), + ); + } + }); + + it('a frozen+empty plan/intake backlog reports code-freeze even when a prompt is due (AC5)', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.getDueScheduledPrompt).not.toHaveBeenCalled(); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('aborts before spawn when the lastTriggeredAt persist fails (fail-closed, AC4)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + recordScheduledPromptTrigger: vi.fn().mockResolvedValue(false), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('marker-write-failed'); + // No log record, no pane: an unrecorded dispatch never runs; the entry + // stays due for the next idle slot. + expect(deps.recordDispatch).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('aborts before spawn when the log marker cannot be written (fail-closed, AC4)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + recordDispatch: vi.fn().mockResolvedValue(false), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('marker-write-failed'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('records a spawn-failure trace and resolves spawn-failed when the pane fails (AC4)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(duePrompt), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: false, error: 'ENOENT' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('spawn-failed'); + expect(outcome.error).toBe('ENOENT'); + expect(deps.recordDispatchFailure).toHaveBeenCalledWith( + expect.objectContaining({ + itemId: 'refactor', + kind: 'scheduled', + error: 'ENOENT', + noItemComment: true, + }), + ); + }); + + it('reports no-candidate (the cooldown trigger) when no prompt is due and the backlog is empty (AC6)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + }); + + it('buildDowntimePaneArgs honours an explicit paneName (Downtime for scheduled prompts)', () => { + const args = buildDowntimePaneArgs('plan', '/skill:refactor', { + model: 'plan', + cwd: '/repo', + paneName: 'Downtime refactor', + }); + expect(args).toEqual([ + '--pane-name', + 'Downtime refactor', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + '/skill:refactor', + ]); + }); +}); + +// ── Implement candidate selection (WL-0MSMAYIKX005LLO4) ────────────── + +describe('implement selection (selectImplementCandidate)', () => { + const open: ImplementCandidate = { + id: 'WL-OPEN', + title: 'open item', + status: 'open', + risk: 'low', + effort: 'small', + sortIndex: 100, + }; + const completed: ImplementCandidate = { + id: 'WL-DONE', + title: 'completed item', + status: 'completed', + risk: 'low', + effort: 'small', + sortIndex: 200, + }; + + it('selects the first open candidate in wl next priority order (ascending sortIndex)', () => { + expect(selectImplementCandidate([open, completed])?.id).toBe('WL-OPEN'); + }); + + it('excludes completed candidates (status=open client-side filter, AC2)', () => { + expect(selectImplementCandidate([completed, { ...open, id: 'WL-OPEN2', sortIndex: 300 }])?.id).toBe('WL-OPEN2'); + }); it('returns null when no open candidate exists', () => { expect(selectImplementCandidate([completed])).toBeNull(); @@ -829,24 +1477,30 @@ describe('implement selection (selectImplementCandidate)', () => { // thresholds again so a malformed/absent server filter can never dispatch // a Medium+/Large+ item (fail-closed). - it('only risk exactly Low is eligible (Medium/High/Critical excluded)', () => { + it('risk ≤ Medium eligible (High/Critical excluded)', () => { + const low = { ...open, id: 'WL-LOW', risk: 'low' as const }; const medium = { ...open, id: 'WL-MED', risk: 'medium' as const }; const high = { ...open, id: 'WL-HIGH-R', risk: 'high' as const }; const critical = { ...open, id: 'WL-CRIT', risk: 'critical' as const }; - expect(selectImplementCandidate([medium, high, critical, open])?.id).toBe('WL-OPEN'); - expect(selectImplementCandidate([medium])).toBeNull(); + // medium is first eligible in input order (all share sortIndex 100) + expect(selectImplementCandidate([medium, low, high, critical, open])?.id).toBe('WL-MED'); + expect(selectImplementCandidate([medium])?.id).toBe('WL-MED'); + expect(selectImplementCandidate([low])?.id).toBe('WL-LOW'); expect(selectImplementCandidate([high])).toBeNull(); expect(selectImplementCandidate([critical])).toBeNull(); }); - it('effort Small and Extra Small eligible (Medium/Large/Extra Large excluded)', () => { + it('effort ≤ Medium eligible (Large/Extra Large excluded)', () => { const xs = { ...open, id: 'WL-XS', effort: 'xs' as const }; + const small = { ...open, id: 'WL-S', effort: 'small' as const }; const medium = { ...open, id: 'WL-MED-E', effort: 'medium' as const }; const large = { ...open, id: 'WL-LARGE', effort: 'large' as const }; const xl = { ...open, id: 'WL-XL', effort: 'xl' as const }; - expect(selectImplementCandidate([medium, large, xl, open])?.id).toBe('WL-OPEN'); + // medium is first eligible in input order (all share sortIndex 100) + expect(selectImplementCandidate([medium, large, xl, open])?.id).toBe('WL-MED-E'); expect(selectImplementCandidate([xs])?.id).toBe('WL-XS'); - expect(selectImplementCandidate([medium])).toBeNull(); + expect(selectImplementCandidate([small])?.id).toBe('WL-S'); + expect(selectImplementCandidate([medium])?.id).toBe('WL-MED-E'); expect(selectImplementCandidate([large])).toBeNull(); expect(selectImplementCandidate([xl])).toBeNull(); }); @@ -860,12 +1514,51 @@ describe('implement selection (selectImplementCandidate)', () => { expect(selectImplementCandidate([noEffort])).toBeNull(); }); - it('recognizes long-form effort spellings (Small / Extra Small)', () => { + it('recognizes long-form effort spellings (Small / Medium / Extra Small)', () => { const small = { ...open, id: 'WL-S', effort: 'Small' as const }; + const medium = { ...open, id: 'WL-M', effort: 'Medium' as const }; const extraSmall = { ...open, id: 'WL-ES', effort: 'Extra Small' as const }; expect(selectImplementCandidate([small])?.id).toBe('WL-S'); + expect(selectImplementCandidate([medium])?.id).toBe('WL-M'); expect(selectImplementCandidate([extraSmall])?.id).toBe('WL-ES'); }); + + it('handles verbose risk/effort descriptions (agent-produced fields)', () => { + const verboseRisk = { ...open, id: 'WL-VERBOSE-RISK', risk: 'medium — NVIDIA driver changes can affect GPU functionality system-wide. Wrong driver version could worsen the problem. Mitigation: test in non-production context first if possible; have rollback plan.' }; + const verboseEffort = { ...open, id: 'WL-VERBOSE-EFFORT', effort: '1–4 hours (estimate: o=1, m=2, p=4) — Small. Diagnostic investigation + targeted fix on a single system. No code changes expected.' }; + // Verbose descriptions with em-dash should extract the leading keyword + expect(selectImplementCandidate([verboseRisk])?.id).toBe('WL-VERBOSE-RISK'); + expect(selectImplementCandidate([verboseEffort])?.id).toBe('WL-VERBOSE-EFFORT'); + // High risk should still be excluded even with verbose description + const highVerbose = { ...open, id: 'WL-HIGH-V', risk: 'high — something went wrong' }; + expect(selectImplementCandidate([highVerbose])).toBeNull(); + }); + + it('regression: a critical item with verbose risk/effort fields wins over a medium item (WL-0MSN3FWV5008KQE9 dispatch)', () => { + // Real-world regression: the downtime dispatcher skipped the critical NVIDIA + // item because its risk/effort fields carry descriptions, selecting the + // medium mode-switch item instead. + const nvidia = { + ...open, + id: 'WL-0MT1KJNDK004SNPK', + title: 'Fix NVIDIA kernel/userspace driver API mismatch (chrome-headless)', + risk: 'Medium — NVIDIA driver changes can affect GPU functionality system-wide. Wrong driver version could worsen the problem. Mitigation: test in non-production context first if possible; have rollback plan.', + effort: '1–4 hours (estimate: o=1, m=2, p=4) — Small. Diagnostic investigation + targeted fix on a single system. No code changes expected.', + sortIndex: 900, + priority: 'critical', + }; + const modeSwitch = { + ...open, + id: 'WL-0MSN3FWV5008KQE9', + title: 'Herdr plugin: activity-gated proxy mode switching', + risk: 'Medium', + effort: 'Small', + sortIndex: 1600, + priority: 'medium', + }; + // Critical (sortIndex 900) must be selected ahead of medium (sortIndex 1600) + expect(selectImplementCandidate([modeSwitch, nvidia])?.id).toBe('WL-0MT1KJNDK004SNPK'); + }); }); describe('parseImplementCandidatesOutput', () => { @@ -999,6 +1692,22 @@ describe('audit selection (selectAuditCandidate)', () => { expect(selectAuditCandidate([boundaryStale], NOW)?.id).toBe('B1'); expect(selectAuditCandidate([boundaryFresh], NOW)).toBeNull(); }); + + it('does not select an item whose updatedAt did not move (flag-only flip, WL-0MSN6ZCTN0027U2R)', () => { + // The worklog core guarantees (packages/shared/src/database.ts update()) + // that flipping needsProducerReview does NOT bump updatedAt — so after + // the flip the item still has its pre-audit updatedAt and the audit + // remains fresh. selectAuditCandidate must therefore not re-dispatch + // a redundant /skill:audit for it. + const afterFlagFlip: AuditCandidate = { + id: 'FLAGFLIP', + title: 'flagged for review', + auditedAt: '2026-01-01T00:00:30.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', // unchanged by the flag flip + sortIndex: 10, + }; + expect(selectAuditCandidate([afterFlagFlip], NOW)).toBeNull(); + }); }); describe('audit selection 7-day recency (selectAuditCandidate)', () => { @@ -1165,6 +1874,35 @@ describe('parseAuditCandidatesOutput', () => { }); }); +describe('parseInProgressOutput (active-audit single-flight)', () => { + it('parses bare workItems entries into their ids', () => { + const stdout = JSON.stringify({ success: true, count: 2, workItems: [{ id: 'WL-A' }, { id: 'WL-B' }] }); + expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A', 'WL-B']); + }); + + it('also accepts the { workItem } wrapper shape inside workItems', () => { + const stdout = JSON.stringify({ workItems: [{ workItem: { id: 'WL-A' } }, { workItem: { id: 'WL-B' } }] }); + expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A', 'WL-B']); + }); + + it('fails closed (null) on malformed JSON', () => { + expect(parseInProgressOutput('not json')).toBeNull(); + }); + + it('fails closed (null) on output without a list', () => { + expect(parseInProgressOutput(JSON.stringify({ success: false }))).toBeNull(); + }); + + it('skips entries without an id but keeps valid ones', () => { + const stdout = JSON.stringify({ workItems: [{ title: 'no id' }, { id: 'WL-A' }] }); + expect([...parseInProgressOutput(stdout)!]).toEqual(['WL-A']); + }); + + it('returns an empty set for an empty workItems list', () => { + expect([...parseInProgressOutput(JSON.stringify({ workItems: [] }))!]).toEqual([]); + }); +}); + describe('audit prompt & pane helpers', () => { const candidate: DowntimeCandidate = { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }; @@ -1842,6 +2580,15 @@ describe('blocked-questions prompt instruction', () => { expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl comment add'); expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl update --needs-producer-review true'); }); + + it('BLOCKED_QUESTIONS_INSTRUCTION includes the final-summary directive', () => { + expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('repeat the questions in your final summary'); + }); + + it('buildDowntimePrompt output includes the final-summary directive', () => { + const prompt = buildDowntimePrompt('implement', candidate); + expect(prompt).toContain('repeat the questions in your final summary'); + }); }); // ── Settings clamps (AC8) ───────────────────────────────────────────── @@ -1864,13 +2611,16 @@ describe('downtime settings clamps', () => { }); it('clampDowntimeIdleThresholdMs keeps valid values and floors at 1s', () => { - expect(clampDowntimeIdleThresholdMs(240_000)).toBe(240_000); + expect(clampDowntimeIdleThresholdMs(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); expect(clampDowntimeIdleThresholdMs(0)).toBe(1_000); }); - it('clampDowntimeRequiredFreeSlots maps negative/non-finite to 0 (all slots) and rounds', () => { - expect(clampDowntimeRequiredFreeSlots(-3)).toBe(0); - expect(clampDowntimeRequiredFreeSlots(Number.NaN)).toBe(0); + it('clampDowntimeRequiredFreeSlots maps negative/non-finite to the default N=2 and rounds', () => { + // Spare-capacity default (parent WL-0MT32F90V008UAD2): negative/non- + // finite input falls back to DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS (2) + // — the out-of-the-box spare-capacity default, still fail-closed safe. + expect(clampDowntimeRequiredFreeSlots(-3)).toBe(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS); + expect(clampDowntimeRequiredFreeSlots(Number.NaN)).toBe(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS); expect(clampDowntimeRequiredFreeSlots(2.7)).toBe(3); expect(clampDowntimeRequiredFreeSlots(2)).toBe(2); }); @@ -1930,7 +2680,7 @@ describe('downtime worker enabled state', () => { deps: makeDeps(), config: () => ({ enabled: cfg.enabled, - thresholdMs: 240_000, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, requiredFreeSlots: 0, model: 'plan', cwd: '/repo', @@ -1943,80 +2693,391 @@ describe('downtime worker enabled state', () => { }); }); -// ── Pane spawn (AC4) ────────────────────────────────────────────────── +// ── Per-instance toggle override (parent WL-0MSZ4NSOE007AQEF) ───────── -describe('downtime pane spawn (send-to-pi.sh)', () => { - it('builds --pane-name / --no-focus / --cwd / --model args for a plan dispatch', () => { - const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { +describe('downtime worker per-instance override', () => { + function makeWorker( + override: boolean | null = null, + globalEnabled: boolean = true, + ) { + const cfg = { + enabled: globalEnabled, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, model: 'plan', cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ ...cfg }), + override, }); - expect(args).toEqual([ - '--pane-name', - 'Downtime plan', - '--no-focus', - '--cwd', - '/repo', - '--model', - 'plan', - 'Run /skill:plan WL-ABC — Some task.', - ]); - }); + return { worker, cfg }; + } - it('uses the intake pane name and forwards the configured model', () => { - const args = buildDowntimePaneArgs('intake', 'Run /skill:intake WL-DEF', { - model: 'code', - cwd: '/repo', - }); - expect(args).toContain('Downtime intake'); - expect(args).toContain('code'); - expect(args).not.toContain('Downtime plan'); + it('defaults to null (follows global settings)', () => { + const { worker } = makeWorker(); + expect(worker.override).toBe(null); + expect(worker.enabled).toBe(true); }); - it('spawns via the injectable spawn and unrefs the child', async () => { - const spawnFn = vi.fn(() => ({ unref: vi.fn(), once: vi.fn() })) as unknown as DowntimeSpawn; - - const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { cwd: '/repo' }, spawnFn); + it('toggle() cycles null → false → null (no force-enable)', () => { + const { worker } = makeWorker(); + // null → false (first press disables) + expect(worker.override).toBe(null); + worker.toggle(); + expect(worker.override).toBe(false); + // false → null (second press returns to follow settings) + worker.toggle(); + expect(worker.override).toBe(null); + }); - expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { - cwd: '/repo', - }); - const handle = (spawnFn as ReturnType).mock.results[0].value; - expect(handle.unref).toHaveBeenCalled(); - expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); - expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); - // No error/exit within the probe window → the pane is assumed opened. - expect(spawned).toEqual({ ok: true }); + it('two consecutive toggles return to follow-settings (null→false→null)', () => { + const { worker, cfg } = makeWorker(); + // First press: disable + worker.toggle(); + expect(worker.override).toBe(false); + expect(worker.enabled).toBe(false); + // Second press: back to follow settings + worker.toggle(); + expect(worker.override).toBe(null); + expect(worker.enabled).toBe(cfg.enabled); // follows global + }); + + it('three presses cycle null → false → null → false', () => { + const { worker } = makeWorker(); + expect(worker.override).toBe(null); + worker.toggle(); // → false + expect(worker.override).toBe(false); + worker.toggle(); // → null + expect(worker.override).toBe(null); + worker.toggle(); // → false again (not true) + expect(worker.override).toBe(false); + }); + + it('tick() short-circuits after disable (override=false)', async () => { + const { worker } = makeWorker(); + worker.toggle(); // → false + const result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); }); - it('resolves {ok:false} with the error trace on a handled spawn error (no unhandled-exception crash)', async () => { - const handle = { - unref: vi.fn(), - once: vi.fn((event: string, listener: (arg: unknown) => void) => { - if (event === 'error') listener(new Error('ENOENT: send-to-pi.sh')); - }), - }; - const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; + it('tick() follows global setting after second press (null → false → null)', async () => { + vi.useFakeTimers(); + try { + const { worker, cfg } = makeWorker(null, true); + // First press: disable + worker.toggle(); + expect(worker.override).toBe(false); + let result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + + // Second press: back to follow settings + worker.toggle(); + expect(worker.override).toBe(null); + result = await worker.tick(); + expect(result.idle).toBe(false); // normal behavior (global busy) + } finally { + vi.useRealTimers(); + } + }); + + it('enabled returns override ?? cfg.enabled (null)', () => { + const { worker, cfg } = makeWorker(); + // null override → follows global + expect(worker.enabled).toBe(true); + cfg.enabled = false; + expect(worker.enabled).toBe(false); + cfg.enabled = true; + // toggle: null → false + worker.toggle(); + expect(worker.override).toBe(false); + expect(worker.enabled).toBe(false); + // toggle: false → null + worker.toggle(); + expect(worker.override).toBe(null); + expect(worker.enabled).toBe(cfg.enabled); // follows global + }); - const spawned = await spawnDowntimePane('/path/to/missing.sh', [], { cwd: '/repo' }, spawnFn); + it('tick() short-circuits when override is false (even if global is true)', async () => { + const { worker } = makeWorker(false, true); + const result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + }); - expect(spawned).toEqual({ ok: false, error: 'ENOENT: send-to-pi.sh' }); - expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); - expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); + it('tick() behaves normally when override is null (follows global)', async () => { + vi.useFakeTimers(); + try { + const { worker } = makeWorker(null, true); + // Busy global → still busy + const result = await worker.tick(); + expect(result.idle).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('two workers with independent overrides toggle independently', () => { + const { worker: w1 } = makeWorker(); + const { worker: w2 } = makeWorker(); + w1.toggle(); + expect(w1.override).toBe(false); + expect(w2.override).toBe(null); + expect(w1.enabled).toBe(false); + expect(w2.enabled).toBe(true); + w2.toggle(); // → false + expect(w2.override).toBe(false); + w2.toggle(); // → null + expect(w2.override).toBe(null); + expect(w2.enabled).toBe(true); // follows global + }); + + it('override forces dispatch on when global setting is false', () => { + const { worker } = makeWorker(true, false); + expect(worker.override).toBe(true); + expect(worker.enabled).toBe(true); // override takes precedence + expect(worker.tick).toBeDefined(); + }); + + it('override set via config is respected (pre-toggled worker)', () => { + const { worker } = makeWorker(false, true); + expect(worker.override).toBe(false); + expect(worker.enabled).toBe(false); }); +}); - it('resolves {ok:false} with the exit trace on a non-zero script exit within the probe window', async () => { - const handle = { - unref: vi.fn(), - once: vi.fn((event: string, listener: (arg: unknown) => void) => { - if (event === 'exit') listener(1); - }), - }; - const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; +// ── Persisted disable marker (WL-0MT5SFP990001FNW) ─────────────────── - const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo' }, spawnFn); +describe('persisted downtime disable marker (.herdr-downtime-disabled)', () => { + let root: string; + let cwd: string; - expect(spawned).toEqual({ ok: false, exitCode: 1 }); + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'herdr-dt-marker-')); + cwd = join(root, 'worklog-root'); + mkdirSync(cwd, { recursive: true }); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function markerPath(): string { + return join(cwd, DOWNTIME_DISABLE_MARKER_FILE); + } + + function makeWorkerWithCwd( + extra: Partial[0]> = {}, + ) { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd, + noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + }; + const merged = { + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ ...cfg }), + ...extra, + }; + const worker = createDowntimeWorker(merged); + return { worker, cfg }; + } + + it('creates the marker file when toggle() disables (AC1)', () => { + const { worker } = makeWorkerWithCwd(); + expect(existsSync(markerPath())).toBe(false); + worker.toggle(); // disable + expect(worker.override).toBe(false); + expect(existsSync(markerPath())).toBe(true); + }); + + it('does not rewrite an existing marker (no write when already present, AC1)', () => { + writeFileSync(markerPath(), 'existing', 'utf8'); + // Direct helper-level check (a worker constructed with a marker present + // restores override=false, so toggle() would remove it — the no-rewrite + // guarantee of AC1 belongs to the write helper itself). + writeDisableMarker(cwd); + expect(readFileSync(markerPath(), 'utf8')).toBe('existing'); + }); + + it('removes the marker when toggle() returns to follow-settings (AC2)', () => { + const { worker } = makeWorkerWithCwd(); + worker.toggle(); // → false, marker written + expect(existsSync(markerPath())).toBe(true); + worker.toggle(); // → null, marker removed + expect(worker.override).toBe(null); + expect(existsSync(markerPath())).toBe(false); + }); + + it('removing an absent marker is a no-op (AC2)', () => { + const { worker } = makeWorkerWithCwd({ override: false }); + worker.toggle(); // → null → remove absent marker + expect(worker.override).toBe(null); + expect(existsSync(markerPath())).toBe(false); + }); + + it('restores override=false at init when the marker exists (AC3)', () => { + writeFileSync(markerPath(), '', 'utf8'); + const { worker } = makeWorkerWithCwd(); + expect(worker.override).toBe(false); + expect(worker.enabled).toBe(false); + }); + + it('explicit opts.override wins over the marker (AC3)', () => { + writeFileSync(markerPath(), '', 'utf8'); + const { worker } = makeWorkerWithCwd({ override: true }); + expect(worker.override).toBe(true); + expect(worker.enabled).toBe(true); + }); + + it('with no marker, override starts null and follows settings (AC4)', () => { + const { worker } = makeWorkerWithCwd(); + expect(worker.override).toBe(null); + expect(worker.enabled).toBe(true); + }); + + it('the marker is scoped to the worklog root (AC5)', () => { + const otherRoot = join(root, 'other-root'); + mkdirSync(otherRoot, { recursive: true }); + writeFileSync(markerPath(), '', 'utf8'); // marker in cwd only + const { worker: a } = makeWorkerWithCwd(); + const cfgB = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: otherRoot, + noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + }; + const b = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ ...cfgB }), + }); + expect(a.override).toBe(false); + expect(b.override).toBe(null); + expect(b.enabled).toBe(true); + }); + + it('marker-restored disable yields zero dispatch across idle + cooldown windows (AC1/AC6)', async () => { + writeFileSync(markerPath(), '', 'utf8'); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete', status: 'open' }, + }), + }); + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps, + config: () => ({ + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd, + noCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + }), + }); + expect(worker.override).toBe(false); // restored from marker + vi.useFakeTimers(); + try { + // Advance well past the idle threshold and the no-candidate cooldown: + // a disabled worker must short-circuit on EVERY tick (zero polls, + // zero dispatch, zero pane spawns) regardless of elapsed time. + vi.setSystemTime(Date.now() + DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS + 60_000); + const r1 = await worker.tick(); + expect(r1).toEqual({ polled: false, dispatched: false, idle: false }); + const r2 = await worker.tick(); + expect(r2).toEqual({ polled: false, dispatched: false, idle: false }); + expect(deps.getNextItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); + +// ── Pane spawn (AC4) ────────────────────────────────────────────────── + +describe('downtime pane spawn (send-to-pi.sh)', () => { + it('builds --pane-name / --no-focus / --cwd / --model args for a plan dispatch', () => { + const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toEqual([ + '--pane-name', + 'Downtime plan', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + 'Run /skill:plan WL-ABC — Some task.', + ]); + }); + + it('uses the intake pane name and forwards the configured model', () => { + const args = buildDowntimePaneArgs('intake', 'Run /skill:intake WL-DEF', { + model: 'code', + cwd: '/repo', + }); + expect(args).toContain('Downtime intake'); + expect(args).toContain('code'); + expect(args).not.toContain('Downtime plan'); + }); + + it('spawns via the injectable spawn and unrefs the child', async () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn(), once: vi.fn() })) as unknown as DowntimeSpawn; + + const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { cwd: '/repo' }, spawnFn); + + expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { + cwd: '/repo', + }); + const handle = (spawnFn as ReturnType).mock.results[0].value; + expect(handle.unref).toHaveBeenCalled(); + expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); + expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); + // No error/exit within the probe window → the pane is assumed opened. + expect(spawned).toEqual({ ok: true }); + }); + + it('resolves {ok:false} with the error trace on a handled spawn error (no unhandled-exception crash)', async () => { + const handle = { + unref: vi.fn(), + once: vi.fn((event: string, listener: (arg: unknown) => void) => { + if (event === 'error') listener(new Error('ENOENT: send-to-pi.sh')); + }), + }; + const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; + + const spawned = await spawnDowntimePane('/path/to/missing.sh', [], { cwd: '/repo' }, spawnFn); + + expect(spawned).toEqual({ ok: false, error: 'ENOENT: send-to-pi.sh' }); + expect(handle.once).toHaveBeenCalledWith('error', expect.any(Function)); + expect(handle.once).toHaveBeenCalledWith('exit', expect.any(Function)); + }); + + it('resolves {ok:false} with the exit trace on a non-zero script exit within the probe window', async () => { + const handle = { + unref: vi.fn(), + once: vi.fn((event: string, listener: (arg: unknown) => void) => { + if (event === 'exit') listener(1); + }), + }; + const spawnFn = vi.fn(() => handle) as unknown as DowntimeSpawn; + + const spawned = await spawnDowntimePane('/path/to/send-to-pi.sh', [], { cwd: '/repo' }, spawnFn); + + expect(spawned).toEqual({ ok: false, exitCode: 1 }); }); it('treats a signal-killed child (exit code null) as a failure', async () => { @@ -2077,7 +3138,7 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { } = {}) { const cfg = { enabled: overrides.enabled ?? true, - thresholdMs: overrides.thresholdMs ?? 240_000, + thresholdMs: overrides.thresholdMs ?? DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, requiredFreeSlots: 0, model: 'plan', cwd: '/repo', @@ -2174,13 +3235,13 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { // Still idle at the next tick: the tracker was reset after dispatch, so // no second dispatch until another full idle period has elapsed. - vi.setSystemTime(start + cfg.thresholdMs + 30_000); + vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); const next = await worker.tick(); expect(next.dispatched).toBe(false); expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - expect(worker.idleSince).toBe(start + cfg.thresholdMs + 30_000); + expect(worker.idleSince).toBe(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); - vi.setSystemTime(start + cfg.thresholdMs + 30_000 + cfg.thresholdMs); + vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS + cfg.thresholdMs); const after = await worker.tick(); expect(after.dispatched).toBe(true); expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); @@ -2196,7 +3257,7 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...idleAllSlotsFree, active_query: true })); - vi.setSystemTime(start + cfg.thresholdMs + 30_000); + vi.setSystemTime(start + cfg.thresholdMs + DEFAULT_DOWNTIME_POLL_INTERVAL_MS); const busy = await worker.tick(); expect(busy.idle).toBe(false); expect(worker.idleSince).toBeNull(); @@ -2236,7 +3297,7 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); const first = worker.tick(); const second = await worker.tick(); @@ -2259,7 +3320,7 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { } = {}) { const cfg = { enabled: true, - thresholdMs: 240_000, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, requiredFreeSlots: 0, model: 'plan', cwd: '/repo', @@ -2292,7 +3353,7 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); // idle run starts - vi.setSystemTime(start + 240_000); // threshold met → dispatch attempt + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met → dispatch attempt const result = await worker.tick(); @@ -2301,18 +3362,48 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { expect(worker.paused).toBe(true); }); + it('dispatches a due scheduled prompt instead of entering the no-candidate cooldown (AC6)', async () => { + // Scheduled tier first (WL-0MSS1Q5ER007QDKX): even with a genuinely + // empty backlog behind it, a DUE prompt dispatches — it never reaches + // the backlog tiers, so it never triggers the no-candidate cooldown. + const { worker, deps } = makeEmptyBacklogWorker({ + deps: { + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: 'refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, + }), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // idle run starts + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met → dispatch attempt + + const result = await worker.tick(); + + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + '/skill:refactor', + expect.objectContaining({ paneName: 'Downtime refactor' }), + ); + expect(worker.paused).toBe(false); // a dispatch — not the empty-backlog pause + expect(worker.errorStrikes).toBe(0); + }); + it('performs no proxy polling, no idle tracking and no dispatch while paused', async () => { const { worker, deps, fetcher } = makeEmptyBacklogWorker(); const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); await worker.tick(); // enters cooldown expect(worker.paused).toBe(true); const pollsBefore = fetcher.mock.calls.length; const idleBefore = worker.idleSince; - vi.setSystemTime(start + 240_000 + 60_000); // still within the pause + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 60_000); // still within the pause const result = await worker.tick(); expect(result).toEqual({ polled: false, dispatched: false, idle: false }); @@ -2338,13 +3429,13 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); await worker.tick(); // empty backlog → cooldown expect(worker.paused).toBe(true); // The project's backlog fills back up while the worker is paused. backlogEmpty = false; - vi.setSystemTime(start + 240_000 + 3_600_000); // pause expires + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // pause expires const resumed = await worker.tick(); expect(resumed.polled).toBe(true); expect(resumed.dispatched).toBe(false); // fresh idle run — no stale credit @@ -2352,7 +3443,7 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { expect(deps.spawnAgentPane).not.toHaveBeenCalled(); // A full new idle period must elapse before the next dispatch. - vi.setSystemTime(start + 240_000 + 3_600_000 + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); const afterFreshIdle = await worker.tick(); expect(afterFreshIdle.dispatched).toBe(true); // candidate now available expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); @@ -2365,7 +3456,7 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); const result = await worker.tick(); @@ -2397,7 +3488,7 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); const result = await worker.tick(); expect(result.dispatched).toBe(false); expect(worker.paused).toBe(false); // in-flight guard is not an empty backlog @@ -2418,7 +3509,7 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); const result = await worker.tick(); @@ -2433,23 +3524,23 @@ describe('downtime no-candidate cooldown (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); await worker.tick(); // enters cooldown with 3_600_000 (expires at 4_840_000) expect(worker.paused).toBe(true); // Operator lowers the cooldown live; the in-progress pause keeps its // original expiry, but the NEXT cooldown entry uses the new value. cfg.noCandidateCooldownMs = 60_000; - vi.setSystemTime(start + 240_000 + 3_600_000); // original pause expires + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // original pause expires await worker.tick(); // resumes, starts a fresh idle run - vi.setSystemTime(start + 240_000 + 3_600_000 + 240_000); // fresh threshold met + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // fresh threshold met await worker.tick(); // empty backlog again → 60s cooldown (new value) expect(worker.paused).toBe(true); // With the NEW 60s value the pause expires long before the old 60-min // default would have: at +61s the worker has resumed polling. - vi.setSystemTime(start + 240_000 + 3_600_000 + 240_000 + 61_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 61_000); const resumed = await worker.tick(); expect(resumed.polled).toBe(true); expect(worker.paused).toBe(false); @@ -2463,7 +3554,7 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { function makeErrorWorker(overrides: { deps?: Partial } = {}) { const cfg = { enabled: true, - thresholdMs: 240_000, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, requiredFreeSlots: 0, model: 'plan', cwd: '/repo', @@ -2488,7 +3579,7 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); // idle run starts - vi.setSystemTime(start + 240_000); // threshold met + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // threshold met const s1 = await worker.tick(); // strike 1 expect(s1.dispatched).toBe(false); @@ -2526,7 +3617,7 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); const s1 = await worker.tick(); expect(s1.dispatched).toBe(false); @@ -2554,20 +3645,20 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); await worker.tick(); // strike 1 await worker.tick(); // strike 2 await worker.tick(); // strike 3 → paused expect(worker.paused).toBe(true); - vi.setSystemTime(start + 240_000 + 3_600_000); // pause expires + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000); // pause expires const resumed = await worker.tick(); expect(resumed.polled).toBe(true); expect(worker.paused).toBe(false); expect(worker.errorStrikes).toBe(0); // A fresh error is strike 1 again — not a strike 4. - vi.setSystemTime(start + 240_000 + 3_600_000 + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 3_600_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); await worker.tick(); expect(worker.errorStrikes).toBe(1); expect(worker.paused).toBe(false); @@ -2587,7 +3678,7 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); await worker.tick(); // strike 1 expect(worker.errorStrikes).toBe(1); @@ -2598,9 +3689,9 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { // A fresh full idle period is required after the dispatch (AC5) before // the next dispatch attempt. - vi.setSystemTime(start + 240_000 + 240_000); // fresh idle run starts + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); // fresh idle run starts await worker.tick(); - vi.setSystemTime(start + 240_000 + 480_000); // fresh threshold met + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 480_000); // fresh threshold met await worker.tick(); // strike 1 (fresh — not a strike 2) expect(worker.errorStrikes).toBe(1); await worker.tick(); // strike 2 @@ -2625,7 +3716,7 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { const start = 1_000_000; vi.setSystemTime(start); await worker.tick(); - vi.setSystemTime(start + 240_000); + vi.setSystemTime(start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); await worker.tick(); // strike 1 expect(worker.errorStrikes).toBe(1); @@ -2646,10 +3737,11 @@ describe('three-strike rule on CLI errors (createDowntimeWorker)', () => { // duration PER SLOT IDENTITY so the SAME N slots are free for the full // threshold — never any-N transient availability. // -// Red phase: all tests in this section fail against the current -// implementation (which has no per-slot parsing, no per-slot tracker, and no -// per-slot evaluateIdle/worker routing) until the implementation feature -// WL-0MSP28LSY007NDYX lands. +// The proxy (observability.py) serves `slot_id` as an INTEGER +// (`slot.get("id", i)`); `parseLlamaStatus` coerces numeric ids to strings +// and clamps negatives to 0 (WL-0MSVRMAWM007QNR5 — the Aug 15-16 +// zero-dispatch regression fix). Regression tests mirror the live payload +// at parse level and end-to-end through the worker tick. // ── parseLlamaStatus: optional per-slot fields (AC1/AC2) ─────────────── @@ -2698,9 +3790,65 @@ describe('parseLlamaStatus per-slot slots array', () => { expect(parseLlamaStatus({ ...base, slots: [{ is_processing: false }] })).toBeNull(); }); - it('treats a non-string or empty slot_id as malformed → null', () => { + it('treats an empty-string slot_id as malformed → null (zero-length guard preserved)', () => { expect(parseLlamaStatus({ ...base, slots: [{ slot_id: '', is_processing: false }] })).toBeNull(); - expect(parseLlamaStatus({ ...base, slots: [{ slot_id: 42, is_processing: false }] })).toBeNull(); + }); + + it('coerces numeric slot_ids to strings (proxy contract: int, WL-0MSVRMAWM007QNR5)', () => { + const status = parseLlamaStatus({ + ...base, + slots: [ + { slot_id: 0, is_processing: false }, + { slot_id: 3, is_processing: true }, + ], + }); + expect(status).not.toBeNull(); + expect(status!.slots).toEqual([ + { slot_id: '0', is_processing: false }, + { slot_id: '3', is_processing: true }, + ]); + }); + + it('clamps negative numeric slot_ids to 0 (WL-0MSVRMAWM007QNR5)', () => { + const status = parseLlamaStatus({ + ...base, + slots: [{ slot_id: -2, is_processing: false }], + }); + expect(status).not.toBeNull(); + expect(status!.slots).toEqual([{ slot_id: '0', is_processing: false }]); + }); + + it('treats a non-finite or non-integer numeric slot_id as malformed → null (fail-closed)', () => { + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: Number.NaN, is_processing: false }] })).toBeNull(); + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: Number.POSITIVE_INFINITY, is_processing: false }] })).toBeNull(); + expect(parseLlamaStatus({ ...base, slots: [{ slot_id: 1.5, is_processing: false }] })).toBeNull(); + }); + + it('regression: parses the live proxy payload (integer slot_ids, WL-0MSVRMAWM007QNR5)', () => { + // observability.py serves `"slot_id": slot.get("id", i)` — an integer — + // per slot. Before the fix, parseLlamaStatus rejected the numeric + // slot_id and every 30s poll failed closed to busy — the Aug 15-16 + // zero-dispatch regression. Mirror the live payload exactly. + const livePayload = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + slots: [ + { slot_id: 0, is_processing: false }, + { slot_id: 1, is_processing: false }, + { slot_id: 2, is_processing: false }, + { slot_id: 3, is_processing: false }, + ], + }; + const status = parseLlamaStatus(livePayload); + expect(status).not.toBeNull(); + expect(status!.slots!.map((s) => s.slot_id)).toEqual(['0', '1', '2', '3']); + // The coerced payload is a valid idle status for the default N=0 AND + // for per-slot mode (0 < N < total) — dispatch can resume. + expect(evaluateIdle(status!, 0)).toBe(true); + expect(evaluateIdle(status!, 2)).toBe(true); }); it('treats a non-boolean or missing is_processing as malformed → null', () => { @@ -2778,9 +3926,9 @@ describe('per-slot idle tracker (createPerSlotIdleTracker)', () => { const start = 1_000_000; tracker.record([a, b], start); - expect(tracker.thresholdMetCount(240_000, start + 239_999)).toBe(0); - expect(tracker.thresholdMetCount(240_000, start + 240_000)).toBe(2); - expect(tracker.thresholdMetCount(240_000, start + 500_000)).toBe(2); + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS - 1)).toBe(0); + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(2); + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + 500_000)).toBe(2); }); it('a reset slot needs a fresh full run before it counts toward the threshold again', () => { @@ -2793,9 +3941,9 @@ describe('per-slot idle tracker (createPerSlotIdleTracker)', () => { tracker.record([a, b], start + 20_000); // Only b has a full continuous run at start+threshold+5s. - expect(tracker.thresholdMetCount(240_000, start + 240_000 + 5_000)).toBe(1); + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS + 5_000)).toBe(1); // a completes its fresh run at start+20s+threshold → both count. - expect(tracker.thresholdMetCount(240_000, start + 20_000 + 240_000)).toBe(2); + expect(tracker.thresholdMetCount(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, start + 20_000 + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS)).toBe(2); }); }); @@ -2821,11 +3969,16 @@ describe('evaluateIdle per-slot mode (0 < N < total with slots present)', () => expect(evaluateIdle(twoFree, 3)).toBe(false); // needs 3 — not enough }); - it('per-slot mode still requires the base idle checks (global busy → false)', () => { - expect(evaluateIdle({ ...twoFree, active_query: true }, 2)).toBe(false); - expect(evaluateIdle({ ...twoFree, model_switch_in_progress: true }, 2)).toBe(false); - expect(evaluateIdle({ ...twoFree, local_lease_active: true }, 2)).toBe(false); - expect(evaluateIdle({ ...twoFree, llama_server_running: false }, 2)).toBe(false); + it('per-slot mode still requires the relaxed global checks (server up + no model switch)', () => { + // Spare-capacity relaxation (parent WL-0MT32F90V008UAD2 AC2): in per-slot + // mode ONLY llama_server_running and model_switch_in_progress remain + // global gates — query/lease signals are superseded by per-slot + // is_processing (a busy slot's query/lease is the operator's own session). + expect(evaluateIdle({ ...twoFree, active_query: true }, 2)).toBe(true); // relaxed: query does NOT block + expect(evaluateIdle({ ...twoFree, local_active_query: true }, 2)).toBe(true); // relaxed + expect(evaluateIdle({ ...twoFree, local_lease_active: true }, 2)).toBe(true); // relaxed: lease does NOT block + expect(evaluateIdle({ ...twoFree, model_switch_in_progress: true }, 2)).toBe(false); // still global + expect(evaluateIdle({ ...twoFree, llama_server_running: false }, 2)).toBe(false); // still global }); it('N=0 (default) still requires ALL slots free even when per-slot data is present', () => { @@ -2843,68 +3996,401 @@ describe('evaluateIdle per-slot mode (0 < N < total with slots present)', () => }); }); -// ── Worker routing: per-slot mode vs fallback (WL-0MSG7P9N8009PCKG AC) ─ +// ── Spare-capacity dispatch (parent WL-0MT32F90V008UAD2, F1) ────────── +// Tests for the spare-capacity dispatch feature: relaxed global gate in +// per-slot mode (local_active_query / local_lease_active do NOT block), +// the new DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 2, per-tier free-slot +// minimums at dispatch selection, and unchanged pre-fix proxy degradation. -describe('downtime worker per-slot routing (createDowntimeWorker)', () => { - function makePerSlotWorker(overrides: { - requiredFreeSlots?: number; - thresholdMs?: number; - status?: unknown; - deps?: Partial; - } = {}) { - const cfg = { - enabled: true, - thresholdMs: overrides.thresholdMs ?? 240_000, - requiredFreeSlots: overrides.requiredFreeSlots ?? 2, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - }; - const fetcher = vi - .fn() - .mockResolvedValue(jsonResponseFixture(overrides.status ?? perSlotTwoFree)); - const poller = createDowntimePoller('http://proxy:8000', fetcher); - const deps = makeDeps({ - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - ...overrides.deps, - }); - const worker = createDowntimeWorker({ - poller, - deps, - config: () => ({ ...cfg }), - }); - return { worker, deps, cfg, fetcher }; - } +/** Per-slot status: 3 of 4 slots free (slot-1 busy), with global query/lease + * signals active — exactly the "operator session" pattern the spare-capacity + * dispatch must handle. */ +function perSlotThreeOfFourFreeActiveQuery(): LlamaStatus { + return { + llama_server_running: true, + active_query: true, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 3, + total_slots: 4, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + { slot_id: 'slot-4', is_processing: false }, + ], + }; +} - beforeEach(() => { - vi.useFakeTimers(); +describe('spare-capacity dispatch: relaxed global idle gate (per-slot mode)', () => { + it('per-slot mode: a mid-query (active_query) busy slot does NOT block dispatch into free slots (AC1)', () => { + // 3 of 4 slots free with active_query=true, local_active_query=true, + // local_lease_active=true. In the relaxed global gate (AC1), these + // per-slot query/lease signals are superseded by per-slot is_processing. + // Only llama_server_running and model_switch_in_progress stay global. + const status = perSlotThreeOfFourFreeActiveQuery(); + // The new relaxed check: active_query/local_active_query/local_lease + // are NOT blocking in per-slot mode — 3 free >= N=2. + expect(evaluateIdle(status, 2)).toBe(true); // spare capacity: 3 >= 2 free }); - afterEach(() => { - vi.useRealTimers(); + it('per-slot mode: local_lease_active on a busy slot does NOT block dispatch (AC1)', () => { + const status: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 3, + total_slots: 4, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + { slot_id: 'slot-4', is_processing: false }, + ], + }; + // local_lease_active is per-slot; in per-slot mode it does NOT block. + expect(evaluateIdle(status, 2)).toBe(true); // 3 free >= 2 }); - it('routes to per-slot mode: dispatches with only N of total slots free (same slots, full threshold)', async () => { - // perSlotTwoFree: 2 of 4 slots free. Legacy all-slots-free mode would - // NEVER dispatch (available 2 < 4); per-slot mode dispatches after the - // threshold because the SAME 2 slots have been free throughout. - const { worker, deps, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); - const start = 1_000_000; - vi.setSystemTime(start); - await worker.tick(); - expect(worker.idleSince ?? null).not.toBeNull(); // idle run starts + it('per-slot mode: local_active_query on a busy slot does NOT block dispatch (AC2)', () => { + const status: LlamaStatus = { + llama_server_running: true, + active_query: true, + model_switch_in_progress: false, + local_active_query: true, + local_lease_active: false, + available_slots: 2, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + }; + // 2 of 3 free with a mid-query busy slot — per-slot mode should fire. + expect(evaluateIdle(status, 2)).toBe(true); // AC2: mid-query busy slot + }); - vi.setSystemTime(start + cfg.thresholdMs - 1); - const before = await worker.tick(); - expect(before.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + it('per-slot mode: model_switch_in_progress still blocks dispatch (global gate)', () => { + const status = perSlotThreeOfFourFreeActiveQuery(); + // model_switch_in_progress is ALWAYS global — even in per-slot mode. + expect(evaluateIdle({ ...status, model_switch_in_progress: true }, 2)).toBe(false); + }); - vi.setSystemTime(start + cfg.thresholdMs); - const at = await worker.tick(); - expect(at.dispatched).toBe(true); + it('per-slot mode: llama_server_running=false still blocks dispatch (global gate)', () => { + const status = perSlotThreeOfFourFreeActiveQuery(); + // server down is ALWAYS global — even in per-slot mode. + expect(evaluateIdle({ ...status, llama_server_running: false }, 2)).toBe(false); + }); +}); + +describe('spare-capacity dispatch: DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 2 (AC1)', () => { + it('DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS should be 2 for out-of-the-box spare-capacity', () => { + // The new default: 2 of 3 slots must be free for dispatch. + expect(DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS).toBe(2); + }); +}); + +describe('spare-capacity dispatch: pre-fix proxy degradation unchanged (AC4)', () => { + it('pre-fix proxy (no per-slot data): a configured N (0 { + // Without per-slot identity, N=2 with 4 slots should still require ALL + // slots free (fail-closed degradation, parent AC4). + const status: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 2, + total_slots: 4, + }; + // No slots array — all-slots-free path: 2 free < 4 required → busy. + expect(evaluateIdle(status, 2)).toBe(false); // 2 < 4 → busy (all-slots-free) + // Even when N equals total (4), 2 < 4 available → still busy. + expect(evaluateIdle(status, 4)).toBe(false); // 2 < 4 → busy + // Now with all slots free: 4 === 4 → idle. + const allFree: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + expect(evaluateIdle(allFree, 2)).toBe(true); // 4 >= 4 (all-slots-free) → idle + }); + + it('pre-fix proxy with N=0 (default): all-slots-free behavior unchanged', () => { + const status: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 2, + total_slots: 4, + }; + // N=0 requires all slots free regardless of per-slot mode. + expect(evaluateIdle(status, 0)).toBe(false); // 2 < 4 → busy + }); +}); + +describe('spare-capacity dispatch: ambiguous payloads are busy (AC5)', () => { + it('missing slot_id fields are treated as busy (fail-closed)', () => { + const malformed: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 4, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + ], + }; + // The slots array has only 2 entries for 4 total — this is ambiguous. + // With per-slot mode active (0 < N < total), the free count is 1 < 2. + expect(evaluateIdle(malformed, 2)).toBe(false); + }); + + it('total_slots 0 is always busy', () => { + const status: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 0, + total_slots: 0, + }; + expect(evaluateIdle(status, 2)).toBe(false); + }); + + it('N > total can never be idle', () => { + const status: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: false }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + }; + // 3 free < 4 required — never idle. + expect(evaluateIdle(status, 4)).toBe(false); + }); +}); + +// ── Worker routing: per-slot mode vs fallback (WL-0MSG7P9N8009PCKG AC) ─ + +describe('downtime worker per-slot routing (createDowntimeWorker)', () => { + function makePerSlotWorker(overrides: { + requiredFreeSlots?: number; + thresholdMs?: number; + status?: unknown; + deps?: Partial; + } = {}) { + const cfg = { + enabled: true, + thresholdMs: overrides.thresholdMs ?? DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: overrides.requiredFreeSlots ?? 2, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const fetcher = vi + .fn() + .mockResolvedValue(jsonResponseFixture(overrides.status ?? perSlotTwoFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('routes to per-slot mode: dispatches with only N of total slots free (same slots, full threshold)', async () => { + // perSlotTwoFree: 2 of 4 slots free. Legacy all-slots-free mode would + // NEVER dispatch (available 2 < 4); per-slot mode dispatches after the + // threshold because the SAME 2 slots have been free throughout. + const { worker, deps, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + // idleSince is null because slots 3,4 are processing — the title bar + // must show "busy", not "idle". Dispatch is unaffected (perSlotTracker + // still counts the 2 free slots for spare-capacity dispatch). + expect(worker.idleSince).toBeNull(); + + vi.setSystemTime(start + cfg.thresholdMs - 1); + const before = await worker.tick(); + expect(before.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('AC1 spare-capacity: 3 slots/1 busy with the operator session active (query+lease) still dispatches into the 2 free slots', async () => { + // parent ACL1: 2 of 3 slots continuously free for the full threshold → + // dispatch fires even though 1 slot is occupied by an active + // (query + lease) session — exactly the operator working in the TUI + // while the downtime worker uses the spare capacity. + const operatorSessionPayload = { + llama_server_running: true, + active_query: true, + local_active_query: true, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 2, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + }; + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ + requiredFreeSlots: 2, + status: operatorSessionPayload, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // 2 free → per-slot idle run starts + // idleSince is null because slot-1 is processing — title bar shows + // "busy" even though dispatch fires into free slots. + expect(worker.idleSince).toBeNull(); + + vi.setSystemTime(start + cfg.thresholdMs - 1); + const before = await worker.tick(); + expect(before.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); // dispatch into the 2 free slots + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(fetcher).toHaveBeenCalled(); + }); + + it('AC2 spare-capacity: a mid-query (processing) busy slot does NOT reset the free slots\' timers', async () => { + // parent AC2: with N free of total in per-slot mode, idle is evaluated + // from the free slots only. A processing slot 1 does not reset slot-2 / + // slot-3 timers — dispatch still fires once the free slots have been + // continuously free for the full threshold (the busy slot is the + // operator's own mid-query session). + const { worker, deps, cfg, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // perSlotTwoFree (slot-1, slot-2 free) → timers start + + // slot-1 goes mid-query while slot-2 stays free; the global query signal + // also fires. Spare-capacity relaxation: only slot-1's timer resets. + fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...perSlotTwoFree, active_query: true })); + vi.setSystemTime(start + cfg.thresholdMs - 10_000); + const mid = await worker.tick(); + expect(mid.idle).toBe(true); // free count still ≥ N=2 (slot-1 busy) + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); // slot-2 ran the full threshold + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('AC3 tier minimums flow from the poll: 1 free slot skips the audit tier, plan dispatches (worker end-to-end)', async () => { + // N=1 with a single free slot: the idle gate is satisfied (1 ≥ N), but + // the AUDIT tier needs ≥2 free slots (parent WL-0MT32F90V008UAD2 AC3) — + // the worker must pass the polled free count into the tier selection so + // the audit candidate is skipped and the plan pane dispatches instead. + const oneFreePayload = { + llama_server_running: true, + active_query: true, + model_switch_in_progress: false, + local_lease_active: true, + available_slots: 1, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: true }, + { slot_id: 'slot-3', is_processing: false }, + ], + }; + const { worker, deps, cfg } = makePerSlotWorker({ + requiredFreeSlots: 1, + status: oneFreePayload, + deps: { + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, + }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete', status: 'open' }, + }), + }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // 1 free slot ≥ N=1 → per-slot idle run starts + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + + // The worker saw freeSlots=1: the audit candidate was never consulted + // and the plan pane dispatched. + expect(deps.getNextAuditCandidate).not.toHaveBeenCalled(); + expect(at.dispatched).toBe(true); + expect((deps.spawnAgentPane as ReturnType).mock.calls[0][0]).toContain('/skill:plan WL-PLAN'); + }); + + it('regression: a live proxy payload (integer slot_ids) idles and dispatches (WL-0MSVRMAWM007QNR5)', async () => { + // observability.py serves `"slot_id": slot.get("id", i)` — an integer — + // for each slot. Before the fix, parseLlamaStatus rejected the numeric + // slot_id, so every poll failed closed to busy and the worker never + // dispatched (the Aug 15-16 zero-dispatch regression). This mirrors the + // live payload end-to-end: poll → parse → idle run → threshold → dispatch. + const livePayload = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + slots: [ + { slot_id: 0, is_processing: false }, + { slot_id: 1, is_processing: false }, + { slot_id: 2, is_processing: false }, + { slot_id: 3, is_processing: false }, + ], + }; + const { worker, deps, cfg } = makePerSlotWorker({ status: livePayload }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince ?? null).not.toBeNull(); // idle run starts + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); }); @@ -2973,7 +4459,11 @@ describe('downtime worker per-slot routing (createDowntimeWorker)', () => { vi.setSystemTime(start); await worker.tick(); // all free → timers start - fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...perSlotAllFree, active_query: true })); + // model_switch_in_progress is a GLOBAL gate in per-slot mode (spare- + // capacity relaxation, parent WL-0MT32F90V008UAD2 AC2) — it resets + // every slot timer. A per-slot query/lease (active_query) is NOT global + // anymore and is covered by the dedicated spare-capacity tests. + fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...perSlotAllFree, model_switch_in_progress: true })); vi.setSystemTime(start + 10_000); await worker.tick(); // global busy → every slot timer reset @@ -3052,4 +4542,973 @@ describe('downtime worker per-slot routing (createDowntimeWorker)', () => { expect(at.dispatched).toBe(false); expect(deps.spawnAgentPane).not.toHaveBeenCalled(); }); + + // ── Title bar idle display (parent WL-0MT65T14L002HTWB) ──────────── + // The global idle tracker (`worker.idleSince`) drives the title bar + // `[⏳ downtime idle Xs]` indicator. In per-slot mode the relaxed + // per-slot global gate deliberately ignores query/busy signals for + // DISPATCH; the DISPLAY must still reflect actual per-slot activity. + // idleSince (the display signal) is null whenever ANY slot is + // processing — the title bar shows `[downtime busy]` — while dispatch + // into free slots is unaffected. + + it('display: title bar shows busy when ANY slot is processing (incl. the operator\'s)', async () => { + // perSlotTwoFree: slots 3,4 processing. The title-bar display must show + // "busy" (idleSince null) even though the per-slot dispatch gate (2 + // free slots) is satisfied. + const { worker, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBeNull(); // → `[downtime busy]` in the title bar + + // Even after the dispatch threshold elapses, the display stays busy + // while a slot is processing (dispatch itself fires per usual). + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); // dispatch still fires (spare capacity) + expect(worker.idleSince).toBeNull(); // display never flips to idle mid-query + }); + + it('display: title bar shows idle ONLY when every slot is free', async () => { + // perSlotAllFree: all 4 slots free — no active processing anywhere, so + // the display can honestly show the idle counter. + const { worker, fetcher, cfg } = makePerSlotWorker({ + requiredFreeSlots: 2, + status: perSlotAllFree, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); // → `[⏳ downtime idle 0:00]` + + // Keep the status for the second tick (default is perSlotTwoFree which + // has processing slots). Check BELOW the dispatch threshold so the tick + // does not dispatch (which would reset idleSince by design). + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + cfg.thresholdMs - 10_000); + await worker.tick(); + expect(worker.idleSince).toBe(start); // run start stays fixed while free + }); + + it('display: a slot starting processing resets the idle counter; freeing it restarts a fresh run', async () => { + // Demonstrates that the display (idleSince) tracks actual per-slot + // processing state — completely independent of dispatch logic. + const { worker, fetcher, cfg } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // perSlotTwoFree: slots 3,4 processing → display busy + expect(worker.idleSince).toBeNull(); + + // All slots free → the idle counter starts. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + 10_000); + await worker.tick(); + expect(worker.idleSince).toBe(start + 10_000); // → `[⏳ downtime idle 0:00]` + + // slot-1 goes processing → display flips back to busy. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); + vi.setSystemTime(start + 20_000); + await worker.tick(); + expect(worker.idleSince).toBeNull(); // → `[downtime busy]` + + // Free slots (3) ≥ N=2 and their timers (since +10s) exceed threshold. + // Dispatch fires into the 3 free slots while display shows busy. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); + vi.setSystemTime(start + 20_000 + cfg.thresholdMs); + const at = await worker.tick(); + expect(worker.idleSince).toBeNull(); // display stays busy (slot still processing) + expect(at.dispatched).toBe(true); // dispatch fires (3 ≥ 2 free) + }); + + it('display: transition back to all-free after processing restarts the idle counter', async () => { + // After a busy period, the display should start counting again once + // all slots are free. + const { worker, fetcher } = makePerSlotWorker({ requiredFreeSlots: 2 }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); // perSlotTwoFree: slots 3,4 processing → busy + expect(worker.idleSince).toBeNull(); + + // All slots free → idle counter starts at current time. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + 10_000); + await worker.tick(); + expect(worker.idleSince).toBe(start + 10_000); + + // slot-1 goes processing → display resets to busy. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotOneProcessing)); + vi.setSystemTime(start + 20_000); + await worker.tick(); + expect(worker.idleSince).toBeNull(); + + // All slots free again → display should now count from +20s. + fetcher.mockResolvedValueOnce(jsonResponseFixture(perSlotAllFree)); + vi.setSystemTime(start + 30_000); + await worker.tick(); + expect(worker.idleSince).toBe(start + 30_000); // fresh count from +30s + }); + + it('display: global busy (server down / model switch) also resets idleSince to busy', async () => { + const { worker, fetcher } = makePerSlotWorker({ + requiredFreeSlots: 2, + status: perSlotAllFree, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); + + // model_switch_in_progress is a GLOBAL gate — display shows busy. + fetcher.mockResolvedValueOnce( + jsonResponseFixture({ ...perSlotAllFree, model_switch_in_progress: true }), + ); + vi.setSystemTime(start + 10_000); + await worker.tick(); + expect(worker.idleSince).toBeNull(); // → `[downtime busy]` + }); +}); + +// ── Rotation-aware selection (WL-0MSSRED76008LGB6 / WL-0MSW6CKBY007RB7O) ── + +describe('selectWithRotation (round-robin tie-break)', () => { + const mk = (id: string, priority: string, sortIndex: number) => ({ + id, priority, sortIndex, + }); + + it('empty list → null', () => { + expect(selectWithRotation([])).toBeNull(); + }); + + it('single candidate → that candidate (no rotation)', () => { + const c = mk('a', 'high', 1); + expect(selectWithRotation([c])?.id).toBe('a'); + }); + + it('no registry → first by sortIndex (fail-open pre-rotation behaviour)', () => { + const items = [mk('a', 'high', 2), mk('b', 'high', 1)]; + expect(selectWithRotation(items)?.id).toBe('b'); // lowest sortIndex + }); + + it('candidates without priority → first by sortIndex (fail-open)', () => { + const items = [{ id: 'x', sortIndex: 5 }, { id: 'y', sortIndex: 3 }]; + expect(selectWithRotation(items)?.id).toBe('y'); + }); + + it('rotates through tied top-priority group, skipping lower priority', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-rotation', rng: () => 0.5 }); + const items = [ + mk('low1', 'low', 10), + mk('high1', 'high', 1), + mk('high2', 'high', 2), + mk('medium1', 'medium', 5), + ]; + // Cursor starts at 0 → selects high1; advances to 1 → high2; then back to high1 + expect(selectWithRotation(items, registry)?.id).toBe('high1'); + expect(selectWithRotation(items, registry)?.id).toBe('high2'); + expect(selectWithRotation(items, registry)?.id).toBe('high1'); + }); + + it('single-member top group → no rotation (higher priority still wins)', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-single', rng: () => 0.5 }); + const items = [mk('crit1', 'critical', 1), mk('high1', 'high', 2), mk('high2', 'high', 3)]; + // critical group has 1 member → always crit1, regardless of cursor + expect(selectWithRotation(items, registry)?.id).toBe('crit1'); + expect(selectWithRotation(items, registry)?.id).toBe('crit1'); + }); + + it('priority-first preserved: lower-priority candidate never selected while higher tier has members', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-worker-priority', rng: () => 0.5 }); + const items = [ + mk('med1', 'medium', 10), + mk('med2', 'medium', 11), + mk('low1', 'low', 20), + ]; + // Only medium group has members → rotation within medium only + expect(selectWithRotation(items, registry)?.id).toBe('med1'); + expect(selectWithRotation(items, registry)?.id).toBe('med2'); + expect(selectWithRotation(items, registry)?.id).toBe('med1'); + }); +}); + +describe('rotation-aware selection wired into tiers', () => { + it('selectImplementCandidate rotates within tied priority when registry given', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-implement', rng: () => 0.5 }); + const candidates: ImplementCandidate[] = [ + { id: 'A', title: 'A', status: 'open', priority: 'high', sortIndex: 1, risk: 'low', effort: 'small' }, + { id: 'B', title: 'B', status: 'open', priority: 'high', sortIndex: 2, risk: 'low', effort: 'small' }, + ]; + expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('A'); + expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('B'); + expect(selectImplementCandidate(candidates, undefined, registry)?.id).toBe('A'); + }); + + it('selectNextCandidate rotates within tied priority when registry given', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-next', rng: () => 0.5 }); + const candidates: DowntimeCandidate[] = [ + { id: 'X', title: 'X', stage: 'plan', status: 'open', priority: 'high', sortIndex: 1 }, + { id: 'Y', title: 'Y', stage: 'plan', status: 'open', priority: 'high', sortIndex: 2 }, + ]; + expect(selectNextCandidate(candidates, undefined, registry)?.id).toBe('X'); + expect(selectNextCandidate(candidates, undefined, registry)?.id).toBe('Y'); + }); + + it('selectAuditCandidate rotates within tied priority when registry given', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-audit', rng: () => 0.5 }); + const now = Date.now(); + const candidates: AuditCandidate[] = [ + { id: 'P', title: 'P', priority: 'high', sortIndex: 1, updatedAt: new Date(now).toISOString() }, + { id: 'Q', title: 'Q', priority: 'high', sortIndex: 2, updatedAt: new Date(now).toISOString() }, + ]; + expect(selectAuditCandidate(candidates, now, undefined, registry)?.id).toBe('P'); + expect(selectAuditCandidate(candidates, now, undefined, registry)?.id).toBe('Q'); + }); +}); + +// ── Probe jitter (WL-0MSSRED76008LGB6 / WL-0MSW6DEI9005XH8Q) ────────── + +describe('worker probe jitter (jitterPollIntervalMs)', () => { + it('returns jittered interval within ±50% when a registry is provided', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-deps', rng: () => 0.75 }); + const { worker } = (() => { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const w = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + registry, + config: () => ({ ...cfg }), + }); + return { worker: w }; + })(); + // rng 0.75 → factor 1.25 → 10_000 * 1.25 = 12_500 + expect(worker.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(12_500); + }); + + it('returns the static interval when no registry is provided (fail-open)', () => { + const { worker } = (() => { + const cfg = { + enabled: true, + thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }; + const w = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ ...cfg }), + }); + return { worker: w }; + })(); + expect(worker.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(10_000); + }); + + it('two workers with different RNG produce different jitter values', () => { + const r1 = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-a', rng: () => 0.1 }); + const r2 = createRoundRobinRegistry({ worklogDir: '/tmp/rr-jitter-b', rng: () => 0.9 }); + const mk = (registry: typeof r1) => createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + registry, + config: () => ({ enabled: true, thresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, requiredFreeSlots: 0, model: 'plan', cwd: '/repo', noCandidateCooldownMs: 3_600_000 }), + }); + const w1 = mk(r1); + const w2 = mk(r2); + // rng 0.1 → 0.6× = 6_000; rng 0.9 → 1.4× = 14_000 + expect(w1.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(6_000); + expect(w2.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).toBe(14_000); + expect(w1.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)).not.toBe(w2.jitterPollIntervalMs(DEFAULT_DOWNTIME_POLL_INTERVAL_MS)); + }); +}); + +// ── Critical-first tier (parent WL-0MT3FM8VA005XBHE) ──────────────────── +// Test-first matrix for the critical-first pre-tier: an open critical item +// at ANY stage (idea / intake_complete / plan_complete) is dispatched with +// its stage-appropriate skill ahead of every non-critical candidate. The +// F2/F3/F4 contract being pinned here (parseCriticalCandidatesOutput, +// selectCriticalCandidate, criticalSkillKind, resolveDependencyFrontier, +// the getNextCriticalCandidate dep, and the tier order) is RED until the +// implementation slices land (WL-0MT3I9UVU004722X, WL-0MT3I9YNZ007IC5V, +// WL-0MT3IA1UB005TLVJ) — the tests reference exports that do not exist yet +// and fail as a unit (the rest of this file stays green). + +// ── Candidate shapes reused across the critical matrix ────────────── + +/** Open critical worklog item at a dispatchable stage. */ +function criticalIdea( + id = 'WL-CRIT-I', + overrides: Record = {}, +): Record { + return { + id, + title: `Critical idea ${id}`, + status: 'open', + stage: 'idea', + priority: 'critical', + sortIndex: 100, + risk: 'low', + effort: 'small', + ...overrides, + }; +} + +/** Open critical worklog item at intake_complete (plan-ready). */ +function criticalReady( + id = 'WL-CRIT-R', + overrides: Record = {}, +): Record { + return { + id, + title: `Critical ready ${id}`, + status: 'open', + stage: 'intake_complete', + priority: 'critical', + sortIndex: 100, + risk: 'low', + effort: 'small', + ...overrides, + }; +} + +/** Open critical worklog item at plan_complete (implement-ready). */ +function criticalPlanned( + id = 'WL-CRIT-P', + overrides: Record = {}, +): Record { + return { + id, + title: `Critical planned ${id}`, + status: 'open', + stage: 'plan_complete', + priority: 'critical', + sortIndex: 100, + risk: 'medium', + effort: 'medium', + ...overrides, + }; +} + +// ── Critical-first: selection helpers (F2) ─────────────────────────── + +describe('critical selection (selectCriticalCandidate)', () => { + it('selects a critical idea candidate (stage-appropriate intake)', () => { + const selected = selectCriticalCandidate([criticalIdea()] as never); + expect(selected?.id).toBe('WL-CRIT-I'); + expect(selected?.stage).toBe('idea'); + expect(criticalSkillKind(selected?.stage ?? '')).toBe('intake'); + }); + + it('selects a critical intake_complete candidate (stage-appropriate plan)', () => { + const selected = selectCriticalCandidate([criticalReady()] as never); + expect(selected?.id).toBe('WL-CRIT-R'); + expect(selected?.stage).toBe('intake_complete'); + expect(criticalSkillKind(selected?.stage ?? '')).toBe('plan'); + }); + + it('selects a critical plan_complete candidate within caps (stage-appropriate implement)', () => { + const selected = selectCriticalCandidate([criticalPlanned()] as never); + expect(selected?.id).toBe('WL-CRIT-P'); + expect(selected?.stage).toBe('plan_complete'); + expect(criticalSkillKind(selected?.stage ?? '')).toBe('implement'); + }); + + it('stage→skill mapping is exact: idea→intake, intake_complete→plan, plan_complete→implement', () => { + expect(criticalSkillKind('idea')).toBe('intake'); + expect(criticalSkillKind('intake_complete')).toBe('plan'); + expect(criticalSkillKind('plan_complete')).toBe('implement'); + }); + + it('non-dispatchable stages map to null (in_progress / in_review are not targets)', () => { + expect(criticalSkillKind('in_progress')).toBeNull(); + expect(criticalSkillKind('in_review')).toBeNull(); + expect(criticalSkillKind('completed')).toBeNull(); + expect(criticalSkillKind('')).toBeNull(); + }); + + it('deterministic selection: lowest sortIndex wins across stages (sortIndex priority order)', () => { + const candidates = [ + criticalReady('WL-CRIT-HIGH-IDX', { sortIndex: 900 }), + criticalIdea('WL-CRIT-LOW-IDX', { sortIndex: 10 }), + criticalPlanned('WL-CRIT-MID', { sortIndex: 500 }), + ] as never; + expect(selectCriticalCandidate(candidates)?.id).toBe('WL-CRIT-LOW-IDX'); + }); + + it('plan_complete above the risk cap is NOT implement-selected (caps retained, Q2)', () => { + const highRisk = criticalPlanned('WL-CRIT-HI-RISK', { risk: 'high' }); + expect(selectCriticalCandidate([highRisk] as never)).toBeNull(); + }); + + it('plan_complete above the effort cap is NOT implement-selected (caps retained, Q2)', () => { + const largeEffort = criticalPlanned('WL-CRIT-HI-EFFORT', { effort: 'large' }); + expect(selectCriticalCandidate([largeEffort] as never)).toBeNull(); + }); + + it('a capped-out plan_complete critical does not shadow a within-cap critical sibling', () => { + const candidates = [ + criticalPlanned('WL-CRIT-HI-RISK', { risk: 'high', sortIndex: 10 }), + criticalReady('WL-CRIT-OK', { sortIndex: 900 }), + ] as never; + // The capped critical is excluded, so the next critical wins (even with + // a higher sortIndex) — caps never multi-starve the critical tier. + expect(selectCriticalCandidate(candidates)?.id).toBe('WL-CRIT-OK'); + }); + + it('excludes non-open items (status=open client-side guard)', () => { + const inProgress = criticalIdea('WL-CRIT-INPROG', { status: 'in_progress' }); + expect(selectCriticalCandidate([inProgress] as never)).toBeNull(); + }); + + it('excludes candidates still at their dispatched-at stage (dispatched-marker change-guard)', () => { + const dispatched = new Map([['WL-CRIT-I', 'idea']]); + const other = criticalReady('WL-CRIT-OTHER'); + const selected = selectCriticalCandidate( + [criticalIdea(), other] as never, + dispatched, + ); + // WL-CRIT-I was dispatched for /skill:intake at idea and is still at idea + // → excluded; the next critical candidate is selected. + expect(selected?.id).toBe('WL-CRIT-OTHER'); + }); + + it('releases a candidate whose stage advanced past its dispatched-at stage', () => { + const dispatched = new Map([['WL-CRIT-R', 'intake_complete']]); + // Now at plan_complete (a different stage) → not suppressed. + const advanced = criticalPlanned('WL-CRIT-R'); + expect(selectCriticalCandidate([advanced] as never, dispatched)?.id).toBe('WL-CRIT-R'); + }); + + it('round-robins within the critical group when a registry is given (tie-break)', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-critical', rng: () => 0.5 }); + const candidates = [ + criticalIdea('WL-CRIT-A', { sortIndex: 1 }), + criticalIdea('WL-CRIT-B', { sortIndex: 2 }), + ] as never; + expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-A'); + expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-B'); + expect(selectCriticalCandidate(candidates, undefined, registry)?.id).toBe('WL-CRIT-A'); + }); + + it('critical rotation does not disturb the non-critical implement tier cursor', () => { + const registry = createRoundRobinRegistry({ worklogDir: '/tmp/rr-critical-isolated', rng: () => 0.5 }); + const criticals = [ + criticalIdea('WL-CRIT-A', { sortIndex: 1 }), + criticalIdea('WL-CRIT-B', { sortIndex: 2 }), + ] as never; + const implements_: ImplementCandidate[] = [ + { id: 'WL-IMP-A', title: 'A', status: 'open', priority: 'high', sortIndex: 1, risk: 'low', effort: 'small' }, + { id: 'WL-IMP-B', title: 'B', status: 'open', priority: 'high', sortIndex: 2, risk: 'low', effort: 'small' }, + ]; + // Rotate the critical group twice… + selectCriticalCandidate(criticals, undefined, registry); + selectCriticalCandidate(criticals, undefined, registry); + // …the implement tier's own cursor is untouched: still starts at A. + expect(selectImplementCandidate(implements_, undefined, registry)?.id).toBe('WL-IMP-A'); + }); + + it('returns null on an empty list', () => { + expect(selectCriticalCandidate([] as never)).toBeNull(); + }); +}); + +describe('parseCriticalCandidatesOutput', () => { + it('parses the wl list batch shape preserving status/stage/risk/effort/sortIndex/priority', () => { + const candidates = parseCriticalCandidatesOutput( + JSON.stringify({ + success: true, + count: 2, + workItems: [ + criticalIdea('WL-CRIT-I', { sortIndex: 7 }), + criticalReady('WL-CRIT-R', { sortIndex: 3 }), + ], + }), + ); + expect(candidates).toEqual([ + expect.objectContaining({ id: 'WL-CRIT-I', stage: 'idea', sortIndex: 7, priority: 'critical' }), + expect.objectContaining({ id: 'WL-CRIT-R', stage: 'intake_complete', sortIndex: 3, priority: 'critical' }), + ]); + }); + + it('malformed JSON yields null (fail-closed)', () => { + expect(parseCriticalCandidatesOutput('not json')).toBeNull(); + }); + + it('entries without an id are skipped; empty workItems yields []', () => { + const candidates = parseCriticalCandidatesOutput( + JSON.stringify({ + success: true, + count: 0, + workItems: [{ title: 'no id', stage: 'idea' }], + }), + ); + expect(candidates).toEqual([]); + }); +}); + +// ── Critical-first: dependency frontier (F3, decision Q3) ──────────── + +describe('resolveDependencyFrontier', () => { + it('an unblocked critical candidate resolves to itself (no frontier redirect)', async () => { + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async () => [], + ); + expect(result?.id).toBe('WL-CRIT-C'); + }); + + it('dispatches the nearest OPEN blocker when the critical candidate is dependency-blocked', async () => { + const blocker = criticalReady('WL-BLOCK'); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => (id === 'WL-CRIT-C' ? [blocker] : []), + ); + expect(result?.id).toBe('WL-BLOCK'); + expect(result?.stage).toBe('intake_complete'); + }); + + it('recurses through dependency-blocked blockers to the nearest open ancestor', async () => { + const ancestor = criticalPlanned('WL-ANCESTOR'); + const midBlocker = criticalReady('WL-MID', { status: 'in_progress' }); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => { + if (id === 'WL-CRIT-C') return [midBlocker]; + if (id === 'WL-MID') return [ancestor]; + return []; + }, + ); + expect(result?.id).toBe('WL-ANCESTOR'); + }); + + it('a chain bottoming in a closed blocker resolves to null (fall through to tiers)', async () => { + const closed = criticalIdea('WL-CLOSED', { status: 'completed' }); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => (id === 'WL-CRIT-C' ? [closed] : []), + ); + expect(result).toBeNull(); + }); + + it('an in_progress/in_review frontier blocker resolves to null (non-dispatchable stage)', async () => { + const busy = criticalReady('WL-BUSY', { status: 'in_progress' }); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => (id === 'WL-CRIT-C' ? [busy] : []), + ); + expect(result).toBeNull(); + }); + + it('tolerates cycles (bounded recursion, no infinite loop)', async () => { + const a = criticalIdea('WL-A'); + const b = criticalReady('WL-B'); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => { + if (id === 'WL-CRIT-C') return [a]; + if (id === 'WL-A') return [b]; + if (id === 'WL-B') return [a]; // cycle: A ↔ B + return []; + }, + ); + // Resolves without hanging; a cycle has no nearest open unblocked + // ancestor, so the frontier yields null (fall through). + expect(result).toBeNull(); + }); + + it('a blocker above the implement caps is not a valid frontier (caps apply to blockers, Q2)', async () => { + const capped = criticalPlanned('WL-CAPPED', { risk: 'high' }); + const candidate = criticalIdea('WL-CRIT-C'); + const result = await resolveDependencyFrontier( + candidate as never, + async (id) => (id === 'WL-CRIT-C' ? [capped] : []), + ); + expect(result).toBeNull(); + }); +}); + +describe('parseDepListBlockersOutput', () => { + it('parses outbound depends-on edges as the blockers of the queried item', () => { + const blockers = parseDepListBlockersOutput( + JSON.stringify({ + success: true, + item: 'WL-CRIT-C', + inbound: [{ id: 'WL-DEPENDENT', title: 'Depends on me', status: 'open', direction: 'depended-on-by' }], + outbound: [ + { id: 'WL-BLOCKER', title: 'Blocking item', status: 'open', priority: 'critical', direction: 'depends-on' }, + ], + }), + ); + expect(blockers).toEqual([ + { id: 'WL-BLOCKER', title: 'Blocking item', status: 'open', priority: 'critical' }, + ]); + }); + + it('ignores inbound depended-on-by edges (they are dependents, not blockers)', () => { + const blockers = parseDepListBlockersOutput( + JSON.stringify({ + success: true, + item: 'WL-CRIT-C', + inbound: [{ id: 'WL-DEPENDENT', title: 'Depends on me', status: 'open', direction: 'depended-on-by' }], + outbound: [], + }), + ); + expect(blockers).toEqual([]); + }); + + it('malformed JSON or a missing outbound list yields null (fail-closed)', () => { + expect(parseDepListBlockersOutput('not json')).toBeNull(); + expect(parseDepListBlockersOutput(JSON.stringify({ success: true, item: 'WL-CRIT-C' }))).toBeNull(); + }); +}); + +describe('parseShownWorkItem', () => { + it('parses the wl show single-item shape into a full critical candidate', () => { + const item = parseShownWorkItem( + JSON.stringify({ + success: true, + workItem: { + id: 'WL-BLOCKER', + title: 'Blocking item', + status: 'open', + stage: 'plan_complete', + risk: 'medium', + effort: 'medium', + sortIndex: 3, + priority: 'critical', + }, + }), + ); + expect(item).toEqual({ + id: 'WL-BLOCKER', + title: 'Blocking item', + status: 'open', + stage: 'plan_complete', + risk: 'medium', + effort: 'medium', + sortIndex: 3, + priority: 'critical', + }); + }); + + it('malformed output or a missing workItem yields null (fail-closed)', () => { + expect(parseShownWorkItem('not json')).toBeNull(); + expect(parseShownWorkItem(JSON.stringify({ success: true }))).toBeNull(); + }); +}); + +// ── Critical-first: tier wiring (F4, decisions Q1/Q2/Q3) ───────────── + +describe('dispatch critical-first tier', () => { + it('dispatches a critical idea candidate with /skill:intake ahead of ANY non-critical candidate', async () => { + const deps = makeDeps({ + // Non-critical implement candidate exists and is the old winner — + // the critical tier must win regardless. + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Non-critical implement', + stage: 'implement', + } as DowntimeCandidate), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('intake'); + expect(outcome.candidate?.id).toBe('WL-CRIT-I'); + expect(deps.getNextCriticalCandidate).toHaveBeenCalledWith('/repo'); + expect(deps.getNextImplementCandidate).not.toHaveBeenCalled(); + expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-I', { status: 'open', stage: 'idea' }); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-CRIT-I'), + { model: 'plan', cwd: '/repo' }, + ); + }); + + it('dispatches a critical intake_complete candidate with /skill:plan ahead of the implement tier', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Non-critical implement', + stage: 'implement', + } as DowntimeCandidate), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalReady('WL-CRIT-R'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-CRIT-R'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-R', { status: 'open', stage: 'intake_complete' }); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-CRIT-R'), + expect.anything(), + ); + }); + + it('dispatches a critical plan_complete candidate within caps with /skill:implement first', async () => { + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalPlanned('WL-CRIT-P'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('WL-CRIT-P'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-P', { status: 'open', stage: 'plan_complete' }); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:implement WL-CRIT-P'), + expect.anything(), + ); + }); + + it('critical-first tier runs AFTER the audit tier (audit keeps its slot)', async () => { + const deps = makeDeps({ + getNextAuditCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-AUD', title: 'Audit me', stage: 'audit' }, + }), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('audit'); + expect(deps.getNextCriticalCandidate).not.toHaveBeenCalled(); + }); + + it('scheduled-prompts tier still runs FIRST (ahead of the critical tier)', async () => { + const deps = makeDeps({ + getDueScheduledPrompt: vi.fn().mockResolvedValue({ + id: 'refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, + } as ScheduledPrompt), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('scheduled'); + expect(deps.getNextCriticalCandidate).not.toHaveBeenCalled(); + }); + + it('empty critical tier falls through to the non-critical tiers unchanged (regression)', async () => { + const deps = makeDeps({ + getNextImplementCandidate: vi.fn().mockResolvedValue({ + id: 'WL-IMP', + title: 'Implement me', + stage: 'implement', + } as DowntimeCandidate), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('implement'); + expect(deps.getNextCriticalCandidate).toHaveBeenCalledTimes(1); + expect(deps.getNextImplementCandidate).toHaveBeenCalledTimes(1); + }); + + it('a critical-tier wl failure fails closed to wl-error (never a silent fall-through)', async () => { + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: false }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('wl-error'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('during a code freeze critical implement dispatch PAUSES (fail-closed) while plan/intake tiers still run', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalPlanned('WL-CRIT-P'), // implement-ready, but frozen + }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-PLAN', title: 'Prep task', stage: 'intake_complete' }, + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Frozen → the critical tier is still consulted (a critical plan/intake + // candidate could still dispatch, Q1), but the implement-ready critical + // is NOT dispatched (no new code lands mid-release); the non-critical + // plan/intake tiers still dispatch. + expect(deps.getNextCriticalCandidate).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-PLAN'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-PLAN'), + expect.anything(), + ); + expect(deps.claimItem).not.toHaveBeenCalledWith('WL-CRIT-P', expect.anything()); + }); + + it('freeze split-by-skill: critical plan/intake dispatch CONTINUES while frozen (Q1)', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('frozen'), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalReady('WL-CRIT-R'), // plan-ready + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-CRIT-R', { status: 'open', stage: 'intake_complete' }); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-CRIT-R'), + expect.anything(), + ); + }); + + it('ambiguous marker is treated as frozen (fail-closed): critical implement pauses, plan/intake continue', async () => { + const deps = makeDeps({ + readCodeFreezeStatus: vi.fn().mockReturnValue('ambiguous'), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalPlanned('WL-CRIT-P'), + }), + getNextItem: vi + .fn() + .mockResolvedValueOnce({ ok: true, candidate: null }) // intake_complete empty + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-IDEA', title: 'An idea', stage: 'idea' } }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + // Frozen: the critical implement candidate is skipped (fail-closed on + // the ambiguous marker); the intake tier still dispatches. + expect(outcome.kind).toBe('intake'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-IDEA'), + expect.anything(), + ); + expect(deps.claimItem).not.toHaveBeenCalledWith('WL-CRIT-P', expect.anything()); + }); + + it('no double-dispatch: claim CAS applies to the critical tier (stale claim aborts)', async () => { + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + claimItem: vi.fn().mockResolvedValue({ ok: false, reason: 'stale' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('claim-failed'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('dependency-blocked critical candidate dispatches its frontier blocker with the blocker stage-appropriate skill (Q3)', async () => { + // The dep returns the frontier-resolved blocker (like getNextItem does): + // the critical item is dependency-blocked, so the selected candidate IS + // the nearest open blocker, carrying ITS stage. + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalReady('WL-BLOCKER', { title: 'Blocking work item' }), + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(outcome.candidate?.id).toBe('WL-BLOCKER'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-BLOCKER', { status: 'open', stage: 'intake_complete' }); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-BLOCKER'), + expect.anything(), + ); + }); + + it('records the critical dispatch with the skill-mapped kind (rolling log + change-guard consistency)', async () => { + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-CRIT-I'); + expect(event.kind).toBe('intake'); + expect(event.stage).toBe('idea'); + expect(event.cwd).toBe('/repo'); + }); + + it('single-flight: a concurrent dispatch refuses the critical tier (dispatch-in-flight)', async () => { + // Gate the pane spawn so the first dispatch is verifiably in flight + // when the second call arrives (same pattern as the existing + // single-flight guard test, using the critical-tier candidate). + let release!: () => void; + const gate = new Promise<{ ok: true }>((resolve) => { + release = () => resolve({ ok: true }); + }); + const deps = makeDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ + ok: true, + candidate: criticalIdea('WL-CRIT-I'), + }), + spawnAgentPane: vi.fn().mockImplementation(() => gate), + }); + + const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + release(); + const [firstOutcome, secondOutcome] = await Promise.all([first, second]); + + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(firstOutcome.dispatched).toBe(true); + expect(firstOutcome.kind).toBe('intake'); + expect(secondOutcome.dispatched).toBe(false); + expect(secondOutcome.reason).toBe('dispatch-in-flight'); + }); }); diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index a5db50f2e..1815b6c02 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -8,7 +8,9 @@ * * - `isIdleStatus` — idle detection from a `/llama/local/status` payload. * - `evaluateIdle` — runtime idle evaluation with the fail-closed - * degradation for a configured N < total slots (no per-slot data yet). + * degradation for a configured N < total slots (no per-slot data yet) and + * the spare-capacity per-slot mode (relaxed global gate, parent + * WL-0MT32F90V008UAD2). * - `parseLlamaStatus` / `fetchLocalStatus` / `createDowntimePoller` — the * single-flight poller for `GET {proxyUrl}/llama/local/status` with * per-poll timeout and fail-closed parsing. @@ -16,9 +18,11 @@ * threshold). * - `dispatchDowntimeWork` — dispatch orchestration: completed/in_review * items without a valid audit (modified within the last 7 days) → - * `/skill:audit ` (audit tier, WL-0MSI8H3HP000K0RG), then the + * `/skill:audit ` (audit tier, WL-0MSI8H3HP000K0RG; ROOT-ONLY — + * children are never dispatched independently, + * WL-0MSTLFW14000KPEC), then the * implement tier (WL-0MSMAYPQP001FLR6): the highest-priority open - * plan_complete item with risk Low / effort Small|XS → + * plan_complete item with risk ≤ Medium / effort ≤ Medium → * `/skill:implement `, then `wl next --stage intake_complete` → * `/skill:plan `, fallback `--stage idea` → `/skill:intake `, * pre-dispatch claim, per-process single-flight. Code-freeze gate @@ -65,14 +69,43 @@ * - `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` / * `clampDowntimeRequiredFreeSlots` / `clampDowntimeNoCandidateCooldownMs` * — settings clamps, wired into `settings.ts`. + * - `selectWithRotation` (WL-0MSSRED76008LGB6) — rotation-aware selection: + * within each tier, candidates sharing the same priority level are + * rotated round-robin via the shared durable cursor + * (`.worklog/downtime-round-robin.json`, `downtime-round-robin.ts`); + * fail-open — no registry / no priority → pre-rotation sortIndex order. + * - `jitterPollIntervalMs` (WL-0MSSRED76008LGB6) — probe jitter: the + * effective poll interval is jittered ±50% of + * `downtimePollIntervalMs` per reschedule so instances do not probe in + * lockstep; fail-open without a registry → static interval. * * Fail-closed behaviour (never dispatch, never throw) is the SAFE default * at every boundary. */ import { spawn } from 'node:child_process'; -import { isAuditFresh } from './icons.js'; +import { isAuditFresh } from '@worklog/shared/icons'; import type { CodeFreezeStatus } from './code-freeze.js'; +import { disableMarkerExists, removeDisableMarker, writeDisableMarker } from './downtime-disable-marker.js'; +import type { ScheduledPrompt } from './scheduled-prompts.js'; +import type { RoundRobinRegistry } from './downtime-round-robin.js'; +import { + readCoordinationFile, + getEntry, + upsertEntry, + removeEntry, + pruneStaleEntries, + type CoordinationEntry, +} from './coordination.js'; +import { + createLeaderElectionManager, + cleanupStaleElection, + DEFAULT_LEASE_TTL_SECONDS, +} from './leader-election.js'; +import { appendCoordinationLogEntry } from './downtime-log.js'; + +export type { ScheduledPrompt } from './scheduled-prompts.js'; +export type { CoordinationEntry } from './coordination.js'; // ── Constants ───────────────────────────────────────────────────────── @@ -85,11 +118,35 @@ export const DOWNTIME_POLL_INTERVAL_FLOOR_MS = 10_000; /** Defensive floor for the idle threshold (prevents immediate dispatch). */ export const DOWNTIME_IDLE_THRESHOLD_FLOOR_MS = 1_000; -export const DEFAULT_DOWNTIME_POLL_INTERVAL_MS = 30_000; -export const DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS = 240_000; +export const DEFAULT_DOWNTIME_POLL_INTERVAL_MS = 10_000; +export const DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS = 60_000; -/** 0 = all slots must be free (default). Any positive integer N is accepted. */ -export const DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 0; +/** + * Default required-free-slots count: 2 of 3 slots (spare-capacity dispatch, + * parent WL-0MT32F90V008UAD2). At least two slots must be free before + * downtime work is dispatched, so one operator session slot is always + * reserved for interactive work. A value of 0 means ALL slots must be free + * (the pre-spare-capacity default); any positive integer N is accepted. + */ +export const DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 2; + +/** + * Minimum free slots required for an AUDIT dispatch (parent + * WL-0MT32F90V008UAD2 AC3): an audit pane needs a second slot for its + * Phase 2 child deep-analysis (`AUDIT_PHASE2_PARALLELISM=1` — the child + * runs strictly after the parent, so parent + one child = 2 local slots, + * WL-0MSORQ1RG005DGUS). Applied as an ADDITIONAL selection-time check on + * the latest polled status; the idle-duration gate (configured N) is + * unchanged. `AUDIT_PHASE2_PARALLELISM=1` → 2 slots minimum. + */ +export const DOWNTIME_AUDIT_MIN_FREE_SLOTS = 2; + +/** + * Minimum free slots required for a single-pane dispatch (implement / + * plan / intake / scheduled tiers, parent WL-0MT32F90V008UAD2 AC3): each + * pane consumes exactly one local slot, so ≥1 free slot suffices. + */ +export const DOWNTIME_PANE_MIN_FREE_SLOTS = 1; /** Sane floor for the no-candidate cooldown (the pause cannot be disabled or set trivially small). */ export const DOWNTIME_NO_CANDIDATE_COOLDOWN_FLOOR_MS = 60_000; @@ -103,6 +160,15 @@ export const DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS = 3_600_000; */ export const DOWNTIME_AUDIT_RECENCY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; +/** + * Stale-audit window (WL-0MT3PHW4I002SNOV): an audit dispatch marker older + * than this is treated as stale — the audit pane may have crashed without + * updating the work item — and is ignored by the active-audit single-flight + * check, so a NEW audit dispatch can proceed. Only one audit is active at a + * time during downtime dispatch. + */ +export const DOWNTIME_AUDIT_STALE_WINDOW_MS = 2 * 60 * 60 * 1000; // 2 hours + /** * Three-strike rule: this many consecutive CLI-error dispatch outcomes * pause the worker entirely (after logging the persistent error). @@ -135,6 +201,25 @@ export const DOWNTIME_RUN_TIMEOUT_MS = 60_000; export const DEFAULT_DOWNTIME_PROXY_URL = 'http://192.168.0.199:8000'; export const DEFAULT_DOWNTIME_MODEL = 'plan'; +/** + * Default coordination check-in interval: 30 minutes (parent AC3). + * Every instance re-verifies/updates its entry in the shared coordination + * file at this cadence — including the leader. + */ +export const DEFAULT_COORDINATION_CHECK_IN_MS = 30 * 60 * 1000; + +/** + * Coordinator tier priority (parent AC4): the leader dispatches the + * highest-priority tier first — audit, then implement, then plan, then + * intake. The scheduled-prompts tier (WL-0MSS1Q5ER007QDKX) is NOT part of + * this order: like the legacy path, the coordination path checks it as a + * FIRST dispatch stage (freeze-gated, before ANY coordination-tier work), + * so a due prompt dispatches instead of reaching these tiers. + */ +export const COORDINATION_TIER_ORDER = ['audit', 'implement', 'plan', 'intake'] as const; + +export type CoordinationTierKind = (typeof COORDINATION_TIER_ORDER)[number]; + // ── Types ───────────────────────────────────────────────────────────── /** Shape of `GET /llama/local/status` as served by the llama-proxy. */ @@ -173,7 +258,14 @@ export interface LlamaStatus { slots?: LlamaSlot[]; } -/** Per-slot detail served inside `LlamaStatus.slots`. */ +/** + * Per-slot detail served inside `LlamaStatus.slots` (NORMALIZED by + * `parseLlamaStatus`). The proxy (observability.py) serves `slot_id` as an + * INTEGER (`slot.get("id", i)`); the parser coerces numeric ids to strings + * and clamps negatives to 0 (WL-0MSVRMAWM007QNR5), so the normalized + * `slot_id` is always a non-empty string — the per-slot idle tracker keys + * its timers by it. + */ export interface LlamaSlot { slot_id: string; is_processing: boolean; @@ -182,9 +274,11 @@ export interface LlamaSlot { // ── Idle detection (implemented) ────────────────────────────────────── /** - * The global (slot-count-independent) idle checks shared by `isIdleStatus` - * and the per-slot branch of `evaluateIdle`: llama-server up, no active - * query (local signal preferred), no model switch, no local lease. + * The FULL global idle checks used by `isIdleStatus` and the count-based + * (non-per-slot) path of `evaluateIdle`: llama-server up, no active + * query (local signal preferred), no model switch, no local lease. The + * per-slot branch uses the relaxed `perSlotGlobalIdleChecks` instead + * (spare-capacity dispatch, parent WL-0MT32F90V008UAD2). */ function globalIdleChecks(status: LlamaStatus): boolean { if (!status.llama_server_running) return false; @@ -201,6 +295,23 @@ function globalIdleChecks(status: LlamaStatus): boolean { return true; } +/** + * Per-slot-safe global checks (spare-capacity dispatch, parent + * WL-0MT32F90V008UAD2): in per-slot identity mode, ONLY llama-server-up and + * no-model-switch remain GLOBAL gates — `active_query` / + * `local_active_query` / `local_lease_active` are superseded by the per-slot + * `is_processing` signal (the busy slot holding the operator's query/lease IS + * the active work the operator wants to run alongside; it must not block + * dispatch into the free slots, LP-0MSG5TA7Y002GN39 Q&A). Used exclusively by + * the per-slot branches of `evaluateIdle` and `createDowntimeWorker.tick()`; + * the count-based path keeps the full `globalIdleChecks` (fail-closed). + */ +function perSlotGlobalIdleChecks(status: LlamaStatus): boolean { + if (!status.llama_server_running) return false; + if (status.model_switch_in_progress) return false; + return true; +} + /** * True when the proxy reports an idle state for the required free-slot * count: @@ -240,19 +351,25 @@ export function isIdleStatus(status: LlamaStatus, requiredFreeSlots: number): bo * identity AND 0 < N < total, the free count comes from the slots array * (which identifies WHICH slots are free) so the same N slots can be * required; the count-based logic is unchanged for N ≤ 0 / N ≥ total. + * + * Spare-capacity relaxation (parent WL-0MT32F90V008UAD2): in per-slot mode + * the global gate is the per-slot-safe subset (server up + no model switch + * only) — a query/lease tied to a busy slot is the operator's own session + * and must not block dispatch into the free slots (F1 tests AC1/AC2). */ export function evaluateIdle(status: LlamaStatus, requiredFreeSlots: number): boolean { const total = status.total_slots; if (!Number.isFinite(total) || total <= 0) return false; // ambiguous → busy - // Per-slot mode: per-slot identity present AND 0 < N < total. Base global - // checks still apply; the slot requirement is the per-slot free count. + // Per-slot mode: per-slot identity present AND 0 < N < total. The relaxed + // global gate applies (server up + no model switch); the slot requirement + // is the per-slot free count. if ( Array.isArray(status.slots) && requiredFreeSlots > 0 && requiredFreeSlots < total ) { - if (!globalIdleChecks(status)) return false; + if (!perSlotGlobalIdleChecks(status)) return false; // Fail-closed counting: an entry without an explicit boolean // `is_processing` is treated as processing (busy), never free. const free = status.slots.filter( @@ -426,10 +543,14 @@ export function parseLlamaStatus(raw: unknown): LlamaStatus | null { // Optional per-slot identity (LP-0MSG5TA7Y002GN39): absent on pre-feature // proxies (slots stays undefined — backward compatible). A malformed - // array (non-array, entry missing/empty/non-string slot_id, non-boolean + // array (non-array, entry missing/empty slot_id, non-boolean // is_processing, duplicate slot_ids) is ambiguous → null (busy, // fail-closed): per-slot tracking must never run on identity it cannot - // trust. An empty array is valid (zero slots reported free). + // trust. slot_id accepts BOTH the string contract and the proxy's + // integer contract (`slot.get("id", i)` in observability.py): numeric + // ids are coerced to strings and negatives clamped to 0 + // (WL-0MSVRMAWM007QNR5 — the Aug 15-16 zero-dispatch regression). An + // empty array is valid (zero slots reported free). let slots: LlamaSlot[] | undefined; if (o.slots !== undefined) { if (!Array.isArray(o.slots)) return null; @@ -438,11 +559,25 @@ export function parseLlamaStatus(raw: unknown): LlamaStatus | null { for (const entry of o.slots) { if (typeof entry !== 'object' || entry === null) return null; const slot = entry as Record; - if (typeof slot.slot_id !== 'string' || slot.slot_id.length === 0) return null; + // slot_id: string contract preserves the existing zero-length + // rejection guard (empty identity is ambiguous); the proxy's + // integer contract is accepted and coerced to string with negative + // values clamped to 0 (WL-0MSVRMAWM007QNR5). A non-finite or + // non-integer number is ambiguous → null (busy, fail-closed). + let slotId: string; + if (typeof slot.slot_id === 'string') { + if (slot.slot_id.length === 0) return null; + slotId = slot.slot_id; + } else if (typeof slot.slot_id === 'number') { + if (!Number.isFinite(slot.slot_id) || !Number.isInteger(slot.slot_id)) return null; + slotId = String(Math.max(0, slot.slot_id)); + } else { + return null; + } if (typeof slot.is_processing !== 'boolean') return null; - if (seen.has(slot.slot_id)) return null; // duplicate identity → ambiguous - seen.add(slot.slot_id); - parsed.push({ slot_id: slot.slot_id, is_processing: slot.is_processing }); + if (seen.has(slotId)) return null; // duplicate identity → ambiguous + seen.add(slotId); + parsed.push({ slot_id: slotId, is_processing: slot.is_processing }); } slots = parsed; } @@ -542,6 +677,13 @@ export function createDowntimePoller( export type DowntimeStage = 'intake_complete' | 'idea' | 'audit' | 'implement'; export type DowntimeSkillKind = 'plan' | 'intake' | 'audit' | 'implement'; +/** + * Every dispatch kind recorded in the rolling audit log: the worklog tiers + * plus the scheduled-prompts tier (WL-0MSS1Q5ER007QDKX), which has no work + * item (kind `scheduled`, log-only markers). + */ +export type DowntimeDispatchKind = DowntimeSkillKind | 'scheduled'; + export interface DowntimeCandidate { id: string; title: string; @@ -557,6 +699,8 @@ export interface DowntimeCandidate { status?: string; /** wl next priority order preserved for deterministic selection. */ sortIndex?: number; + /** Worklog priority level (critical/high/medium/low) — round-robin grouping key. */ + priority?: string; } /** @@ -574,11 +718,16 @@ export interface ImplementCandidate { risk?: string; effort?: string; sortIndex?: number; + /** Worklog priority level (critical/high/medium/low) — round-robin grouping key. */ + priority?: string; } /** * A completed/in_review item candidate for the downtime audit tier. - * Parsed from `wl list --status completed --stage in_review --json`. + * Parsed from `wl list --status completed --stage in_review --root-only + * --json` — root-only (WL-0MSTLFW14000KPEC): only parent items (no + * parentId) can be audit candidates; children are never dispatched + * independently. */ export interface AuditCandidate { id: string; @@ -586,6 +735,8 @@ export interface AuditCandidate { auditedAt?: string | null; updatedAt?: string; sortIndex?: number; + /** Worklog priority level (critical/high/medium/low) — round-robin grouping key. */ + priority?: string; } /** @@ -602,6 +753,56 @@ export type DowntimeNextResult = | { ok: true; candidate: DowntimeCandidate | null } | { ok: false }; +/** + * Enriched single-item view fetched by the leader for a coordination entry + * (via `deps.fetchItem` — `wl show ` enriched with the audit timestamp + * from the completed/in_review list query). Carries the fields the + * coordinator needs to classify an item into its dispatch tier. + */ +export interface DowntimeItemInfo { + id: string; + title?: string; + /** Worklog status (`open` | `completed` | `in_progress` | …). */ + status?: string; + /** Worklog stage (idea | intake_complete | plan_complete | in_review | …). */ + stage?: string; + /** Worklog priority level (critical/high/medium/low). */ + priority?: string; + /** Risk level (Low/Medium/High/…). */ + risk?: string; + /** Effort level (XS/S/M/L/XL). */ + effort?: string; + /** Latest audit timestamp (enriched; absent on show-only items). */ + auditedAt?: string | null; + /** Item update timestamp (audit-tier recency window). */ + updatedAt?: string; + /** wl priority order preserved for deterministic ordering. */ + sortIndex?: number; +} + +/** + * Outcome of one `fetchItem` lookup: `{ok:true}` with the item info, or + * `{ok:false}` on a wl/CLI failure or an unparseable item (fail-closed — + * never a silent skip). + */ +export type DowntimeItemResult = + | { ok: true; info: DowntimeItemInfo } + | { ok: false }; + +/** + * Outcome of the check-in most-important-item computation. `{ok:true, + * kind/candidate}` — a dispatchable item was found; `{ok:true, + * noCandidate:true}` — the instance genuinely has nothing dispatchable + * (its own entry may be removed); `{ok:false}` — wl/CLI errors occurred + * during the computation (fail-open: keep the existing entry, retry at + * the next check-in). + */ +export type MostImportantItemResult = + | { ok: true; kind: DowntimeSkillKind; candidate: DowntimeCandidate } + | { ok: true; noCandidate: true } + | { ok: false }; + + /** * Expected state for the pre-dispatch claim (compare-and-swap, RCA * WL-0MSRBFFLN005W3VT design point 1): the claim transition to @@ -625,6 +826,25 @@ export type DowntimeClaimResult = | { ok: true } | { ok: false; reason: 'stale' | 'error' }; +/** + * Result of the active-audit single-flight check (WL-0MT3PHW4I002SNOV). + * + * - `{ok:true, active:true}` — a non-stale `kind=audit` dispatch marker + * maps to an item still `in_progress` (dispatched but not yet + * completed/reviewed): an audit is running, so the audit tier must be + * skipped this tick (outcome reason `audit-in-flight`). + * - `{ok:true, active:false}` — no non-stale marker maps to an + * `in_progress` item: the audit tier may proceed (audits stay strictly + * sequential — never fan-out). + * - `{ok:false}` — the check could not complete (e.g. worklog query + * failure): fail-open — the audit tier is skipped and dispatch falls + * through to the next tier; all dispatch is never blocked by an + * unanswerable check. + */ +export type DowntimeActiveAuditResult = + | { ok: true; active: boolean } + | { ok: false }; + /** External boundaries injected so the dispatch logic is testable. */ export interface DowntimeWorkerDeps { /** @@ -640,6 +860,11 @@ export interface DowntimeWorkerDeps { /** * Look up the next completed/in_review item WITHOUT a valid audit (the * audit dispatch tier, which runs before the implement/plan/intake tiers). + * ROOT-ONLY (WL-0MSTLFW14000KPEC): the `wl list` lookup carries + * `--root-only`, so completed/in_review children (items with a parentId) + * are excluded server-side and are never dispatched independently — only + * parent items are audit candidates (the producer reviews deliverable + * units, whose audits cover their children). * Uses the same `DowntimeNextResult` error channel as `getNextItem` * (WL-0MSLWJ2KP0002SV0): `{ok:false}` is a wl/parse failure (a CLI-error * strike — never a candidate), while `{ok:true, candidate:null}` is a @@ -652,9 +877,25 @@ export interface DowntimeWorkerDeps { * never re-selected while they still lack a fresh audit. */ getNextAuditCandidate(cwd: string): Promise; + /** + * Active-audit single-flight check (WL-0MT3PHW4I002SNOV): true when any + * non-stale `kind=audit` dispatch marker in the shared rolling dispatch + * log (`/.worklog/downtime-dispatches.log`) maps to an item still + * `in_progress` — an audit was dispatched (by ANY instance, leader or + * not) and has not yet completed/reviewed. The dispatcher consults this + * before selecting an audit candidate and skips the audit tier while one + * is active, keeping audits strictly sequential (never fan-out). A marker + * older than `DOWNTIME_AUDIT_STALE_WINDOW_MS` (2h) is treated as stale + * (the audit pane may have crashed without updating the work item) and + * ignored. Fail-open: `{ok:false}` on a worklog-query failure — the + * dispatcher then skips the audit tier and falls through to the next + * tier rather than blocking all dispatch (fail-safe). `cwd` is the + * worklog root the dispatch log lives under. + */ + getActiveAudit(cwd: string): Promise; /** * Look up the next implement-tier candidate (WL-0MSMAYPQP001FLR6): the - * highest-priority open plan_complete item with risk Low / effort Small|XS, + * highest-priority open plan_complete item with risk ≤ Medium / effort ≤ Medium, * excluding dependency-blocked items (wl next default) and items already * dispatched for `/skill:implement` (kind `implement` dispatched markers, * AC6). Fail-closed: a wl failure yields null (no dispatch) — the @@ -662,6 +903,38 @@ export interface DowntimeWorkerDeps { * `.worklog/downtime-dispatches.log` is consulted for the marker set. */ getNextImplementCandidate(cwd: string): Promise; + /** + * Look up the next critical-first candidate (WL-0MT3FM8VA005XBHE): the + * highest-priority open CRITICAL item at ANY stage (idea / + * intake_complete / plan_complete), including dependency-blocked ones + * (`wl list --priority critical --status open` does NOT exclude them — + * unlike `wl next`), with the stage-appropriate skill mapping and the + * plan_complete implement caps (decision Q2). The returned candidate is + * the stage-appropriate dispatch target (kind derived via + * `criticalSkillKind(candidate.stage)` by the tier). Fail-closed: a wl + * failure resolves `{ok:false}` via the `DowntimeNextResult` error + * channel (never a silent empty — a broken critical lookup must not + * look like "no critical work"). `cwd` is the worklog root whose + * `.worklog/downtime-dispatches.log` is consulted for the dispatched- + * marker change-guard (an item already dispatched for its tier while + * still at its dispatched-at stage is excluded). + */ + getNextCriticalCandidate(cwd: string): Promise; + /** + * Fetch ONE work item by id (leader-only coordination path): the leader + * re-checks each coordination entry's item against its CURRENT worklog + * state so a stale entry can never be dispatched on faith alone. + * `cwd` is the entry's worklog root (the item lives in that worklog). + * Fail-closed: `{ok:false}` on a wl failure or an unparseable/missing + * item — the caller treats it as a CLI-error strike (never a silent + * skip). + * + * Optional for backward compatibility: the legacy (non-coordination) + * dispatch path never consults it; `createDowntimeDeps` always provides + * it and `dispatchFromCoordination` (the leader path) fails closed + * (`wl-error`) when it is absent. + */ + fetchItem?(itemId: string, cwd: string): Promise; /** * Read the current code-freeze marker status (tri-state: 'frozen' / * 'not-frozen' / 'ambiguous'). `cwd` is the worklog root; the marker @@ -688,8 +961,13 @@ export interface DowntimeWorkerDeps { * with no failure — fire-and-forget); `{ok:false, error|exitCode}` on a * handled spawn `error` event or a non-zero script exit within the probe * window (no unhandled-exception crash, WL-0MSLWJ3I70031Z8U absorbed). + * `paneName` overrides the default `Downtime ` pane name — the + * scheduled-prompts tier passes `Downtime ` (WL-0MSS1Q5ER007QDKX). */ - spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise; + spawnAgentPane( + prompt: string, + opts: { model: string; cwd: string; paneName?: string }, + ): Promise; /** * Audit trail for a successful dispatch: comment on the item + rolling * log entry under `.worklog`. Resolves TRUE only when the rolling-log @@ -697,8 +975,28 @@ export interface DowntimeWorkerDeps { * tolerated (the comment is a durable cross-machine trail, not the * marker). A false result makes the dispatcher ABORT before spawning — an * unmarked item is never dispatched (fail-closed, RCA design point 2). + * Scheduled-prompt dispatches pass `noItemComment: true` — there is no + * work item, so only the rolling-log marker is written (no wl comment). */ recordDispatch(event: DowntimeDispatchEvent): Promise; + /** + * Look up the first DUE scheduled prompt (WL-0MSS1Q5ER007QDKX): reads the + * project-local config at `/.worklog/scheduled-prompts.json` and + * returns the first entry whose frequency threshold is met, in config + * order. Absent or malformed config → null (fail-closed, logged): no + * scheduled dispatch and the existing tiers are unaffected (`wl init` is + * the provisioning path). A due entry resolves non-null — the caller + * dispatches it instead of reaching the backlog tiers. + */ + getDueScheduledPrompt(cwd: string): Promise; + /** + * Persist a scheduled prompt's `lastTriggeredAt` (atomic tmp+rename) so a + * delayed dispatch never fires more often than its frequency. Resolves + * false on any failure — the dispatcher ABORTS the spawn (an unrecorded + * dispatch never runs) and the entry stays due for the next idle slot. + * Must never throw (fail-closed). + */ + recordScheduledPromptTrigger(cwd: string, promptId: string, at: string): Promise; /** * Record a spawn-failure trace for an attempted dispatch (audit-log * integrity, WL-0MSLWJ3I70031Z8U AC2): a failed pane spawn appends an @@ -718,12 +1016,19 @@ export interface DowntimeWorkerDeps { /** Audit event recorded for every successful downtime dispatch. */ export interface DowntimeDispatchEvent { itemId: string; - kind: DowntimeSkillKind; + kind: DowntimeDispatchKind; /** ISO-8601 UTC timestamp of the dispatch. */ dispatchedAt: string; /** Worklog root (the rolling log lives at `/.worklog`). */ cwd: string; title?: string; + /** + * When true, `recordDispatch` skips the work-item comment (scheduled-prompt + * dispatches have no work item — there is no item to comment; the rolling + * log marker is the only trace, WL-0MSS1Q5ER007QDKX AC4). Absent/false on + * normal tier dispatches (comment added, unchanged). + */ + noItemComment?: boolean; /** * Worklog stage of the item at dispatch (plan/intake change-guard, RCA * WL-0MSRBFFLN005W3VT design point 3): a candidate is excluded while it is @@ -765,13 +1070,18 @@ export interface DowntimeErrorEvent { export interface DowntimeDispatchOutcome { dispatched: boolean; candidate?: DowntimeCandidate; - kind?: DowntimeSkillKind; + kind?: DowntimeDispatchKind; /** * Non-dispatch reasons: 'dispatch-in-flight' | 'no-candidate' | * 'wl-error' | 'code-freeze' | 'claim-failed' (lost the CAS race — * another pane won; neutral) | 'marker-write-failed' (fail-closed abort - * BEFORE spawn) | 'spawn-failed' (handled spawn error or non-zero script - * exit; outcome is not success). + * BEFORE spawn — includes the scheduled-prompt persist failure) | + * 'spawn-failed' (handled spawn error or non-zero script exit; outcome is + * not success) | 'audit-in-flight' (WL-0MT3PHW4I002SNOV: an audit is + * already running — a non-stale kind=audit dispatch marker maps to an + * `in_progress` item — so the audit tier was skipped; a skip that leaves + * an empty remaining backlog reports this reason, NEVER 'no-candidate', + * so the no-candidate cooldown is not entered while the audit runs). */ reason?: string; /** @@ -898,17 +1208,549 @@ async function dispatchClaimedTier( } /** - * dispatch one downtime work item. Selection priority (audit tier first, - * WL-0MSI8H3HP000K0RG): a completed/in_review item WITHOUT a valid audit AND + * Dispatch one due scheduled prompt (WL-0MSS1Q5ER007QDKX). Unlike the + * worklog tiers there is NO work item: no pre-dispatch claim, no wl item + * comment. Fixed pipeline, fail-closed at every boundary: + * + * 1. PERSIST first: `lastTriggeredAt` is written to the config (atomic + * tmp+rename) BEFORE anything else. A persist failure ABORTS the + * dispatch — an unrecorded dispatch never runs (AC4) — and the entry + * stays due for the next idle slot. + * 2. Rolling-log MARKER (kind `scheduled`) before the pane spawns: a + * marker failure ALSO aborts before the spawn (fail-closed). + * 3. SPAWN the pane through the existing send-to-pi.sh path running the + * prompt text, named `Downtime ` (--no-focus, --cwd, --model). + * A handled spawn failure appends an outcome:'spawn-failed' trace and + * resolves 'spawn-failed' (the outcome is NOT success); the marker and + * the persisted lastTriggeredAt stand (the entry is not re-selected + * until its frequency elapses — a best-effort cadence tolerates the + * failed run). + */ +async function dispatchScheduledPrompt( + deps: DowntimeWorkerDeps, + prompt: ScheduledPrompt, + opts: { model: string; cwd: string }, +): Promise { + const at = new Date().toISOString(); + + // 1. Persist lastTriggeredAt (atomic tmp+rename). A failure aborts BEFORE + // the log marker or the spawn — an unrecorded dispatch never runs and the + // entry remains due (AC4 fail-closed). + const persisted = await deps.recordScheduledPromptTrigger(opts.cwd, prompt.id, at); + if (!persisted) { + return { dispatched: false, reason: 'marker-write-failed' }; + } + + // 2. Rolling-log marker (kind `scheduled`, noItemComment — there is no + // work item to comment). A marker failure aborts BEFORE the spawn. + let marked = false; + try { + marked = await deps.recordDispatch({ + itemId: prompt.id, + kind: 'scheduled', + dispatchedAt: at, + cwd: opts.cwd, + title: `Scheduled prompt: ${prompt.prompt}`, + noItemComment: true, + }); + } catch { + // Fail-closed: a throwing recordDispatch (stub or regression) is a + // marker write failure — abort before spawn. + marked = false; + } + if (!marked) { + return { dispatched: false, reason: 'marker-write-failed' }; + } + + // 3. Spawn the pane running the prompt text, named `Downtime `. + const spawn = await deps.spawnAgentPane(prompt.prompt, { + model: opts.model, + cwd: opts.cwd, + paneName: `Downtime ${prompt.id}`, + }); + if (!spawn.ok) { + // Failure trace (WL-0MSLWJ3I70031Z8U AC2 pattern): the audit log + // distinguishes "attempted" (failed spawn) from "opened" (success + // marker). Fail-closed: audit logging must never crash the worker, so a + // throwing trace is swallowed (the marker + persisted trigger already + // stand). + try { + await deps.recordDispatchFailure({ + itemId: prompt.id, + kind: 'scheduled', + dispatchedAt: new Date().toISOString(), + cwd: opts.cwd, + title: `Scheduled prompt: ${prompt.prompt}`, + noItemComment: true, + error: spawn.error, + exitCode: spawn.exitCode, + }); + } catch { + // fail-closed: audit logging must never crash the worker + } + return { + dispatched: false, + reason: 'spawn-failed', + error: spawn.error, + exitCode: spawn.exitCode, + }; + } + + return { dispatched: true, kind: 'scheduled' }; +} + +// ── Leader-election coordination dispatch (parent WL-0MST3OJ8S0001ROL) ── + +/** + * Rank of a dispatch kind in the coordinator tier priority (audit >> + * implement >> plan >> intake). Unknown/null kinds rank below every tier + * (-1) — never dispatchable. + */ +export function coordinationTierRank(kind: DowntimeSkillKind | null): number { + if (kind === null) return -1; + const idx = COORDINATION_TIER_ORDER.indexOf(kind as CoordinationTierKind); + return idx === -1 ? -1 : idx; +} + +/** + * Classify a fetched work item into its coordinator dispatch tier, or null + * when it is not currently dispatchable (fail-closed on ambiguous state): + * + * - `completed` + `in_review` + no fresh audit + within the 7-day audit + * recency window → `audit` (same freshness/recency semantics as the + * audit tier's `selectAuditCandidate`; `auditedAt` enriched by the + * fetchItem dep — absent auditedAt means not fresh → audit-eligible). + * - `open` + `plan_complete` + risk ≤ Medium + effort ≤ Medium → + * `implement` (the implement tier's caps, belt-and-suspenders + * client-side). + * - `open` + `intake_complete` → `plan`. + * - `open` + `idea` → `intake`. + * - Every other state (in_progress already claimed, completed with a + * fresh audit, past the recency window, above the implement caps, + * unknown status/stage) → null (never dispatched). + * + * `now` is injectable for deterministic tests. + */ +export function classifyItemForDispatch( + info: DowntimeItemInfo, + now: number = Date.now(), +): DowntimeSkillKind | null { + const status = typeof info.status === 'string' ? info.status : ''; + const stage = typeof info.stage === 'string' ? info.stage : ''; + if (status === 'completed') { + if (stage !== 'in_review') return null; + // Audit tier: no FRESH audit (auditedAt absent/older than updatedAt + // semantics per isAuditFresh). A missing auditedAt means not fresh → + // audit-eligible (the audit tier's conservative default). + if (isAuditFresh(info.auditedAt, info.updatedAt)) return null; + // 7-day recency window (mirrors selectAuditCandidate): an item not + // modified within the window is not a candidate; missing updatedAt is + // included (absent data must not silently drop candidates). + if (typeof info.updatedAt === 'string' && info.updatedAt.length > 0) { + const updated = new Date(info.updatedAt).getTime(); + if (Number.isNaN(updated)) return null; // unparseable → fail-closed + if (now - updated > DOWNTIME_AUDIT_RECENCY_WINDOW_MS) return null; + } + return 'audit'; + } + if (status !== 'open') return null; + if (stage === 'idea') return 'intake'; + if (stage === 'intake_complete') return 'plan'; + if (stage === 'plan_complete') { + // Implement caps (risk ≤ Medium, effort ≤ Medium) — same ordinal + // semantics as selectImplementCandidate (fail-closed on unset). + const risk = riskOrdinal(info.risk); + if (risk === null || risk > 2) return null; + const effort = effortOrdinal(info.effort); + if (effort === null || effort > 3) return null; + return 'implement'; + } + return null; +} + +/** Build a dispatch candidate from fetched item info. */ +export function toCoordinationCandidate(info: DowntimeItemInfo): DowntimeCandidate { + return { + id: info.id, + title: info.title ?? '', + stage: (info.stage as DowntimeStage) ?? 'idea', + status: info.status, + priority: info.priority, + sortIndex: info.sortIndex, + }; +} + +/** + * Compute this instance's most-important dispatchable work item (parent + * AC3 / AC5): the highest-priority item in ITS OWN worklog, following the + * standard tier order (scheduled prompts are skipped — they have no work + * item — then audit, then critical, then implement, then plan, then + * intake), with the code-freeze gate applied to the audit/implement tiers. + * This is the same selection `dispatchDowntimeWork` used pre-refactor, now + * feeding the coordination check-in instead of an immediate dispatch. + * + * The lookups apply the existing dispatched-marker exclusions (audit /[ + * implement/plan/intake marker sets) and the client-side `open` guards, so + * an item already dispatched by this worker for its tier is never offered + * to the coordinator again — the durable marker stays the source of truth. + * + * A wl failure at any tier does NOT short-circuit: the remaining tiers are + * still tried (same resilience as the old dispatcher); the result carries + * `{ok:false}` ONLY when the computation ended on a CLI error with no + * candidate found at all (the caller then keeps the existing entry, + * fail-open). + */ +export async function computeMostImportantItem( + deps: DowntimeWorkerDeps, + cwd: string, + now: number = Date.now(), +): Promise { + if (typeof deps.getNextAuditCandidate !== 'function') return { ok: false }; + const freezeStatus = deps.readCodeFreezeStatus(cwd); + const frozen = freezeStatus === 'frozen' || freezeStatus === 'ambiguous'; + let sawError = false; + + if (!frozen) { + const audit = await deps.getNextAuditCandidate(cwd); + if (audit.ok) { + if (audit.candidate !== null) { + return { ok: true, kind: 'audit', candidate: audit.candidate }; + } + } else { + sawError = true; + } + } + + const critical = await deps.getNextCriticalCandidate(cwd); + if (critical.ok) { + if (critical.candidate !== null) { + const kind = criticalSkillKind(critical.candidate.stage); + if (kind !== null && !(frozen && kind === 'implement')) { + return { ok: true, kind, candidate: critical.candidate }; + } + } + } else { + sawError = true; + } + + if (!frozen) { + const implement = await deps.getNextImplementCandidate(cwd); + if (implement !== null) { + return { ok: true, kind: 'implement', candidate: implement }; + } + } + + const intakeComplete = await deps.getNextItem('intake_complete', cwd); + if (intakeComplete.ok) { + if (intakeComplete.candidate !== null) { + return { ok: true, kind: 'plan', candidate: intakeComplete.candidate }; + } + } else { + sawError = true; + } + + const idea = await deps.getNextItem('idea', cwd); + if (idea.ok) { + if (idea.candidate !== null) { + return { ok: true, kind: 'intake', candidate: idea.candidate }; + } + } else { + sawError = true; + } + + // Genuinely empty backlog (both prep tiers answered) → no candidate. A + // CLI error with no candidate → {ok:false} (the check-in keeps the + // existing entry, fail-open — a transient wl error must never drop a + // valid offer). + return sawError ? { ok: false } : { ok: true, noCandidate: true }; +} + +/** + * Leader-only dispatch from the shared coordination list (parent AC4): + * re-fetch each entry's item, classify it into its tier, apply the + * code-freeze gate (audit/implement skipped while frozen, plan/intake + * still run) and dispatch the highest-priority available item via the + * existing `dispatchClaimedTier` pipeline (CAS claim → marker write → + * spawn) — the dispatched-marker exclusion and CAS claim guards are + * preserved by construction (AC5). On a successful dispatch (or a + * claimed-but-failed spawn), the entry is removed from the coordination + * file so its owner re-queues its next item at the next check-in (AC3). + * + * Tier priority: audit → implement → plan → intake. Within a tier, + * entries dispatch in file order (the instances' offers are equally + * weighted; round-robin is superseded by the shared-list model). + * + * Fail-closed at every boundary: a wl failure fetching an entry resolves + * `wl-error` only when EVERY entry failed (a fully broken lookup — a + * strike); a per-entry failure skips that entry (fail-open per instance, + * parent risk mitigation — one broken worklog must not starve the rest). + */ +export async function dispatchFromCoordination( + deps: DowntimeWorkerDeps, + entries: CoordinationEntry[], + opts: { model: string; cwd: string; coordinationDir: string; freeSlots?: number; leaseTtlMs?: number }, + now: number = Date.now(), +): Promise { + // The leader path REQUIRES the item-fetch dep: without it the leader can + // never classify an entry — fail closed to a strike (a misconfigured + // deployment must be visible, not silently idle). + if (typeof deps.fetchItem !== 'function') { + return { dispatched: false, reason: 'wl-error' }; + } + const freezeStatus = deps.readCodeFreezeStatus(opts.cwd); + const frozen = freezeStatus === 'frozen' || freezeStatus === 'ambiguous'; + + // Stale-entry pruning (parent AC3 risk mitigation): the leader prunes + // entries whose owner has not refreshed within the lease TTL (crashed or + // idle instances) at the START of every dispatch cycle, so dead offers + // never starve the queue and their owners re-queue on their next + // check-in. Fail-safe: pruneStaleEntries never throws (lock contention or + // IO → 0 removed). + const pruned = pruneStaleEntries( + opts.coordinationDir, + (opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_SECONDS * 1000), + now, + ); + if (pruned > 0) { + // Audit trail (WL-0MSXHAE290067VAL). + void appendCoordinationLogEntry(opts.cwd, { + kind: 'coordination', + operation: 'prune', + prunedCount: pruned, + at: new Date(now).toISOString(), + }); + // The coordination FILE is the source of truth: the passed snapshot is + // stale after a prune — re-read it so a pruned (crashed-instance) + // entry can never classify or dispatch this cycle. Fail-safe: a failed + // re-read falls back to the snapshot (already pruned entries are then + // skipped by classify → non-dispatchable). + entries = readCoordinationFile(opts.coordinationDir)?.entries ?? entries; + } + + // Per-tier free-slot minimums (parent WL-0MT32F90V008UAD2 AC3): the + // audit tier needs ≥ 2 slots (parent + Phase 2 child at + // AUDIT_PHASE2_PARALLELISM=1); every single-pane tier needs ≥ 1. When + // freeSlots is absent (direct API callers), the minimums do not gate + // (fail-open for direct API use — the worker always passes the count). + const freeSlots = opts.freeSlots; + const auditEligible = freeSlots === undefined || freeSlots >= DOWNTIME_AUDIT_MIN_FREE_SLOTS; + const panesEligible = freeSlots === undefined || freeSlots >= DOWNTIME_PANE_MIN_FREE_SLOTS; + if (!panesEligible) { + // No slot can host a pane — nothing to dispatch (mirrors the legacy + // tier chain's 0-free-slots defensive no-candidate). + return { dispatched: false, reason: 'no-candidate' }; + } + + // 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 + // are skipped so no new code changes land mid-release, and dispatch falls + // through to the coordination tiers below. A due prompt dispatches its + // prompt text immediately — it never reaches the backlog tiers, so it + // never triggers the no-candidate cooldown (AC6). Absent or malformed + // config resolves null (fail-closed, logged by the dep): no scheduled + // dispatch and the tiers below are unaffected (AC2). + if (!frozen) { + const duePrompt = await deps.getDueScheduledPrompt(opts.cwd); + if (duePrompt !== null) { + return await dispatchScheduledPrompt(deps, duePrompt, { + model: opts.model, + cwd: opts.cwd, + }); + } + } + + // Re-fetch + classify every entry once, grouped by tier. + const byTier = new Map>(); + let fetchAttempts = 0; + let fetchFailures = 0; + for (const entry of entries) { + if (entry.instanceId.length === 0 || entry.workItemId.length === 0) continue; + fetchAttempts += 1; + const result = await deps.fetchItem(entry.workItemId, entry.directory); + if (!result.ok) { + fetchFailures += 1; + continue; + } + const kind = classifyItemForDispatch(result.info, now); + if (kind === null) continue; + if (frozen && (kind === 'audit' || kind === 'implement')) continue; + const group = byTier.get(kind) ?? []; + group.push({ entry, info: result.info }); + byTier.set(kind, group); + } + + // A wl lookup that failed for EVERY entry is a persistent CLI/parse + // failure — a strike (never a silent no-candidate). Per-instance + // failures are tolerated (fail-open). + if (fetchAttempts > 0 && fetchFailures === fetchAttempts) { + return { dispatched: false, reason: 'wl-error' }; + } + + for (const tier of COORDINATION_TIER_ORDER) { + const group = byTier.get(tier) ?? []; + // Audit-tier slot minimum: an audit pane needs a second slot for its + // Phase 2 child (WL-0MSORQ1RG005DGUS) — skip the whole audit group + // when too few slots are free (ineligible, never a strike). + if (tier === 'audit' && !auditEligible) continue; + for (const { entry, info } of group) { + const outcome = await dispatchClaimedTier( + deps, + tier as DowntimeSkillKind, + toCoordinationCandidate(info), + { model: opts.model, cwd: entry.directory }, + ); + if (outcome.dispatched) { + // Dispatched — remove the entry so the owner re-queues its next + // most-important item at the next check-in (AC3 re-queue). + removeEntry(opts.coordinationDir, entry.instanceId); + return outcome; + } + if (outcome.reason === 'claim-failed') { + // Another pane won the CAS race — neutral; try the next entry. + continue; + } + if (outcome.reason === 'wl-error') { + // A wl failure is a strike — stop the cycle (three-strike rule + // decides when to pause). + return outcome; + } + if (outcome.reason === 'spawn-failed' || outcome.reason === 'marker-write-failed') { + // The item is claimed (+ marked) but the pane never appeared: + // remove the entry so the owner re-queues; the standing marker and + // the in_progress claim prevent double-dispatch. + removeEntry(opts.coordinationDir, entry.instanceId); + return outcome; + } + } + } + + // No tier had a dispatchable entry (or a freeze skip with an empty + // plan/intake list). The caller decides cooldown vs. resume via the + // same reason semantics as the legacy dispatcher. + return frozen + ? { dispatched: false, reason: 'code-freeze' } + : { dispatched: false, reason: 'no-candidate' }; +} + +/** + * One 30-minute coordination check-in (parent AC3): recompute this + * instance's most-important item and upsert its entry in the shared + * coordination file. When the instance has NOTHING dispatchable (genuine + * empty backlog, no CLI errors) the own entry is removed — the leader must + * not float a dead offer; the owner re-offers when work appears. On a + * CLI-error computation the existing entry is kept (fail-open) — a + * transient wl failure must never drop a valid offer. Returns the stored + * item id (or null when nothing was offered). + */ +export async function runCoordinationCheckIn( + deps: DowntimeWorkerDeps, + coordinator: { + cwd: string; + coordinationDir: string; + instanceId: string; + }, + 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); + if (!result.ok) { + // wl/CLI errors — keep the existing entry (fail-open), retry next check-in. + return { offered: current?.workItemId ?? null, updated: false }; + } + if ('noCandidate' in result && result.noCandidate) { + // Genuinely nothing dispatchable — remove the own entry (no dead offers). + const removed = removeEntry(coordinator.coordinationDir, coordinator.instanceId) !== null; + // Audit trail (WL-0MSXHAE290067VAL): log the emptied check-in. + void appendCoordinationLogEntry(coordinator.cwd, { + kind: 'coordination', + operation: 'checkin', + instanceId: coordinator.instanceId, + workItemId: null, + at: new Date(now).toISOString(), + }); + return { offered: null, updated: removed }; + } + if (!('candidate' in result) || result.candidate === undefined) { + // Defensive: a malformed result (stub/regression) keeps the entry. + return { offered: current?.workItemId ?? null, updated: false }; + } + const entry: CoordinationEntry = { + instanceId: coordinator.instanceId, + workItemId: result.candidate.id, + directory: coordinator.cwd, + assignedAt: current?.assignedAt ?? new Date(now).toISOString(), + lastUpdated: new Date(now).toISOString(), + }; + const updated = upsertEntry(coordinator.coordinationDir, entry); + // Audit trail (WL-0MSXHAE290067VAL): log the offered item on every + // successful check-in write. + if (updated) { + void appendCoordinationLogEntry(coordinator.cwd, { + kind: 'coordination', + operation: 'checkin', + instanceId: coordinator.instanceId, + workItemId: entry.workItemId, + at: new Date(now).toISOString(), + }); + } + return { offered: entry.workItemId, updated }; +} + +/** + * dispatch one downtime work item. Selection priority: FIRST the + * scheduled-prompts tier (WL-0MSS1Q5ER007QDKX) — a due scheduled prompt + * (e.g. `/skill:refactor` every 3 days) dispatches its prompt text before + * any backlog tier, gated by the same code-freeze marker as audit/implement + * (frozen OR ambiguous ⇒ skipped, plan/intake still run) and never + * triggering the no-candidate cooldown; then the audit tier + * (WL-0MSI8H3HP000K0RG): a completed/in_review item WITHOUT a valid audit AND * NOT already dispatched for audit by this worker → * `/skill:audit ` (the dispatched-marker exclusion, - * WL-0MSLIY8ZR004QUSY, is applied by `deps.getNextAuditCandidate`); then the + * WL-0MSLIY8ZR004QUSY, is applied by `deps.getNextAuditCandidate`). The + * audit tier is ROOT-ONLY (WL-0MSTLFW14000KPEC): `wl list --root-only` + * excludes completed/in_review children, so only parent items are ever + * dispatched for audit — sub-tasks are never audited independently; and + * exactly one audit is active at a time (WL-0MT3PHW4I002SNOV, + * single-flight): before any candidate is selected the active-audit check + * (`deps.getActiveAudit`) skips the audit tier while a non-stale + * `kind=audit` dispatch marker (within `DOWNTIME_AUDIT_STALE_WINDOW_MS`, + * 2h) maps to an item still `in_progress` — an audit dispatched by ANY + * instance is honoured via the shared dispatch log (the stickiest + * cross-instance single-flight signal), and a marker older than the + * window is stale (the audit pane may have crashed without updating the + * work item) and is ignored so a NEW audit can proceed. The skip reports + * reason 'audit-in-flight' (never 'no-candidate' — the empty-backlog + * cooldown is not entered while the audit runs) and the check fails open + * (an unanswerable check just falls through to the next tier — dispatch + * is never blocked); then — + * BEFORE the non-critical implement tier — the critical-first tier + * (WL-0MT3FM8VA005XBHE): the highest-priority open CRITICAL item at ANY + * stage (idea / intake_complete / plan_complete), including + * dependency-blocked ones (`wl list --priority critical --status open` does + * NOT exclude them — unlike `wl next`), dispatched with the + * stage-appropriate skill (Q2 caps retained + Q3 dependency-frontier + * resolution — see `getNextCriticalCandidate`); then the non-critical * implement tier (WL-0MSMAYPQP001FLR6): the highest-priority open - * plan_complete item with risk Low / effort Small|XS → `/skill:implement ` + * plan_complete item with risk ≤ Medium / effort ≤ Medium → `/skill:implement ` * (fail-closed null on wl error or no candidate — never short-circuits the * fallback, AC5/AC6); if none, `wl next --stage intake_complete` → * `/skill:plan `; if none, `wl next --stage idea` → `/skill:intake `; - * if all four are empty, no dispatch. + * if all are empty, no dispatch. + * + * Critical-tier semantics: the lookup resolves through the same + * `DowntimeNextResult` error channel as the audit tier — a GENUINELY empty + * critical tier (`{ok:true, candidate:null}`) falls through to the + * non-critical tiers, while `{ok:false}` is a wl-error strike (never a + * silent fall-through, so a broken critical lookup can never look like "no + * critical work"). The stage-appropriate skill comes from + * `criticalSkillKind(candidate.stage)`; a dependency-blocked candidate is + * dispatched as its nearest OPEN frontier blocker with the blocker's own + * stage (Q3). The critical dispatch runs through the SAME + * `dispatchClaimedTier` pipeline (CAS claim with the TIER_EXPECTED stage + * entry → marker → spawn), so the claim-CAS, the dispatched-marker + * change-guard (rolling-log kind is the skill-mapped implement/plan/intake) + * and the single-flight guard compose unchanged. * * The audit tier resolves through the same `DowntimeNextResult` error * channel as the plan/intake tiers (WL-0MSLWJ2KP0002SV0): a GENUINELY empty @@ -920,10 +1762,15 @@ async function dispatchClaimedTier( * * Code-freeze gate (WL-0MSQ0RPQP00636JY): the marker is re-read fresh on * EVERY dispatch (never cached). While the marker is frozen OR ambiguous - * (fail-closed), the audit and implement tiers are skipped — no new + * (fail-closed), the audit and non-critical implement tiers are skipped — no new * implementation work (or audits) starts during a release freeze — and * dispatch continues with the plan/intake tiers, which are low-risk prep - * and still allowed. A freeze skip with an empty plan/intake backlog is + * and still allowed. The critical tier is STILL consulted during a freeze, + * with the split-by-skill rule (Q1): a critical plan_complete + * (implement-kind) candidate is skipped (no new code mid-release), while + * critical idea/intake_complete (intake/plan-kind) candidates STILL + * dispatch — critical prep is as low-risk as non-critical prep. A freeze + * skip with an empty plan/intake backlog is * reported as reason 'code-freeze' (NEVER 'no-candidate'), so it never * triggers the worker's no-candidate cooldown: polling continues and * implement/audit dispatch resumes immediately when the freeze lifts. @@ -942,44 +1789,188 @@ async function dispatchClaimedTier( */ export async function dispatchDowntimeWork( deps: DowntimeWorkerDeps, - opts: { model: string; cwd: string }, + opts: { model: string; cwd: string; freeSlots?: number }, ): Promise { if (dispatchInFlight) { return { dispatched: false, reason: 'dispatch-in-flight' }; } dispatchInFlight = true; try { + // Per-tier free-slot minimums at selection time (parent + // WL-0MT32F90V008UAD2 AC3): each tier requires a minimum number of + // FREE slots on the LATEST polled status before its candidate is even + // considered — audit needs 2 (parent + Phase 2 child at + // `AUDIT_PHASE2_PARALLELISM=1`, WL-0MSORQ1RG005DGUS), every single- + // pane tier (scheduled/implement/plan/intake) needs 1. These are + // ADDITIONAL selection-time checks — the idle-duration gate (configured + // N) is unchanged. `freeSlots` is computed by the caller from the + // latest poll (per-slot free count when per-slot identity is served, + // else `available_slots`). When absent (direct legacy callers), the + // tier minimums do not gate (fail-open for direct API use; the worker + // always passes the polled count). An unmet minimum skips that tier to + // the next eligible one — ineligible, never a strike and never a + // wl-error (mirrors the code-freeze skip). + const freeSlots = opts.freeSlots; + const panesEligible = freeSlots === undefined || freeSlots >= DOWNTIME_PANE_MIN_FREE_SLOTS; + const auditEligible = freeSlots === undefined || freeSlots >= DOWNTIME_AUDIT_MIN_FREE_SLOTS; + + // Active-audit single-flight outcome flags (WL-0MT3PHW4I002SNOV), read + // at the final empty-backlog return below: an `audit-in-flight` skip is + // reported as such (never 'no-candidate', so the cooldown is not + // entered while an audit runs); a failed active-audit check with an + // otherwise empty backlog reports wl-error (partial information must + // not pause the worker). + let auditInFlight = false; + let auditCheckFailed = false; + // Code-freeze gate (WL-0MSQ0RPQP00636JY): re-read the marker fresh on // every dispatch — never cached, so a freeze that starts or ends // mid-idle-period is honored on the next dispatch attempt. Frozen OR - // ambiguous (fail-closed) → the audit and implement tiers are skipped - // and dispatch falls through to the plan/intake tiers below. + // ambiguous (fail-closed) → the audit and non-critical implement tiers + // are skipped and dispatch falls through to the plan/intake tiers + // below; the critical tier below is STILL consulted, pausing only its + // implement-kind candidate (Q1 split-by-skill) — see the tier comment. const freezeStatus = deps.readCodeFreezeStatus(opts.cwd); const frozen = freezeStatus === 'frozen' || freezeStatus === 'ambiguous'; + if (!frozen && panesEligible) { + // 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 are skipped so no new code changes land mid- + // release, and dispatch falls through to the plan/intake tiers below. + // A due prompt dispatches its prompt text immediately — it never + // reaches the backlog tiers, so it never triggers the no-candidate + // cooldown (AC6). Absent or malformed config resolves null (fail- + // closed, logged by the dep): no scheduled dispatch and the tiers + // below are unaffected (AC2). + const duePrompt = await deps.getDueScheduledPrompt(opts.cwd); + if (duePrompt !== null) { + return await dispatchScheduledPrompt(deps, duePrompt, opts); + } + } + if (!frozen) { // Audit tier (WL-0MSI8H3HP000K0RG): dispatch /skill:audit for the - // first completed/in_review item without a valid audit. The lookup + // first completed/in_review item without a valid audit. Root-only + // (WL-0MSTLFW14000KPEC): only PARENT items are candidates — + // completed/in_review children are excluded server-side and never + // dispatched independently. The lookup // resolves through the DowntimeNextResult error channel // (WL-0MSLWJ2KP0002SV0): {ok:true, candidate:null} is a GENUINELY // empty audit tier and falls through to the implement tier below; // {ok:false} is a wl/parse failure — fail closed to busy (a strike), // never a silent fall-through, so a broken audit query cannot look // like "no audit candidates" and silently disable the audit tier. - const audit = await deps.getNextAuditCandidate(opts.cwd); - if (audit.ok) { - if (audit.candidate !== null) { - return await dispatchClaimedTier(deps, 'audit', audit.candidate, opts); + // + // Per-tier minimum (parent WL-0MT32F90V008UAD2 AC3): the audit tier + // requires ≥ 2 free slots (parent + Phase 2 child at + // `AUDIT_PHASE2_PARALLELISM=1`). When fewer are free the audit + // lookup is skipped entirely — ineligible, not a strike and not a + // wl-error (mirrors the code-freeze skip) — and dispatch falls + // through to the implement tier (which needs only ≥ 1). + if (auditEligible) { + // Active-audit single-flight (WL-0MT3PHW4I002SNOV): before any + // audit candidate is selected, check whether an audit-tier item is + // currently in flight — a non-stale `kind=audit` dispatch marker + // (within DOWNTIME_AUDIT_STALE_WINDOW_MS, 2h) maps to an item + // still `in_progress` (the shared dispatch log makes this + // cross-instance: an audit dispatched by ANY instance, leader or + // not, is seen here). While one is active the audit tier is skipped + // so audits stay strictly sequential — never fan-out consuming + // extra LLM capacity. The skip reports reason 'audit-in-flight' + // (never 'no-candidate') when the remaining backlog is empty, so + // the no-candidate cooldown is not entered while the audit runs + // (mirrors the code-freeze skip, WL-0MSQ0RPQP00636JY) and the next + // idle tick re-checks. `{ok:false}` (the check could not complete, + // e.g. a worklog query failure) fails open: the audit tier is + // skipped and dispatch falls through to the next tier — the + // dispatch loop is never blocked by an unanswerable check, and + // when the fallback backlog is also empty the outcome is a + // wl-error strike (partial information must not pause the worker + // with no-candidate). + const activeAudit = await deps.getActiveAudit(opts.cwd); + if (activeAudit.ok) { + if (activeAudit.active) { + auditInFlight = true; + process.stderr.write( + `[worklog-plugin] Downtime audit tier skipped: audit-in-flight\n`, + ); + } else { + // No active audit: proceed with the candidate lookup unchanged. + const audit = await deps.getNextAuditCandidate(opts.cwd); + if (audit.ok) { + if (audit.candidate !== null) { + return await dispatchClaimedTier(deps, 'audit', audit.candidate, opts); + } + } else { + return { dispatched: false, reason: 'wl-error' }; + } + } + } else { + // Fail-open: the check could not complete — skip the audit tier + // and fall through to the next tier (never block all dispatch). + auditCheckFailed = true; + } + // auditInFlight || auditCheckFailed → the audit tier is skipped and + // dispatch falls through to the implement tier below. + } + } + + // Critical-first tier (WL-0MT3FM8VA005XBHE): consulted on EVERY + // dispatch — including during a freeze. The lookup + // (getNextCriticalCandidate) returns the stage-appropriate critical + // candidate (F2: stage→skill via criticalSkillKind, plan_complete + // implement caps Q2; F3: dependency-blocked items resolve to their + // nearest open frontier blocker with the blocker's own stage). It + // runs AFTER the audit tier (a completed/in_review item needing audit + // keeps its slot) and BEFORE the non-critical implement/plan/intake + // tiers — a critical item at ANY stage always wins over any + // non-critical item (parent AC3). Error channel parity with the audit + // tier (WL-0MSLWJ2KP0002SV0): {ok:true, candidate:null} is a GENUINELY + // empty critical tier and falls through to the non-critical tiers; + // {ok:false} is a wl/parse failure — fail closed to a wl-error strike + // (never a silent fall-through, so a broken critical lookup cannot + // silently look like "no critical work"). + // + // Freeze split-by-skill (Q1): the marker is re-read fresh above, and + // while frozen OR ambiguous (fail-closed) a critical IMPLEMENT + // candidate (plan_complete) is SKIPPED — no new code changes land + // mid-release — but a critical plan/intake candidate STILL dispatches + // (low-risk prep allowed, matching the non-critical plan/intake tiers). + // Pane minimum (parent WL-0MT32F90V008UAD2 AC3): the critical tier + // needs ≥ 1 free slot like every single-pane tier. + if (panesEligible) { + const critical = await deps.getNextCriticalCandidate(opts.cwd); + if (critical.ok) { + if (critical.candidate !== null) { + const kind = criticalSkillKind(critical.candidate.stage); + if (kind !== null && !(frozen && kind === 'implement')) { + return await dispatchClaimedTier(deps, kind, critical.candidate, opts); + } + // Frozen implement-kind critical (or a non-dispatchable stage): + // skip this candidate (fail-closed pause) and fall through to + // the non-critical tiers. } } else { return { dispatched: false, reason: 'wl-error' }; } - // Implement tier (WL-0MSMAYPQP001FLR6): after the audit gate, dispatch - // /skill:implement for the highest-priority open plan_complete item with - // risk Low / effort Small|XS. getNextImplementCandidate is fail-closed + } + + if (!frozen && panesEligible) { + // Implement tier (WL-0MSMAYPQP001FLR6): after the critical-first + // gate, dispatch /skill:implement for the highest-priority open + // plan_complete item with + // risk ≤ Medium / effort ≤ Medium. getNextImplementCandidate is fail-closed // (null on wl failure or no candidate), so a null here means the tier is // exhausted and the plan/intake tiers below still run (AC5/AC6 — a wl // error at the implement tier does NOT short-circuit the fallback). + // Pane minimum (parent WL-0MT32F90V008UAD2 AC3 / F3-fix + // WL-0MT4RQTID000GT69): ≥ 1 free slot at selection time, matching the + // critical/audit/plan/intake tiers — a direct dispatchDowntimeWork( + // {freeSlots:0}) must never dispatch implement. 0 free slots is + // ineligible (never a strike): the lookup is skipped entirely and + // dispatch falls through to the plan tier's defensive no-candidate. const implementCandidate = await deps.getNextImplementCandidate(opts.cwd); if (implementCandidate !== null) { return await dispatchClaimedTier(deps, 'implement', implementCandidate, opts); @@ -987,15 +1978,24 @@ export async function dispatchDowntimeWork( } // Tier 2 (intake_complete → /skill:plan). A CLI error here does NOT // short-circuit: tier 3 is still attempted so a tier-3 candidate can - // still dispatch (operator refinement). + // still dispatch (operator refinement). Pane minimum (parent + // WL-0MT32F90V008UAD2 AC3): plan/intake need ≥ 1 free slot — via the + // worker this is always true (the idle-duration gate has already + // required ≥ N ≥ 1 free), the gate is defensive for direct API callers. let tier2Error = false; - const intakeComplete = await deps.getNextItem('intake_complete', opts.cwd); - if (intakeComplete.ok) { - if (intakeComplete.candidate !== null) { - return await dispatchClaimedTier(deps, 'plan', intakeComplete.candidate, opts); + if (panesEligible) { + const intakeComplete = await deps.getNextItem('intake_complete', opts.cwd); + if (intakeComplete.ok) { + if (intakeComplete.candidate !== null) { + return await dispatchClaimedTier(deps, 'plan', intakeComplete.candidate, opts); + } + } else { + tier2Error = true; } } else { - tier2Error = true; + // 0 free slots (defensive, unreachable via the worker): the panes + // cannot run — treat as an empty backlog (no candidate dispatchable). + return { dispatched: false, reason: 'no-candidate' }; } // Tier 3 (idea → /skill:intake) is ALWAYS attempted when tier 2 produced @@ -1020,7 +2020,11 @@ export async function dispatchDowntimeWork( // lifts (WL-0MSQ0RPQP00636JY). return frozen ? { dispatched: false, reason: 'code-freeze' } - : { dispatched: false, reason: 'no-candidate' }; + : auditInFlight + ? { dispatched: false, reason: 'audit-in-flight' } + : auditCheckFailed + ? { dispatched: false, reason: 'wl-error' } + : { dispatched: false, reason: 'no-candidate' }; } // Tier 3 errored (with or without a tier-2 error): fail closed to busy. // The worker counts this as one CLI-error strike; the backlog is not @@ -1034,17 +2038,19 @@ export async function dispatchDowntimeWork( /** * Argument vector for spawning `send-to-pi.sh`: visible pane named - * `Downtime `, `--no-focus` (visible but never steals focus), - * `--cwd `, `--model `, then the prompt. + * `Downtime ` (`paneName` overrides it — the scheduled-prompts tier + * passes `Downtime `, WL-0MSS1Q5ER007QDKX), `--no-focus` (visible but + * never steals focus), `--cwd `, `--model `, then the + * prompt. */ export function buildDowntimePaneArgs( kind: DowntimeSkillKind, prompt: string, - opts: { model: string; cwd: string }, + opts: { model: string; cwd: string; paneName?: string }, ): string[] { return [ '--pane-name', - `Downtime ${kind}`, + opts.paneName ?? `Downtime ${kind}`, '--no-focus', '--cwd', opts.cwd, @@ -1178,10 +2184,23 @@ export async function spawnDowntimePane( // ── Worker orchestrator (implemented — F3) ──────────────────────────── -/** Per-tick configuration; re-read every tick so settings apply live. */ +/** + * Per-tick configuration; re-read every tick so settings apply live. + * + * `override` (optional) is the per-instance in-memory enabled-state override: + * `null` (default) follows the global setting; `true`/`false` force dispatch + * on/off for THIS worker regardless of the global config. The override is + * purely in-memory — it resets on plugin restart and never touches the shared + * settings file (per-instance scoping, parent WL-0MSZ4NSOE007AQEF). + */ export interface DowntimeWorkerConfig { poller: DowntimePoller; deps: DowntimeWorkerDeps; + /** + * Per-instance in-memory enabled override. `null` (default) = follow the + * global setting; `true`/`false` force dispatch on/off for this worker. + */ + override?: boolean | null; /** Re-read each tick so settings changes apply without a restart. */ config(): { enabled: boolean; @@ -1192,6 +2211,42 @@ export interface DowntimeWorkerConfig { /** Pause duration after a genuine empty backlog (no-candidate), ms. */ noCandidateCooldownMs: number; }; + /** + * Optional shared round-robin registry (WL-0MSSRED76008LGB6) used for + * rotation-aware selection and probe jitter. When absent, selection falls + * back to the pre-rotation sortIndex order and probes use the static + * interval (fail-open). + */ + registry?: RoundRobinRegistry; + /** + * Shared coordination directory — the `.worklog` dir where + * `downtime-coordination.json` (+ the leader lock/lease) lives. All + * herdr instances contributing to the same coordination list pass the + * same value (parent AC3 — single-machine v1). + * + * Optional for backward compatibility / fail-open: when ABSENT the + * worker runs the LEGACY non-coordination flow — every instance polls + * and dispatches through the direct tier chain (`dispatchDowntimeWork`) + * exactly as before the refactor (used by existing tests and by + * configurations that have not wired coordination). Production wiring + * (`createDowntimeWorker` in index.ts) always passes it. + */ + coordinationDir?: string; + /** + * Stable per-instance id used for leader election and the coordination + * entry key (parent AC3 `instanceId`). Omitted → generated once at + * worker construction (per-process id; a restarted instance re-offers + * under a fresh id and its dead lease expires in the TTL). + */ + instanceId?: string; + /** Leader lease TTL seconds (parent AC2 — default 5 minutes). */ + leaseTtlSeconds?: number; + /** + * Coordination check-in interval (parent AC3 — default 30 minutes): + * every instance re-verifies/updates its entry at this cadence, whether + * leader or not. + */ + checkInIntervalMs?: number; } export interface DowntimeWorkerTickResult { @@ -1209,8 +2264,43 @@ export interface DowntimeWorker { readonly dispatching: boolean; /** Timestamp of the last successful dispatch (null until the first). */ readonly lastDispatchAt: number | null; - /** Whether the worker is enabled per the current settings (re-read). */ + /** + * Whether the worker is enabled per the current settings (re-read) AND the + * per-instance in-memory override: effective enabled = `override ?? + * cfg.enabled` (the override takes precedence when set). The getter re-reads + * the global setting each call, so a settings change applies live while the + * override stays in force. + */ readonly enabled: boolean; + /** + * The current per-instance in-memory override (`null` = follow settings; + * `true`/`false` = force dispatch on/off for this instance). + */ + readonly override: boolean | null; + /** + * True when the current `override === false` was restored from the + * persisted disable marker (`.herdr-downtime-disabled`) at construction + * — i.e. the disable survived a pane/plugin restart. False for a live + * toggle() press and after any explicit re-enable; lets the header show a + * "restored" notice so a restored disable is never silent + * (WL-0MT5SG0VU005ARUR). + */ + readonly restoredFromMarker: boolean; + /** + * Flip the per-instance in-memory override: `null` → `false` (disable + * dispatch for this instance) → `true` (force dispatch on for this + * instance) → `null` (return to following the global setting), and so on. + * In-memory only — never written to the settings file, never persisted. + */ + toggle(): void; + /** + * Compute a jittered probe interval (WL-0MSSRED76008LGB6): the effective + * poll interval is jittered ±50% of the configured `downtimePollIntervalMs` + * per call (random, injectable RNG via the worker's registry) so instances + * with identical configuration do not probe in lockstep. Returns the + * clamped interval for the NEXT probe. + */ + jitterPollIntervalMs(baseIntervalMs: number): number; /** * True while the worker is paused in the no-candidate cooldown: no proxy * polling, no idle tracking, and no dispatch until the pause expires @@ -1223,6 +2313,18 @@ export interface DowntimeWorker { * when a cooldown expires. */ readonly errorStrikes: number; + /** + * Whether THIS instance currently holds the elected leadership (parent + * WL-0MST3OJ8S0001ROL AC1): true while the leader lease is valid and + * owned by this instance. Only the leader polls the proxy and dispatches. + */ + readonly isLeader: boolean; + /** + * The instance id this worker uses for leader election and its + * coordination entry key (parent AC3 `instanceId`). Stored per worker + * construction for the process lifetime. + */ + readonly instanceId: string; } /** @@ -1241,6 +2343,48 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker // Three-strike rule: consecutive CLI-error dispatch outcomes. A successful // dispatch, a genuine no-candidate outcome, or an expired cooldown resets it. let errorStrikes = 0; + // Per-instance in-memory enabled override (parent WL-0MSZ4NSOE007AQEF): + // null (default) = follow the global setting; true/false force dispatch + // on/off for THIS instance. In-memory only — resets on plugin restart, + // never written to the shared settings file. Initialized from the optional + // config so callers can construct a pre-toggled worker. + let override: boolean | null = opts.override ?? null; + // Durable disable (WL-0MT5SFP990001FNW): when the per-worklog-root marker + // exists, the worker starts disabled so a previous `d` press survives a + // restart. Explicit opts.override wins over the marker (marker is only the + // fallback when no override is passed). + let restoredFromMarker = false; + if (opts.override === undefined && disableMarkerExists(opts.config().cwd)) { + override = false; + restoredFromMarker = true; + } + + // ── Leader-election + coordination state (parent WL-0MST3OJ8S0001ROL) ── + // One leader-election manager per worker (per process): the instance id + // is fixed at construction so the lease file owner and the coordination + // entry key stay stable for the process lifetime. The manager owns the + // file lock/lease at `/downtime-leader.lock` + + // `downtime-leader-lease.json`. When `coordinationDir` is ABSENT the + // worker runs the legacy flow (self is always the leader — no election) + // so existing non-coordination configurations/tests keep working + // unchanged (fail-open). + const leaderManager = opts.coordinationDir + ? createLeaderElectionManager({ + worklogDir: opts.coordinationDir, + instanceId: opts.instanceId, + leaseTtlSeconds: opts.leaseTtlSeconds, + }) + : null; + const instanceId = leaderManager ? leaderManager.getInstanceId() : (opts.instanceId ?? 'legacy'); + const checkInIntervalMs = opts.checkInIntervalMs ?? DEFAULT_COORDINATION_CHECK_IN_MS; + // Timestamp of the last coordination check-in (30-min cadence, parent + // AC3). null until the first tick so every instance checks in on startup + // (first check-in on startup — parent Constraint). + let lastCheckInAt: number | null = null; + // Per-tick cached leadership decision (the lease read is cheap but let a + // tick observe ONE consistent state — an election win mid-tick applies + // next tick). Legacy mode (no coordinationDir) is always the leader. + let leaderState = leaderManager ? leaderManager.isLeader() : true; return { get idleSince(): number | null { @@ -1252,8 +2396,48 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker get lastDispatchAt(): number | null { return lastDispatchAt; }, + get isLeader(): boolean { + return leaderState; + }, + get instanceId(): string { + return instanceId; + }, get enabled(): boolean { - return opts.config().enabled; + // Effective enabled = override ?? cfg.enabled: the per-instance + // override takes precedence when set (so `d` can force dispatch on + // for one pane even when the global setting is off); with no override + // the worker follows the global setting exactly as before. + return override ?? opts.config().enabled; + }, + get override(): boolean | null { + return override; + }, + get restoredFromMarker(): boolean { + return restoredFromMarker; + }, + toggle(): void { + // null → false → null cycle: pressing `d` disables dispatch; + // pressing again returns to following the global setting. + // No force-enable: a second press never sets override to true. + // The per-worklog-root marker (WL-0MT5SFP990001FNW) is written on + // disable and removed on re-enable so the disable survives restarts. + // A live press (or an explicit re-enable) clears the restored flag so + // the header notice only reflects the marker-restored state. + if (override === null) { + override = false; + restoredFromMarker = false; + writeDisableMarker(opts.config().cwd); + } else { + override = null; + restoredFromMarker = false; + removeDisableMarker(opts.config().cwd); + } + }, + jitterPollIntervalMs(baseIntervalMs: number): number { + // Fail-open: no registry → static interval (no jitter). + return opts.registry + ? opts.registry.getEffectivePollInterval(baseIntervalMs) + : baseIntervalMs; }, get paused(): boolean { return cooldownUntil !== null && Date.now() < cooldownUntil; @@ -1263,7 +2447,10 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker }, async tick(): Promise { const cfg = opts.config(); - if (!cfg.enabled) return { polled: false, dispatched: false, idle: false }; + // Short-circuit on the EFFECTIVE enabled state (override ?? settings): + // while toggled off the worker performs no proxy polling, no idle + // tracking, and no dispatch — exactly the settings-disabled path. + if (!(override ?? cfg.enabled)) return { polled: false, dispatched: false, idle: false }; // Cooldown gate: while paused the worker performs NO proxy polling, NO // idle tracking, and NO dispatch. The pause is a full stop (user // confirmed "pause completely"); once it expires the idle tracker is @@ -1276,8 +2463,84 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker cooldownUntil = null; // pause expired — resume normal polling errorStrikes = 0; // fresh strike counter after the pause } + + // ── Leader election + coordination check-in (parent + // WL-0MST3OJ8S0001ROL AC1/AC2/AC3) ─────────────────────────────────── + // In legacy mode (no coordinationDir) this whole block is skipped: no + // election, no check-in — the worker is always the leader and uses the + // direct tier chain below (pre-refactor behavior). + const tickNow = Date.now(); + if (leaderManager !== null) { + // Every tick resolves leadership (a single cheap lease-file read): + // - While leader: refresh the 5-minute lease (each proxy-poll cycle + // extends it — AC2). + // - While non-leader: NO proxy polling and NO dispatch (AC4); only + // detect a stale/expired lease (leader crashed or idle) and run a + // new election — takeover lands within the lease TTL, well inside + // the 5-minute user-story bound. + // - Fail-safe (constraint): a missing/unreadable lease file is + // treated as "not leader" — the instance continues operating + // without dispatching. + if (leaderState) { + // Refresh the lease first (each poll cycle extends the TTL). A + // failed refresh (rename/IO) is fail-safe: the existing lease + // stands until it expires, then a new election runs. + leaderManager.refreshLease(); + } else if (!leaderManager.hasLease() || leaderManager.detectStaleLeader()) { + // No leader exists yet (fresh election) OR the elected leader's + // lease expired (crash / idle) — try to take over NOW. Stale + // state is cleaned first (an expired lease, or an orphaned lock + // without a lease, must not block the new election). On success + // we become the leader this tick; on lock contention (another + // instance won first — its cleanup+no-op or a valid lease) we + // stay non-leader and retry on a later tick. Fail-safe: a failed + // election leaves us non-leader (no dispatch). + cleanupStaleElection({ worklogDir: opts.coordinationDir! }); + leaderManager.attemptElection(); + leaderState = leaderManager.isLeader(); + if (leaderState) leaderManager.refreshLease(); + // Audit trail (WL-0MSXHAE290067VAL): log the leadership win — + // initial election or takeover after a stale-lease detection. + if (leaderState) { + void appendCoordinationLogEntry(cfg.cwd, { + kind: 'coordination', + operation: 'election', + instanceId, + at: new Date(tickNow).toISOString(), + }); + } + } + + // Coordination check-in (parent AC3 — first on startup, then every + // 30 minutes): recompute THIS instance's most-important item and + // verify/update its entry in the shared coordination file. Runs for + // leader AND non-leader alike — every instance contributes its most + // important item; the single elected leader dispatches from the list. + if (lastCheckInAt === null || tickNow - lastCheckInAt >= checkInIntervalMs) { + lastCheckInAt = tickNow; + try { + await runCoordinationCheckIn(opts.deps, { + cwd: cfg.cwd, + coordinationDir: opts.coordinationDir!, + instanceId, + }, tickNow); + } catch { + // Fail-safe: a throwing check-in (stub or regression) must never + // crash the worker — retried at the next cadence. + } + } + + // Non-leader short-circuit (AC4): no proxy polling, no idle + // tracking, no dispatch — the check-in above is the instance's only + // coordination activity. (Lease takeover was handled above.) + if (!leaderState) { + return { polled: false, dispatched: false, idle: false }; + } + } + if (opts.poller.isPolling()) return { polled: false, dispatched: false, idle: tracker.idleSince !== null }; + const status = await opts.poller.poll(); if (status === null) { tracker.record(false); // endpoint failure/ambiguity → busy @@ -1288,9 +2551,12 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker // Per-slot routing (LP-0MSG5TA7Y002GN39): when the proxy serves // per-slot identity AND the config asks for 0 < N < total, idle // duration is tracked PER SLOT so dispatch requires the SAME N slots - // continuously free for the full threshold. Any global busy condition - // (query / model switch / lease / server down / ambiguous) resets ALL - // slot timers; a slot reporting processing resets only its own. + // continuously free for the full threshold. The global gate in + // per-slot mode is the per-slot-safe subset (server up + no model + // switch): a query/lease tied to a busy slot is the operator's own + // session and must not reset the free slots' timers (spare-capacity + // dispatch, parent WL-0MT32F90V008UAD2). A slot reporting processing + // resets only its own timer. const perSlotMode = Array.isArray(status.slots) && cfg.requiredFreeSlots > 0 && @@ -1299,9 +2565,17 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker let idle: boolean; let ready: boolean; if (perSlotMode && Array.isArray(status.slots)) { - const globalIdle = globalIdleChecks(status); - // Keep the worker's idleSince view in sync with the run state. - tracker.record(globalIdle); + const globalIdle = perSlotGlobalIdleChecks(status); + // Display-only: the global idle tracker also reflects per-slot query + // activity for the title bar idle indicator — when any slot is + // processing (including the operator's), the title bar shows "busy", + // not "idle". The dispatch logic (spare-capacity relaxation) is + // unaffected because free-slot count comes from perSlotTracker, not + // tracker.idleSince. (parent WL-0MT65T14L002HTWB) + const anySlotProcessing = status.slots.some( + (s) => typeof s.is_processing === 'boolean' && s.is_processing, + ); + tracker.record(globalIdle && !anySlotProcessing); if (globalIdle) { perSlotTracker.record(status.slots); idle = true; @@ -1323,12 +2597,55 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker if (!ready) return { polled: true, dispatched: false, idle: true }; if (dispatching) return { polled: true, dispatched: false, idle: true }; + // ── Dispatch (parent WL-0MST3OJ8S0001ROL AC4) ── + // Per-tier free-slot minimums (parent WL-0MT32F90V008UAD2 AC3): the + // latest polled free-slot count flows into dispatch selection — per-slot + // free count when per-slot identity is served (fail-closed counting: + // an entry without an explicit boolean `is_processing` is busy, never + // free), else `available_slots`. The coordination path uses it to gate + // the audit tier (needs ≥ 2 slots: parent + Phase 2 child); the legacy + // path passes it through to the direct tier chain unchanged. + const freeSlots = + Array.isArray(status.slots) + ? status.slots.filter( + (s) => typeof s.is_processing === 'boolean' && !s.is_processing, + ).length + : status.available_slots; + + // Coordination mode: the elected leader reads the shared coordination + // list, re-fetches and classifies each entry's item (tier priority: + // audit → implement → plan → intake) and dispatches the + // highest-priority available item when a slot opens, removing its + // entry. The CAS claim + dispatched-marker guards are preserved + // inside `dispatchClaimedTier` (AC5). The idle-duration gate above + // has already required ≥ N ≥ 1 free slots continuously, so the + // dispatch runs only with a real slot available. + // + // Legacy mode (no coordinationDir / no fetchItem dep): the direct + // per-instance tier chain (`dispatchDowntimeWork`) — pre-refactor + // behavior, retained as the fail-open fallback. dispatching = true; try { - const outcome = await dispatchDowntimeWork(opts.deps, { - model: cfg.model, - cwd: cfg.cwd, - }); + const outcome = + opts.coordinationDir && typeof opts.deps.fetchItem === 'function' + ? await dispatchFromCoordination( + opts.deps, + readCoordinationFile(opts.coordinationDir)?.entries ?? [], + { + model: cfg.model, + cwd: cfg.cwd, + coordinationDir: opts.coordinationDir, + freeSlots, + leaseTtlMs: opts.leaseTtlSeconds + ? opts.leaseTtlSeconds * 1000 + : DEFAULT_LEASE_TTL_SECONDS * 1000, + }, + ) + : await dispatchDowntimeWork(opts.deps, { + model: cfg.model, + cwd: cfg.cwd, + freeSlots, + }); if (outcome.dispatched) { lastDispatchAt = Date.now(); errorStrikes = 0; // a successful dispatch proves the CLI is healthy @@ -1388,14 +2705,16 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker /** * Instruction appended to every dispatched prompt: when the skill run * cannot proceed because answers are needed, record the questions in a - * comment on the work item, mark it as needing producer review, and stop — - * never block indefinitely (parent AC6). + * comment on the work item, mark it as needing producer review, repeat the + * questions in the final summary, and stop — never block indefinitely (parent + * AC6). */ export const BLOCKED_QUESTIONS_INSTRUCTION = 'If you cannot proceed because you need answers, record the questions in a ' + 'comment on the work item (wl comment add --comment "question: ...") and ' + 'mark the item as needing producer review (wl update --needs-producer-review ' + - 'true), then stop — do not block indefinitely.'; + 'true), repeat the questions in your final summary, then stop — do not block ' + + 'indefinitely.'; /** Build the prompt dispatched to a pi agent pane for the given candidate. */ export function buildDowntimePrompt(kind: DowntimeSkillKind, candidate: DowntimeCandidate): string { @@ -1418,7 +2737,7 @@ export function buildDowntimePrompt(kind: DowntimeSkillKind, candidate: Downtime */ export function buildDowntimeDispatchComment( itemId: string, - kind: DowntimeSkillKind, + kind: DowntimeDispatchKind, dispatchedAt: string, title?: string, ): string { @@ -1426,6 +2745,7 @@ export function buildDowntimeDispatchComment( kind === 'plan' ? '/skill:plan' : kind === 'audit' ? '/skill:audit' : kind === 'implement' ? '/skill:implement' + : kind === 'scheduled' ? 'scheduled prompt' : '/skill:intake'; const suffix = title ? ` (${title.replace(/[\r\n]+/g, ' ')})` : ''; return `Auto-dispatched by the herdr downtime worker at ${dispatchedAt} — running ${skill} ${itemId}${suffix}.`; @@ -1485,6 +2805,7 @@ export function parseNextCandidatesOutput( typeof nested.sortIndex === 'number' && Number.isFinite(nested.sortIndex) ? nested.sortIndex : undefined, + priority: typeof nested.priority === 'string' ? nested.priority : undefined, }); } return candidates; @@ -1529,13 +2850,72 @@ export function skillKindFromPrompt(prompt: string): DowntimeSkillKind { return prompt.includes('/skill:plan ') ? 'plan' : 'intake'; } +// ── Rotation-aware selection (WL-0MSSRED76008LGB6) ──────────────────── + +/** + * A candidate carrying the fields needed for rotation-aware selection: + * a stable id, an optional priority level (the round-robin grouping key), + * and an optional sortIndex (wl next priority order preserved). + */ +export interface RotatableCandidate { + id: string; + priority?: string; + sortIndex?: number; +} + +/** + * Apply round-robin rotation within the highest-priority group of a + * candidate list. Fail-open design (WL-0MSSRED76008LGB6): + * + * - No registry (or an empty/closed registry) → fall back to the plain + * sortIndex order (first candidate wins) — the pre-rotation behaviour. + * - Candidates with no `priority` field → fall back to sortIndex order + * (rotation needs the priority grouping key). + * - The highest-priority group with multiple members rotates through a + * shared durable cursor (`advanceCursor` persists the advance). + * - A single-member group needs no rotation → sortIndex order stands. + * + * Candidates are sorted ascending by sortIndex (wl next priority order) + * before grouping, so the first group always holds the top priority. + * + * @param candidates Candidates already filtered/validated by the caller. + * @param registry Optional shared round-robin registry. When absent, + * rotation is skipped entirely (fail-open). + * @returns The selected candidate, or null when the list is empty. + */ +export function selectWithRotation( + candidates: T[], + registry?: RoundRobinRegistry, +): T | null { + if (candidates.length === 0) return null; + const sorted = [...candidates].sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); + if (sorted.length === 1) return sorted[0] ?? null; + if (!registry) return sorted[0] ?? null; + + // Group by priority level; only rotate within the highest-priority group + // (candidates are sorted ascending by sortIndex, so the first group holds + // the top priority). Missing priority on the leader → no rotation + // possible (fail-open). + const topPriority = sorted[0]?.priority; + if (!topPriority) return sorted[0] ?? null; + + const topGroup = sorted.filter((c) => c.priority === topPriority); + if (topGroup.length <= 1) return sorted[0] ?? null; + + // Rotate: the cursor selects the next member of the tied group and + // persists the advance (write-then-spawn ordering handled by callers). + const index = registry.advanceCursor(topPriority, topGroup.length); + return topGroup[index] ?? topGroup[0] ?? null; +} + // ── Audit-tier selection (WL-0MSI8H3HP000K0RG) ──────────────────────── /** - * Parse the stdout of `wl list --status completed --stage in_review --json` - * into typed audit candidates. Accepts both the bare array shape and the - * `{ workItems: [...] }` wrapper. Malformed/empty output yields null - * (fail-closed). + * Parse the stdout of `wl list --status completed --stage in_review + * --root-only --json` (root-only, WL-0MSTLFW14000KPEC: only parent items + * are audit candidates) into typed audit candidates. Accepts both the bare + * array shape and the `{ workItems: [...] }` wrapper. Malformed/empty + * output yields null (fail-closed). */ export function parseAuditCandidatesOutput(stdout: string): AuditCandidate[] | null { let parsed: unknown; @@ -1562,15 +2942,59 @@ export function parseAuditCandidatesOutput(stdout: string): AuditCandidate[] | n auditedAt: typeof o.auditedAt === 'string' ? o.auditedAt : undefined, updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : undefined, sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, + priority: typeof o.priority === 'string' ? o.priority : undefined, }); } return candidates; } +/** + * Parse the item ids from `wl list --status in_progress --json` output + * (WL-0MT3PHW4I002SNOV): returns the ids of every item currently + * `in_progress`, or null on malformed output (fail-closed — the + * active-audit single-flight check treats an unparseable worklog query as + * a failed check, `{ok:false}`). Accepts the same shapes as the other wl + * parsers: a `{workItems: [...]}` object with bare item objects, a + * `{workItems: [{workItem: {...}}]}` wrapper shape, or a bare array. + * Entries without a usable id are skipped. + */ +export function parseInProgressOutput(stdout: string): Set | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const items = Array.isArray(parsed) + ? parsed + : parsed !== null && typeof parsed === 'object' && Array.isArray((parsed as { workItems?: unknown }).workItems) + ? (parsed as { workItems: unknown[] }).workItems + : null; + if (items === null) return null; + + const ids = new Set(); + for (const raw of items) { + if (typeof raw !== 'object' || raw === null) continue; + const o = raw as Record; + // Accept both bare item objects and the {workItem: {...}} wrapper. + const wrapped = typeof o.workItem === 'object' && o.workItem !== null + ? (o.workItem as Record) + : undefined; + const id = typeof wrapped?.id === 'string' + ? wrapped.id + : typeof o.id === 'string' + ? o.id + : undefined; + if (typeof id === 'string' && id.length > 0) ids.add(id); + } + return ids; +} + /** * Select the next audit candidate from parsed in_review items: the first * item WITHOUT a valid audit, sorted ascending by `sortIndex` (heartbeat - * convention). "Valid audit" reuses `isAuditFresh` from icons.ts (fresh = a + * convention). "Valid audit" reuses `isAuditFresh` from @worklog/shared/icons + * (fresh = a * review icon that is neither ⏳ nor 🔍). Returns null when no candidate is * unaudited/stale (or the list is empty). Missing auditedAt/updatedAt means * not fresh → selected. @@ -1596,9 +3020,10 @@ export function selectAuditCandidate( candidates: AuditCandidate[], now: number = Date.now(), dispatchedItemIds?: ReadonlySet, + registry?: RoundRobinRegistry, ): AuditCandidate | null { const recencyCutoff = now - DOWNTIME_AUDIT_RECENCY_WINDOW_MS; - const target = candidates + const filtered = candidates .filter((c) => !isAuditFresh(c.auditedAt, c.updatedAt)) .filter((c) => !(dispatchedItemIds?.has(c.id) ?? false)) .filter((c) => { @@ -1608,7 +3033,7 @@ export function selectAuditCandidate( return updated >= recencyCutoff; }) .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); - return target[0] ?? null; + return selectWithRotation(filtered, registry); } /** @@ -1628,7 +3053,11 @@ export function toDowntimeCandidate(candidate: AuditCandidate): DowntimeCandidat * client-side guard. Unset/unknown values map to null (fail-closed). */ function riskOrdinal(risk: string | undefined | null): number | null { - switch ((risk ?? '').trim().toLowerCase()) { + // Extract the leading keyword before any delimiter (—, -, :, whitespace). + // Agents may produce verbose risk fields like "Medium — NVIDIA driver changes…"; + // the belt-and-suspenders guard must recognise the level regardless. + const normalized = (risk ?? '').trim().toLowerCase().split(/[-:–—\s]+/)[0]; + switch (normalized) { case 'low': return 1; case 'medium': return 2; case 'high': return 3; @@ -1647,7 +3076,13 @@ function riskOrdinal(risk: string | undefined | null): number | null { * (fail-closed). */ function effortOrdinal(effort: string | undefined | null): number | null { - switch ((effort ?? '').trim().toLowerCase().replace(/[\s-]+/g, '')) { + const normalized = (effort ?? '').trim().toLowerCase(); + // Try exact match on stripped version first (simple cases: "small", "medium", + // "Extra Small" → "extrasmall"). If that fails, fall back to searching for + // the keyword anywhere in the string — agents produce verbose fields like + // "1–4 hours — Small. Diagnostic investigation…" (keyword after the dash). + const stripped = normalized.replace(/[\s-]+/g, ''); + switch (stripped) { case 'xs': case 'extrasmall': return 1; case 's': @@ -1658,7 +3093,20 @@ function effortOrdinal(effort: string | undefined | null): number | null { case 'large': return 4; case 'xl': case 'extralarge': return 5; - default: return null; + default: + // Fallback: search for the keyword bounded by non-word characters. + // Checked in order of specificity (longest first) so "small" wins over "s". + if (/(?:^|\W)extrasmall(?:\W|$)/.test(normalized)) return 1; + if (/(?:^|\W)xs(?:\W|$)/.test(normalized)) return 1; + if (/(?:^|\W)small(?:\W|$)/.test(normalized)) return 2; + if (/(?:^|\W)s(?:\W|$)/.test(normalized)) return 2; + if (/(?:^|\W)extralarge(?:\W|$)/.test(normalized)) return 5; + if (/(?:^|\W)large(?:\W|$)/.test(normalized)) return 4; + if (/(?:^|\W)xl(?:\W|$)/.test(normalized)) return 5; + if (/(?:^|\W)l(?:\W|$)/.test(normalized)) return 4; + if (/(?:^|\W)m(?:\W|$)/.test(normalized)) return 3; + if (/(?:^|\W)medium(?:\W|$)/.test(normalized)) return 3; + return null; } } @@ -1699,6 +3147,7 @@ export function parseImplementCandidatesOutput(stdout: string): ImplementCandida risk: typeof o.risk === 'string' ? o.risk : undefined, effort: typeof o.effort === 'string' ? o.effort : undefined, sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, + priority: typeof o.priority === 'string' ? o.priority : undefined, }); } return candidates; @@ -1707,33 +3156,34 @@ export function parseImplementCandidatesOutput(stdout: string): ImplementCandida /** * Select the next implement candidate from parsed wl next output: the * first candidate that is open (`status === 'open'`, AC2), carries risk - * exactly Low and effort Small/Extra Small (AC1 threshold boundaries, + * risk ≤ Medium and effort ≤ Medium (AC1 threshold boundaries, * fail-closed on unset/unknown), is not in the dispatched-marker set * (kind `implement`, AC6), sorted ascending by `sortIndex` (wl next * priority order preserved). Returns null when no candidate qualifies * (or the list is empty). * * Belt-and-suspenders client-side guard (AC1): even though `wl next - * --risk low --effort small` filters server-side, the herdr tier verifies + * --risk medium --effort medium` filters server-side, the herdr tier verifies * the thresholds again so a malformed/absent server filter can never * dispatch a Medium+/Large+ item. */ export function selectImplementCandidate( candidates: ImplementCandidate[], dispatchedItemIds?: ReadonlySet, + registry?: RoundRobinRegistry, ): ImplementCandidate | null { - const target = candidates + const filtered = candidates .filter((c) => c.status === 'open') .filter((c) => { const risk = riskOrdinal(c.risk); - if (risk === null || risk !== 1) return false; // only risk exactly Low + if (risk === null || risk > 2) return false; // risk ≤ Medium (1=Low, 2=Medium) const effort = effortOrdinal(c.effort); - if (effort === null || effort > 2) return false; // Small (2) + Extra Small (1) + if (effort === null || effort > 3) return false; // effort ≤ Medium (1=XS, 2=S, 3=M) return true; }) .filter((c) => !(dispatchedItemIds?.has(c.id) ?? false)) .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); - return target[0] ?? null; + return selectWithRotation(filtered, registry); } /** @@ -1764,8 +3214,9 @@ export function toImplementCandidate(candidate: ImplementCandidate): DowntimeCan export function selectNextCandidate( candidates: DowntimeCandidate[], dispatchedStages?: ReadonlyMap, + registry?: RoundRobinRegistry, ): DowntimeCandidate | null { - const target = candidates + const filtered = candidates .filter((c) => c.status === 'open') .filter((c) => { const dispatchedAt = dispatchedStages?.get(c.id); @@ -1774,14 +3225,380 @@ export function selectNextCandidate( return dispatchedAt === undefined || dispatchedAt !== c.stage; }) .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); - return target[0] ?? null; + return selectWithRotation(filtered, registry); +} + +// ── Critical-first selection (WL-0MT3FM8VA005XBHE, F2) ─────────────── +// The critical-first pre-tier dispatches an open critical item at ANY +// stage (idea / intake_complete / plan_complete) with its stage- +// appropriate skill — ahead of EVERY non-critical candidate. Stage→skill +// mapping: idea → /skill:intake, intake_complete → /skill:plan, +// plan_complete → /skill:implement. Implement dispatch keeps the +// implement tier's risk/effort caps (decision Q2); every existing guard +// (CAS claim, dispatched-marker change-guard, single-flight) composes +// unchanged. + +/** + * An open critical work-item candidate, enumerated by + * `wl list --priority critical --status open` (blocked items included — + * `wl list` does not exclude dependency-blocked items). Carries the + * worklog `stage` (needed for the stage→skill mapping), the client-side + * `status` guard, and the risk/effort fields for the plan_complete caps + * (decision Q2). + */ +export interface CriticalCandidate { + id: string; + title: string; + /** Worklog status (`open` for selectable items; fail-closed on missing). */ + status: string; + /** Worklog stage: idea | intake_complete | plan_complete | in_progress | … */ + stage: string; + risk?: string; + effort?: string; + /** wl priority order preserved for deterministic selection. */ + sortIndex?: number; + /** Worklog priority level (critical) — round-robin grouping key. */ + priority?: string; +} + +/** + * Map a critical candidate's worklog stage to the dispatch skill kind. + * Only dispatchable stages map: `idea` → `intake`, `intake_complete` → + * `plan`, `plan_complete` → `implement`. Every other stage (in_progress, + * in_review, completed, …) is NOT a critical-tier dispatch target and + * maps to null (the tier falls through to the normal order). + */ +export function criticalSkillKind(stage: string): DowntimeSkillKind | null { + if (stage === 'idea') return 'intake'; + if (stage === 'intake_complete') return 'plan'; + if (stage === 'plan_complete') return 'implement'; + return null; +} + +/** + * Parse the stdout of `wl list --priority critical --status open --json` + * into typed critical candidates. Accepts the shape the CLI emits for a + * batch (`{ workItems: [...] }`, flat entries carrying the enriched + * workItem fields). Entries without an id are skipped; malformed JSON or + * output without a workItems list yields null (fail-closed); an empty + * list yields `[]` (a genuine empty critical tier). + */ +export function parseCriticalCandidatesOutput(stdout: string): CriticalCandidate[] | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const items = + parsed !== null && + typeof parsed === 'object' && + Array.isArray((parsed as { workItems?: unknown }).workItems) + ? (parsed as { workItems: unknown[] }).workItems + : null; + if (items === null) return null; + + const candidates: CriticalCandidate[] = []; + for (const raw of items) { + if (typeof raw !== 'object' || raw === null) continue; + const o = raw as Record; + if (typeof o.id !== 'string' || o.id.length === 0) continue; + candidates.push({ + id: o.id, + title: typeof o.title === 'string' ? o.title : '', + status: typeof o.status === 'string' ? o.status : '', + stage: typeof o.stage === 'string' ? o.stage : '', + risk: typeof o.risk === 'string' ? o.risk : undefined, + effort: typeof o.effort === 'string' ? o.effort : undefined, + sortIndex: typeof o.sortIndex === 'number' && Number.isFinite(o.sortIndex) ? o.sortIndex : undefined, + priority: typeof o.priority === 'string' ? o.priority : undefined, + }); + } + return candidates; +} + +/** + * Select the next critical-tier candidate from parsed wl list output: + * the first open candidate at a dispatchable stage (idea / + * intake_complete / plan_complete), with `plan_complete` gated by the + * implement caps (risk ≤ Medium / effort ≤ Medium, decision Q2 — an + * above-caps critical is never implement-dispatched by the downtime + * worker), excluding candidates in the dispatched-marker change-guard + * map (id → stage at dispatch — excluded while still at the dispatched- + * at stage; a stage advancement releases it). Sorted ascending by + * `sortIndex` (wl priority order) with round-robin tie-break within the + * critical group (reused `selectWithRotation`; the rotation cursor keys + * on the `critical` priority, so critical rotation never disturbs + * non-critical tier cursors). Returns null when no candidate qualifies + * (or the list is empty). + */ +export function selectCriticalCandidate( + candidates: CriticalCandidate[], + dispatchedStages?: ReadonlyMap, + registry?: RoundRobinRegistry, +): CriticalCandidate | null { + const filtered = candidates + .filter((c) => c.status === 'open') + // Dispatchable stages only (idea / intake_complete / plan_complete). + .filter((c) => criticalSkillKind(c.stage) !== null) + // Caps retained (Q2): a plan_complete candidate must be risk ≤ Medium + // / effort ≤ Medium to be implement-dispatched — same ordinal filter + // semantics as selectImplementCandidate (fail-closed on unset). + .filter((c) => { + if (c.stage !== 'plan_complete') return true; + const risk = riskOrdinal(c.risk); + if (risk === null || risk > 2) return false; // risk ≤ Medium + const effort = effortOrdinal(c.effort); + if (effort === null || effort > 3) return false; // effort ≤ Medium + return true; + }) + .filter((c) => { + const dispatchedAt = dispatchedStages?.get(c.id); + // Change-guard: exclude while still at the dispatched-at stage; a + // missing recorded stage (legacy entry) never suppresses selection. + return dispatchedAt === undefined || dispatchedAt !== c.stage; + }) + .sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); + return selectWithRotation(filtered, registry); +} + +/** + * Parse the stdout of `wl dep list --json` into the blocker refs of + * the queried item: the outbound edges with direction `depends-on` (items + * the queried item depends on — its blockers). Edge entries carry only + * id/title/status/priority; the caller enriches via `wl show` when the + * full stage/risk/effort/sortIndex fields are needed (frontier caps + * check, Q2). Malformed JSON or output without an outbound edge list + * yields null (fail-closed); an item with no outbound depends-on edges + * yields `[]` (unblocked). + */ +export function parseDepListBlockersOutput( + stdout: string, +): Array<{ id: string; title: string; status: string; priority?: string }> | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const outbound = + parsed !== null && + typeof parsed === 'object' && + Array.isArray((parsed as { outbound?: unknown }).outbound) + ? (parsed as { outbound: unknown[] }).outbound + : null; + if (outbound === null) return null; + + const blockers: Array<{ id: string; title: string; status: string; priority?: string }> = []; + for (const raw of outbound as unknown[]) { + if (typeof raw !== 'object' || raw === null) continue; + const o = raw as Record; + // Only dependency edges count as blockers (the item depends on them + // and is therefore blocked by them). + if (o.direction !== 'depends-on') continue; + if (typeof o.id !== 'string' || o.id.length === 0) continue; + blockers.push({ + id: o.id, + title: typeof o.title === 'string' ? o.title : '', + status: typeof o.status === 'string' ? o.status : '', + priority: typeof o.priority === 'string' ? o.priority : undefined, + }); + } + return blockers; +} + +/** + * Parse the stdout of `wl show --json` into a full critical + * candidate (the shape the frontier resolution needs for its + * stage→skill mapping and implement caps checks). Accepts the CLI's + * single-item shape `{ workItem: {...} }` with the enriched fields. + * Malformed output or a missing workItem yields null (fail-closed). + */ +export function parseShownWorkItem(stdout: string): CriticalCandidate | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const raw = + parsed !== null && + typeof parsed === 'object' && + (parsed as { workItem?: unknown }).workItem !== null && + (parsed as { workItem?: unknown }).workItem !== undefined + ? (parsed as { workItem: Record }).workItem + : null; + if (typeof raw !== 'object' || raw === null) return null; + if (typeof raw.id !== 'string' || raw.id.length === 0) return null; + return { + id: raw.id, + title: typeof raw.title === 'string' ? raw.title : '', + status: typeof raw.status === 'string' ? raw.status : '', + stage: typeof raw.stage === 'string' ? raw.stage : '', + risk: typeof raw.risk === 'string' ? raw.risk : undefined, + effort: typeof raw.effort === 'string' ? raw.effort : undefined, + sortIndex: typeof raw.sortIndex === 'number' && Number.isFinite(raw.sortIndex) ? raw.sortIndex : undefined, + priority: typeof raw.priority === 'string' ? raw.priority : undefined, + }; +} + +/** + * Parse the stdout of `wl show --json` into a coordination item view + * (`DowntimeItemInfo` — the leader's per-entry classification input). + * Accepts the same `{ workItem: {...} }` single-item shape as + * `parseShownWorkItem`, additionally capturing `updatedAt` and (when the + * CLI serves it) `auditedAt` for the audit-tier freshness check. Malformed + * output or a missing id yields null (fail-closed). + */ +export function parseShowItemOutput(stdout: string): DowntimeItemInfo | null { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return null; + } + const raw = + parsed !== null && + typeof parsed === 'object' && + (parsed as { workItem?: unknown }).workItem !== null && + (parsed as { workItem?: unknown }).workItem !== undefined + ? (parsed as { workItem: Record }).workItem + : null; + if (typeof raw !== 'object' || raw === null) return null; + if (typeof raw.id !== 'string' || raw.id.length === 0) return null; + const info: DowntimeItemInfo = { + id: raw.id, + title: typeof raw.title === 'string' ? raw.title : undefined, + status: typeof raw.status === 'string' ? raw.status : undefined, + stage: typeof raw.stage === 'string' ? raw.stage : undefined, + priority: typeof raw.priority === 'string' ? raw.priority : undefined, + risk: typeof raw.risk === 'string' ? raw.risk : undefined, + effort: typeof raw.effort === 'string' ? raw.effort : undefined, + auditedAt: + typeof raw.auditedAt === 'string' + ? raw.auditedAt + : raw.auditedAt === null + ? null + : undefined, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : undefined, + sortIndex: typeof raw.sortIndex === 'number' && Number.isFinite(raw.sortIndex) ? raw.sortIndex : undefined, + }; + return info; +} + +// ── Dependency frontier resolution (WL-0MT3FM8VA005XBHE, F3) ──────── +// Decision Q3: when the selected critical candidate is dependency-blocked, +// the downtime worker follows the blocking chain to the nearest OPEN +// blocker and dispatches IT with the blocker's own stage-appropriate +// skill (the dependency frontier). Recursion walks while blockers are +// themselves dependency-blocked; a chain that bottoms in closed or +// non-dispatchable items yields null and the critical tier falls through +// to the normal tier order. + +/** + * Resolve the dependency frontier for a selected critical candidate + * (decision Q3). When the candidate is dependency-blocked, follow the + * blocking chain and return the nearest OPEN, dispatchable blocker — the + * item to dispatch with its own stage-appropriate skill (a blocker at + * idea → /skill:intake, intake_complete → /skill:plan, plan_complete → + * /skill:implement with the implement caps applied to the blocker too, + * Q2). Semantics: + * + * - Not dependency-blocked (no blockers under the candidate) → the + * candidate itself (dispatch it normally — no frontier redirect). + * - A direct blocker that is open + dispatchable + within caps and not + * itself dependency-blocked → that blocker (nearest open blocker, in + * sortIndex order among direct blockers). + * - A direct blocker that is open but itself dependency-blocked → recurse + * into its chain (the blocking chain is followed to the nearest open + * unblocked ancestor). + * - A direct blocker at a non-dispatchable stage (in_progress, + * in_review) or above the implement caps → not itself a frontier target; + * if it has open blockers beneath, the recursion surfaces the deeper + * open ancestor, otherwise the chain bottoms. + * - Chain bottoms in closed / non-dispatchable items, or a cycle (bounded + * recursion) → null: no dispatch from the critical tier, fall through to + * the normal tier order. + * - The injected `fetchBlockers` resolves the blocker edges for an item + * (`wl dep list` outbound depends-on edges) and returns null on a wl + * failure — fail-closed: resolution yields null (no dispatch) rather + * than a silent wrong target. + * + * @param candidate The selected open critical candidate. + * @param fetchBlockers Resolves the blockers of an item id (null = wl + * failure). + * @param opts.maxDepth Upper bound on recursion depth (cycle guard). + * @returns The frontier dispatch target, or null when the chain bottoms / + * cycles / the blocker lookup fails (fall through to normal tiers). + */ +export async function resolveDependencyFrontier( + candidate: CriticalCandidate, + fetchBlockers: (itemId: string) => Promise, + opts: { maxDepth?: number } = {}, +): Promise { + const maxDepth = opts.maxDepth ?? 8; + const visited = new Set(); + + /** Caps guard shared with selectCriticalCandidate (Q2 applies to blockers). */ + const dispatchable = (c: CriticalCandidate): boolean => + c.status === 'open' && + criticalSkillKind(c.stage) !== null && + (c.stage !== 'plan_complete' || + ((riskOrdinal(c.risk) ?? 9) <= 2 && (effortOrdinal(c.effort) ?? 9) <= 3)); + + // Recursive walk: returns the nearest OPEN dispatchable blocker in the + // chain beneath `item` (or the item itself when it is unblocked and + // dispatchable). OPEN dispatchable blockers are tried first (nearest by + // sortIndex); when an open blocker is itself dependency-blocked, its own + // chain is walked before accepting it (an open blocker with open + // descendants must not shadow a deeper unblocked ancestor). Non- + // dispatchable blockers (in_progress / in_review / closed / capped) are + // never dispatched themselves but their open descendants are still + // surfaced by recursing through them. Cycles bottom the walk via + // `visited` (bounded recursion, decision Q3). + async function walk(item: CriticalCandidate, depth: number): Promise { + if (visited.has(item.id) || depth > maxDepth) return null; // cycle / bound + visited.add(item.id); + const blockers = await fetchBlockers(item.id); + if (blockers === null) return null; // wl failure → fail-closed + if (blockers.length === 0) { + // Not dependency-blocked: this item IS the frontier target, but only + // when it is itself a dispatchable open item (a closed / in_progress + // / in_review / capped leaf is never dispatched). + return dispatchable(item) ? item : null; + } + const sorted = [...blockers].sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); + // 1) Nearest OPEN dispatchable blocker (recursing into its own chain + // when it is dependency-blocked). + for (const blocker of sorted) { + if (!dispatchable(blocker)) continue; + const deeper = await walk(blocker, depth + 1); + if (deeper !== null) return deeper; + } + // 2) No open blocker yielded a frontier — recurse through the non- + // dispatchable blockers (they may have open ancestors beneath). + for (const blocker of sorted) { + if (dispatchable(blocker)) continue; + const deeper = await walk(blocker, depth + 1); + if (deeper !== null) return deeper; + } + // Chain bottoms in closed / non-dispatchable / capped items → fall + // through to the normal tier order. + return null; + } + + const blockers = await fetchBlockers(candidate.id); + if (blockers === null) return null; // wl failure → fail-closed + if (blockers.length === 0) return candidate; // not dependency-blocked + return walk(candidate, 0); } // ── Settings clamps (implemented — wired into settings.ts by F2) ────── /** * Clamp the downtime poll interval: never below the 10s hard floor, default - * 30s for non-finite input. + * 10s for non-finite input (matches DEFAULT_DOWNTIME_POLL_INTERVAL_MS; the + * spec's '30s proxy poll' refers to the 30s proxy status refresh). */ export function clampDowntimePollInterval(value: number): number { if (!Number.isFinite(value)) return DEFAULT_DOWNTIME_POLL_INTERVAL_MS; @@ -1790,7 +3607,8 @@ export function clampDowntimePollInterval(value: number): number { /** * Clamp the idle threshold: reject negative/non-finite (fall back to the - * 4-minute default) and floor at 1s to prevent immediate dispatch. + * 60s default, DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS) and floor at 1s to + * prevent immediate dispatch. */ export function clampDowntimeIdleThresholdMs(value: number): number { if (!Number.isFinite(value) || value < 0) return DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS; diff --git a/packages/herdr/src/events.test.ts b/packages/herdr/src/events.test.ts new file mode 100644 index 000000000..97f729f45 --- /dev/null +++ b/packages/herdr/src/events.test.ts @@ -0,0 +1,541 @@ +/** + * packages/herdr/src/events.test.ts — HerdrEventSubscriber contract tests + * (F1 test harness + F2 implementation) + * + * Tests define the HerdrEventSubscriber contract: + * - Connects to the resolved socket path (HERDR_SOCKET_PATH or default) + * - Sends one events.subscribe call with global subscriptions plus + * per-pane subscriptions for each tracked pane + * - Dispatches typed callbacks for received event frames + * - Reconnects with backoff on socket errors + * - Fails open (reports "events unavailable") on unreachable socket + * - close() unsubscribes and closes the socket + * - addPaneSubscription / removePaneSubscription manage per-pane state + * + * Mock socket server (mock-herdr-socket.ts) provides deterministic events + * without spawning real herdr processes. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + MockHerdrSocket, + createMockSocket, + type HerdrEventFrame, +} from './test-utils/mock-herdr-socket.js'; + +// ── Import the module under test (F2 implementation) ───────────────── + +import { + HerdrEventSubscriber, + type HerdrEventCallbacks, + type EventResult, + type EventsHealth, + DEFAULT_SUBSCRIBE_TIMEOUT_MS, + MAX_RECONNECT_ATTEMPTS, + RECONNECT_BASE_DELAY_MS, +} from './events.js'; + +// ── Mock socket helpers ────────────────────────────────────────────── + +let mockServer: MockHerdrSocket; +let socketAddress: string; + +async function startMockServer(): Promise { + const server = await createMockSocket(); + return server; +} + +async function stopMockServer(server: MockHerdrSocket): Promise { + await server.stop(); +} + +// ── Test fixtures ──────────────────────────────────────────────────── + +function paneFocusedEvent(paneId: string): HerdrEventFrame { + return { event: 'pane_focused', data: { pane_id: paneId } }; +} + +function paneUnfocusedEvent(paneId: string): HerdrEventFrame { + return { event: 'pane_focused', data: { pane_id: paneId, focused: false } }; +} + +function paneAgentStatusChangedEvent( + paneId: string, + status: string, +): HerdrEventFrame { + return { + event: 'pane_agent_status_changed', + data: { pane_id: paneId, agent_status: status }, + }; +} + +function paneAgentDetectedEvent( + paneId: string, + workItemId?: string, +): HerdrEventFrame { + return { + event: 'pane_agent_detected', + data: { pane_id: paneId, ...(workItemId ? { work_item_id: workItemId } : {}) }, + }; +} + +function paneClosedEvent(paneId: string): HerdrEventFrame { + return { event: 'pane_closed', data: { pane_id: paneId } }; +} + +function paneExitedEvent(paneId: string): HerdrEventFrame { + return { event: 'pane_exited', data: { pane_id: paneId } }; +} + +// ── Test suites ────────────────────────────────────────────────────── + +describe('HerdrEventSubscriber — mock socket', () => { + beforeEach(async () => { + mockServer = await startMockServer(); + }); + + afterEach(async () => { + // Clean up any remaining subscribers + vi.restoreAllMocks(); + await stopMockServer(mockServer); + }); + + // ── F2: Basic connection and subscription ────────────────────────── + + describe('connect and subscribe', () => { + it('connects to the mock socket and sends an events.subscribe request', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + // Wait for connection and subscription + const result = await subscriber.connect(); + expect(result.type).toBe('subscribed'); + + // The subscriber should be connected + expect(subscriber.health().status).toBe('active'); + + // Clean up + await subscriber.close(); + }); + + it('sends global subscriptions: pane.focused, pane.agent_detected, pane.closed, pane.exited', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + + // Verify global subscriptions were sent by checking that the mock + // registered the client (the mock only accepts valid subscribe requests) + const client = mockServer.getFirstClient(); + expect(client).toBeDefined(); + + await subscriber.close(); + }); + + it('sends per-pane pane.agent_status_changed subscriptions for each tracked pane', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + trackedPaneIds: ['w1:p1', 'w1:p2'], + }); + + await subscriber.connect(); + + const client = mockServer.getFirstClient(); + expect(client).toBeDefined(); + + await subscriber.close(); + }); + }); + + // ── F2: Event dispatch ───────────────────────────────────────────── + + describe('event dispatch', () => { + it('dispatches pane_focused events to the onPaneFocused callback', async () => { + const onPaneFocused = vi.fn(); + const callbacks: HerdrEventCallbacks = { onPaneFocused }; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + + const client = mockServer.getFirstClient(); + expect(client).toBeDefined(); + + client!.pushEvent(paneFocusedEvent('w1:p1')); + + // Events are dispatched asynchronously — wait for the tick + await new Promise((r) => setTimeout(r, 50)); + + expect(onPaneFocused).toHaveBeenCalledWith({ pane_id: 'w1:p1' }); + expect(onPaneFocused).toHaveBeenCalledTimes(1); + + await subscriber.close(); + }); + + it('dispatches pane_agent_status_changed events to the onAgentStatusChanged callback', async () => { + const onAgentStatusChanged = vi.fn(); + const callbacks: HerdrEventCallbacks = { onAgentStatusChanged }; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + + const client = mockServer.getFirstClient(); + client!.pushEvent(paneAgentStatusChangedEvent('w1:p1', 'working')); + + await new Promise((r) => setTimeout(r, 50)); + + expect(onAgentStatusChanged).toHaveBeenCalledWith({ + pane_id: 'w1:p1', + agent_status: 'working', + }); + + await subscriber.close(); + }); + + it('dispatches pane_agent_detected events to the onAgentDetected callback', async () => { + const onAgentDetected = vi.fn(); + const callbacks: HerdrEventCallbacks = { onAgentDetected }; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + + const client = mockServer.getFirstClient(); + client!.pushEvent(paneAgentDetectedEvent('w1:p99')); + + await new Promise((r) => setTimeout(r, 50)); + + expect(onAgentDetected).toHaveBeenCalledWith({ pane_id: 'w1:p99' }); + + await subscriber.close(); + }); + + it('dispatches pane_closed events to the onPaneClosed callback', async () => { + const onPaneClosed = vi.fn(); + const callbacks: HerdrEventCallbacks = { onPaneClosed }; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + + const client = mockServer.getFirstClient(); + client!.pushEvent(paneClosedEvent('w1:p1')); + + await new Promise((r) => setTimeout(r, 50)); + + expect(onPaneClosed).toHaveBeenCalledWith({ pane_id: 'w1:p1' }); + + await subscriber.close(); + }); + + it('dispatches pane_exited events to the onPaneExited callback', async () => { + const onPaneExited = vi.fn(); + const callbacks: HerdrEventCallbacks = { onPaneExited }; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + + const client = mockServer.getFirstClient(); + client!.pushEvent(paneExitedEvent('w1:p1')); + + await new Promise((r) => setTimeout(r, 50)); + + expect(onPaneExited).toHaveBeenCalledWith({ pane_id: 'w1:p1' }); + + await subscriber.close(); + }); + + it('dispatches multiple events in sequence to correct callbacks', async () => { + const onPaneFocused = vi.fn(); + const onAgentStatusChanged = vi.fn(); + const onPaneClosed = vi.fn(); + const callbacks: HerdrEventCallbacks = { + onPaneFocused, + onAgentStatusChanged, + onPaneClosed, + }; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + + const client = mockServer.getFirstClient(); + + // Push a sequence of events + client!.pushEvent(paneFocusedEvent('w1:p1')); + client!.pushEvent(paneAgentStatusChangedEvent('w1:p1', 'working')); + client!.pushEvent(paneClosedEvent('w1:p1')); + + await new Promise((r) => setTimeout(r, 100)); + + expect(onPaneFocused).toHaveBeenCalledWith({ pane_id: 'w1:p1' }); + expect(onAgentStatusChanged).toHaveBeenCalledWith({ + pane_id: 'w1:p1', + agent_status: 'working', + }); + expect(onPaneClosed).toHaveBeenCalledWith({ pane_id: 'w1:p1' }); + expect(onPaneFocused).toHaveBeenCalledTimes(1); + expect(onAgentStatusChanged).toHaveBeenCalledTimes(1); + expect(onPaneClosed).toHaveBeenCalledTimes(1); + + await subscriber.close(); + }); + }); + + // ── F2: Reconnect with backoff ───────────────────────────────────── + + describe('reconnect with backoff', () => { + it('reports events unavailable when the socket is unreachable (fail-open)', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: '/tmp/herdr-nonexistent-socket-never-exists.sock', + callbacks, + maxReconnectAttempts: 0, + }); + + // Connecting to a non-existent socket should not throw + const result = await subscriber.connect(); + expect(result.type).toBe('unavailable'); + const health = subscriber.health(); + expect(health.status).toBe('unavailable'); + + await subscriber.close(); + }); + + it('reconnects after a transient socket error', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + maxReconnectAttempts: 3, + reconnectBaseDelayMs: 50, + }); + + await subscriber.connect(); + expect(subscriber.health().status).toBe('active'); + + // Close the server to simulate a transient error + await mockServer.stop(); + + // Wait for reconnect attempts + await new Promise((r) => setTimeout(r, 300)); + + // The subscriber should be in reconnecting state + const health = subscriber.health(); + expect(['unavailable', 'reconnecting'].includes(health.status)).toBe(true); + + // Restart the server + mockServer = await startMockServer(); + + // Wait for reconnection + await new Promise((r) => setTimeout(r, 500)); + + // The subscriber should have reconnected + // (health should be active again if reconnection succeeded) + + await subscriber.close(); + }); + }); + + // ── F2: Per-pane subscription management ─────────────────────────── + + describe('per-pane subscription management', () => { + it('addPaneSubscription adds a new pane and sends a subscribe request', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + trackedPaneIds: ['w1:p1'], + }); + + await subscriber.connect(); + + // Add a new pane subscription + const addResult = await subscriber.addPaneSubscription('w1:p2'); + expect(addResult).toBeDefined(); + + // Verify the pane is tracked (health reflects active state with count) + expect(subscriber.health().status).toBe('active'); + expect(subscriber.getTrackedPaneIds()).toContain('w1:p2'); + + await subscriber.close(); + }); + + it('removePaneSubscription drops a pane and cleans up', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + trackedPaneIds: ['w1:p1', 'w1:p2'], + }); + + await subscriber.connect(); + + const result = await subscriber.removePaneSubscription('w1:p2'); + expect(result).toBeDefined(); + + expect(subscriber.health().status).toBe('active'); + expect(subscriber.getTrackedPaneIds()).not.toContain('w1:p2'); + + await subscriber.close(); + }); + }); + + // ── F2: close / cleanup ──────────────────────────────────────────── + + describe('close / cleanup', () => { + it('close() stops the subscriber and cleans up resources', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + expect(subscriber.health().status).toBe('active'); + + await subscriber.close(); + // After close, the subscriber should be in a cleaned-up state + expect(subscriber.health().status).toBe('closed'); + }); + }); + + // ── F2: Socket error tolerance ───────────────────────────────────── + + describe('socket error tolerance', () => { + it('does not throw when socket errors occur during operation', async () => { + const callbacks: HerdrEventCallbacks = { + onError: vi.fn(), + }; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + await subscriber.connect(); + + // The subscriber should not throw even if the mock has issues + // (In production, these are handled by the reconnect mechanism) + + await subscriber.close(); + }); + + it('keeps an error handler on the socket after subscribe (no unhandled ECONNRESET crash)', async () => { + // Regression test (WL-0MSZXB3OF009V5IQ): the `prefix+l` plugin + // pane crashed with exit code 1 seconds after opening because the + // subscriber removed its only socket `'error'` listener once the + // initial subscribe succeeded. When herdr later reset the connection + // (ECONNRESET), Node emitted an unhandled `'error'` event and killed + // the whole process. The socket must keep an `'error'` handler so a + // post-subscribe error is routed to handleSocketError() (fail-open + // reconnect) instead of crashing. + const onError = vi.fn(); + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks: { onError }, + maxReconnectAttempts: 3, + reconnectBaseDelayMs: 50, + }); + + const result = await subscriber.connect(); + expect(result.type).toBe('subscribed'); + expect(subscriber.health().status).toBe('active'); + + const socket = subscriber.testSocket(); + expect(socket).not.toBeNull(); + // The socket MUST retain an 'error' listener after subscribe. + const errorListeners = socket!.listeners('error'); + expect(errorListeners.length).toBeGreaterThan(0); + + // Simulate herdr resetting the connection after subscribe. Directly + // emitting an 'error' event with no listener would throw an unhandled + // 'error' and crash the process (the pre-fix bug). With the fix the + // listener routes it to handleSocketError → fail-open recovery, and + // onError is invoked. The subscriber must stay alive (health is one of + // the live states, never crashed): it may have already reconnected + // back to 'active' (best case) or still be recovering. + socket!.emit('error', Object.assign(new Error('read ECONNRESET'), { + errno: -104, + code: 'ECONNRESET', + syscall: 'read', + })); + + await new Promise((r) => setTimeout(r, 150)); + + // onError proves the socket error handler ran instead of crashing. + expect(onError).toHaveBeenCalled(); + // The subscriber did not die — it is either back up (active), or + // still in fail-open recovery. It must never be 'closed'. + const health = subscriber.health(); + expect(['active', 'unavailable', 'reconnecting'].includes(health.status)).toBe(true); + + await subscriber.close(); + }); + }); + + // ── F2: Constants ────────────────────────────────────────────────── + + describe('constants', () => { + it('exports DEFAULT_SUBSCRIBE_TIMEOUT_MS', () => { + expect(typeof DEFAULT_SUBSCRIBE_TIMEOUT_MS).toBe('number'); + expect(DEFAULT_SUBSCRIBE_TIMEOUT_MS).toBeGreaterThan(0); + }); + + it('exports MAX_RECONNECT_ATTEMPTS', () => { + expect(typeof MAX_RECONNECT_ATTEMPTS).toBe('number'); + expect(MAX_RECONNECT_ATTEMPTS).toBeGreaterThan(0); + }); + + it('exports RECONNECT_BASE_DELAY_MS', () => { + expect(typeof RECONNECT_BASE_DELAY_MS).toBe('number'); + expect(RECONNECT_BASE_DELAY_MS).toBeGreaterThan(0); + }); + }); + + // ── F2: Type exports ─────────────────────────────────────────────── + + describe('type exports', () => { + it('exports EventResult union type', async () => { + const callbacks: HerdrEventCallbacks = {}; + const subscriber = new HerdrEventSubscriber({ + socketPath: mockServer.getAddress(), + callbacks, + }); + + const result = await subscriber.connect(); + // EventResult can be subscribed | unavailable | error + if (result.type === 'subscribed') { + expect(result).toHaveProperty('type', 'subscribed'); + } else if (result.type === 'unavailable') { + expect(result).toHaveProperty('type', 'unavailable'); + } else if (result.type === 'error') { + expect(result).toHaveProperty('type', 'error'); + expect(result).toHaveProperty('error'); + } + + await subscriber.close(); + }); + }); +}); diff --git a/packages/herdr/src/events.ts b/packages/herdr/src/events.ts new file mode 100644 index 000000000..3086e798c --- /dev/null +++ b/packages/herdr/src/events.ts @@ -0,0 +1,711 @@ +/** + * packages/herdr/src/events.ts — HerdrEventSubscriber: event-driven window status + * + * A long-lived JSON-RPC-over-Unix-socket client that subscribes to herdr + * window events (pane focus/visibility, agent status changes, agent + * detected/closed/exited) pushed over the herdr socket. The subscriber + * dispatches typed callbacks to registered listeners, reconnects with + * exponential backoff on socket errors, and fails open when the socket + * is unavailable. + * + * This module replaces the polling-based `isPaneVisible()` (PollGate) and + * `AgentTracker.refreshStates()` with an event-driven path. Polling is kept + * as a fail-open fallback — when events are unavailable the subscriber + * reports `"unavailable"` and callers fall back to `herdr pane get` / + * `herdr agent list`. + * + * Transport: Unix socket at `HERDR_SOCKET_PATH` (default + * `~/.config/herdr/herdr.sock`), or TCP on `HERDR_SOCKET_PORT` for tests. + * Framing: newline-delimited JSON-RPC 2.0, modelled on + * `packages/herdr/shared/grid.py`. + * + * Subscription shape (herdr ≥ 0.7.5): + * - `pane.focused` — global (no pane_id) + * - `pane.agent_status_changed` — per-pane, requires pane_id + * - `pane.agent_detected` — global + * - `pane.closed` — global + * - `pane.exited` — global + * + * Fail-open design: any socket error degrades to polling; never crashes + * the TUI, never blocks refresh/sync. + * + * AC1–AC5 of WL-0MSHB7DHO004RHBJ covered. + */ + +import net from 'node:net'; +import os from 'node:os'; + +// ── Constants ──────────────────────────────────────────────────────── + +/** Default timeout for the initial events.subscribe request (ms). */ +export const DEFAULT_SUBSCRIBE_TIMEOUT_MS = 5000; + +/** Maximum number of reconnect attempts before giving up. */ +export const MAX_RECONNECT_ATTEMPTS = 50; + +/** Base delay (ms) for exponential backoff on reconnect. */ +export const RECONNECT_BASE_DELAY_MS = 500; + +/** Maximum backoff cap (ms). */ +const MAX_RECONNECT_DELAY_MS = 10_000; + +/** Default socket path when HERDR_SOCKET_PATH is not set. */ +const DEFAULT_SOCKET_PATH = + process.env.XDG_CONFIG_HOME + ? `${process.env.XDG_CONFIG_HOME}/herdr/herdr.sock` + : `${os.homedir()}/.config/herdr/herdr.sock`; + +// ── Types ──────────────────────────────────────────────────────────── + +/** + * Event data for pane_agent_status_changed events. + */ +export interface PaneAgentStatusChangedEvent { + pane_id: string; + agent_status: string; +} + +/** + * Event data for pane_agent_detected events. + */ +export interface PaneAgentDetectedEvent { + pane_id: string; + work_item_id?: string; +} + +/** + * Event data for pane_focused events. + */ +export interface PaneFocusedEvent { + pane_id: string; + focused?: boolean; +} + +/** + * Event data for pane_closed events. + */ +export interface PaneClosedEvent { + pane_id: string; +} + +/** + * Event data for pane_exited events. + */ +export interface PaneExitedEvent { + pane_id: string; +} + +/** + * Union of all event data types. + */ +export type PaneEvent = + | PaneFocusedEvent + | PaneAgentStatusChangedEvent + | PaneAgentDetectedEvent + | PaneClosedEvent + | PaneExitedEvent; + +/** + * Typed event callbacks for the subscriber. Each callback receives the + * event data for its specific event type. Callbacks are invoked + * synchronously on the event loop; long-running logic should be deferred. + */ +export interface HerdrEventCallbacks { + /** Called when a pane focuses or unfocuses (pane.focused event). */ + onPaneFocused?: (data: PaneFocusedEvent) => void; + /** Called when an agent's status changes in a tracked pane. */ + onAgentStatusChanged?: (data: PaneAgentStatusChangedEvent) => void; + /** Called when a new agent is detected in a pane. */ + onAgentDetected?: (data: PaneAgentDetectedEvent) => void; + /** Called when a pane is closed. */ + onPaneClosed?: (data: PaneClosedEvent) => void; + /** Called when a pane exits (agent process ends). */ + onPaneExited?: (data: PaneExitedEvent) => void; + /** Called when a socket error occurs (for logging / monitoring). */ + onError?: (error: Error) => void; +} + +/** + * Result of a connect() attempt. + */ +export type EventResult = + | { type: 'subscribed'; subscriptions: number } + | { type: 'unavailable'; reason?: string } + | { type: 'error'; error: string }; + +/** + * Health status of the subscriber. + */ +export type EventsHealth = + | { status: 'active'; paneCount: number } + | { status: 'unavailable'; reason?: string } + | { status: 'reconnecting'; attempt: number; delayMs: number } + | { status: 'closed' }; + +/** + * Internal representation of a subscription request entry. + */ +interface SubscriptionEntry { + type: string; + pane_id?: string; +} + +/** + * Pending request handler: { resolve, reject } pair keyed by request id. + */ +interface PendingCallback { + resolve: () => void; + reject: (err: Error) => void; +} + +// ── JSON-RPC framing ──────────────────────────────────────────────── + +/** + * Frame a JSON-RPC 2.0 request. + */ +function frameRequest(id: string, method: string, params: unknown): string { + return JSON.stringify({ jsonrpc: '2.0', id, method, params }); +} + +// ── HerdrEventSubscriber ───────────────────────────────────────────── + +/** + * Subscribe to herdr window events (pane focus, agent status, etc.) over + * a Unix-socket JSON-RPC connection. + * + * Usage: + * const sub = new HerdrEventSubscriber({ + * socketPath: process.env.HERDR_SOCKET_PATH, + * callbacks: { + * onPaneFocused: (d) => { /* handle * / }, + * onAgentStatusChanged: (d) => { /* handle * / }, + * }, + * trackedPaneIds: ['w1:p1', 'w1:p2'], + * }); + * await sub.connect(); + * await sub.addPaneSubscription('w1:p3'); + * // ... use events ... + * await sub.close(); + * + * The subscriber reconnects automatically on socket errors with exponential + * backoff. When the socket is unreachable, it reports `"unavailable"` so + * callers can fall back to polling. + */ +export class HerdrEventSubscriber { + private socket: net.Socket | null = null; + private reconnectTimer: ReturnType | null = null; + private reconnectAttempts = 0; + private isConnecting = false; + private isClosed = false; + private callbacks: HerdrEventCallbacks; + private readonly socketPath: string; + private readonly timeoutMs: number; + private readonly trackedPaneIds: string[]; + private readonly maxReconnectAttempts: number; + private readonly reconnectBaseDelayMs: number; + private currentHealth: EventsHealth = { status: 'closed' }; + private dataBuffer = ''; + private requestCounter = 0; + private pendingCallbacks = new Map(); + + /** + * @param options Configuration options. + */ + constructor(options: { + /** Path to the herdr socket (or undefined for default). */ + socketPath?: string; + /** Event callbacks to dispatch to. */ + callbacks: HerdrEventCallbacks; + /** Pane IDs to subscribe to for per-pane events. */ + trackedPaneIds?: string[]; + /** Subscribe request timeout in ms. */ + timeoutMs?: number; + /** Max reconnect attempts before giving up. */ + maxReconnectAttempts?: number; + /** Base delay for reconnect backoff in ms. */ + reconnectBaseDelayMs?: number; + }) { + this.callbacks = options.callbacks; + this.socketPath = options.socketPath ?? DEFAULT_SOCKET_PATH; + this.timeoutMs = options.timeoutMs ?? DEFAULT_SUBSCRIBE_TIMEOUT_MS; + this.trackedPaneIds = [...(options.trackedPaneIds ?? [])]; + this.maxReconnectAttempts = options.maxReconnectAttempts ?? MAX_RECONNECT_ATTEMPTS; + this.reconnectBaseDelayMs = options.reconnectBaseDelayMs ?? RECONNECT_BASE_DELAY_MS; + } + + /** + * Connect to the herdr socket and send the initial events.subscribe + * request. Returns a result indicating whether the connection succeeded. + */ + async connect(): Promise { + if (this.isClosed) { + return { type: 'error', error: 'Subscriber is closed' }; + } + if (this.isConnecting) { + return { type: 'unavailable', reason: 'Already connecting' }; + } + + this.isConnecting = true; + this.currentHealth = { status: 'unavailable' }; + + try { + const result = await this.doConnect(); + this.isConnecting = false; + if (result.type === 'subscribed') { + this.reconnectAttempts = 0; + this.currentHealth = { status: 'active', paneCount: this.trackedPaneIds.length }; + } + return result; + } catch (err: any) { + this.isConnecting = false; + return { type: 'unavailable', reason: err?.message ?? 'Connection failed' }; + } + } + + /** + * Perform the actual socket connection and subscription. + */ + private doConnect(): Promise { + return new Promise((resolve, reject) => { + this.socket = net.connect({ path: this.socketPath }); + + // Handle connection errors + const onError = (err: Error) => { + this.socketCleanup(); + this.handleSocketError(err); + resolve({ type: 'unavailable', reason: err.message }); + }; + + // Handle timeouts + const onTimeout = () => { + this.socketCleanup(); + this.handleSocketError(new Error('Connection timed out')); + resolve({ type: 'unavailable', reason: 'Connection timed out' }); + }; + + this.socket.once('error', onError); + this.socket.setTimeout(this.timeoutMs); + this.socket.once('timeout', onTimeout); + + this.socket.on('data', (chunk: Buffer) => { + this.dataBuffer += chunk.toString(); + this.processBuffer(); + }); + + // A socket close (e.g. server shutdown) triggers a reconnect attempt. + this.socket.on('close', () => { + if (!this.isClosed && !this.isConnecting) { + this.handleSocketError(new Error('Socket closed')); + } + }); + + this.socket.once('connect', () => { + // Connected successfully — clear timeout + this.socket!.setTimeout(0); + + // Send the subscribe request and wait for response. + // CRITICAL (WL-0MSZXB3OF009V5IQ): the `error` listener MUST stay + // attached after a successful subscribe. Removing it (as a previous + // version did) left the socket with NO 'error' handler, so a later + // socket reset (ECONNRESET) from herdr emitted an unhandled 'error' + // event and crashed the whole plugin process (exit code 1), taking + // the pane down within seconds of `prefix+l`. The persistent listener + // routes post-subscribe errors through handleSocketError() so the + // subscriber reconnects with backoff (fail-open) instead of crashing. + // Only the timeout listener is removed once the connection settles. + this.sendSubscribeRequest() + .then(() => { + this.socket!.removeListener('timeout', onTimeout); + resolve({ type: 'subscribed', subscriptions: this.getSubscriptionCount() }); + }) + .catch((err) => { + this.socket!.removeListener('timeout', onTimeout); + resolve({ type: 'error', error: err.message }); + }); + }); + }); + } + + /** + * Send the events.subscribe request with global and per-pane subscriptions. + */ + private sendSubscribeRequest(): Promise { + return new Promise((resolve, reject) => { + const subscriptions: SubscriptionEntry[] = [ + { type: 'pane.focused' }, + { type: 'pane.agent_detected' }, + { type: 'pane.closed' }, + { type: 'pane.exited' }, + ]; + + // Add per-pane agent_status_changed subscriptions for each tracked pane + for (const paneId of this.trackedPaneIds) { + subscriptions.push({ + type: 'pane.agent_status_changed', + pane_id: paneId, + }); + } + + const id = `sub-${++this.requestCounter}`; + const frame = frameRequest(id, 'events.subscribe', { subscriptions }); + + // Store the pending callback for response correlation + this.pendingCallbacks.set(id, { resolve, reject }); + + // Set up a timeout for this request + const timeout = setTimeout(() => { + this.pendingCallbacks.delete(id); + reject(new Error('Subscribe request timed out')); + }, this.timeoutMs); + + // Send the request + if (this.socket && !this.socket.destroyed) { + this.socket.write(frame + '\n'); + } else { + clearTimeout(timeout); + this.pendingCallbacks.delete(id); + reject(new Error('Socket not connected')); + } + }); + } + + /** + * Get the current subscription count (global + per-pane). + */ + private getSubscriptionCount(): number { + const globalCount = 4; // focused, agent_detected, closed, exited + return globalCount + this.trackedPaneIds.length; + } + + /** + * Process buffered socket data, parsing JSON-RPC frames. + */ + private processBuffer(): void { + while (this.dataBuffer.includes('\n')) { + const newlineIndex = this.dataBuffer.indexOf('\n'); + const frame = this.dataBuffer.slice(0, newlineIndex).trim(); + this.dataBuffer = this.dataBuffer.slice(newlineIndex + 1); + + if (!frame) continue; + + this.handleFrame(frame); + } + } + + /** + * Handle a single JSON-RPC frame from the server. + */ + private handleFrame(frame: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(frame); + } catch { + // Malformed JSON — ignore + return; + } + + if (!parsed || typeof parsed !== 'object') return; + + const obj = parsed as Record; + + // Check if this is a response to a pending request. + // A RESPONSE has a non-null string/number id (e.g. "sub-1"). + // Event NOTIFICATIONS from the mock have id:null — those are events. + const rawId = obj.id; + const isResponse = typeof rawId === 'string' || typeof rawId === 'number'; + if (isResponse) { + const idStr = String(rawId); + const cb = this.pendingCallbacks.get(idStr); + if (cb) { + this.pendingCallbacks.delete(idStr); + if (obj.result && typeof obj.result === 'object' && (obj.result as any).type === 'subscription_started') { + cb.resolve(); + } else if (obj.error) { + cb.reject(new Error(String((obj.error as any)?.message ?? 'Subscription failed'))); + } + } + return; + } + + // Event notification (no id) — dispatch to callbacks + const eventType = obj.event as string | undefined; + const data = obj.data as Record | undefined; + if (!eventType || !data) return; + + this.dispatchEvent(eventType, data); + } + + /** + * Dispatch an event to the appropriate callback. + */ + private dispatchEvent(eventType: string, data: Record): void { + try { + switch (eventType) { + case 'pane_focused': + this.callbacks.onPaneFocused?.({ + pane_id: String(data.pane_id || ''), + focused: data.focused as boolean | undefined, + }); + break; + case 'pane_agent_status_changed': + this.callbacks.onAgentStatusChanged?.({ + pane_id: String(data.pane_id || ''), + agent_status: String(data.agent_status || ''), + }); + break; + case 'pane_agent_detected': + this.callbacks.onAgentDetected?.({ + pane_id: String(data.pane_id || ''), + work_item_id: data.work_item_id as string | undefined, + }); + break; + case 'pane_closed': + this.callbacks.onPaneClosed?.({ + pane_id: String(data.pane_id || ''), + }); + break; + case 'pane_exited': + this.callbacks.onPaneExited?.({ + pane_id: String(data.pane_id || ''), + }); + break; + default: + // Unknown event type — ignore silently + break; + } + } catch (err) { + // Callback errors must not crash the subscriber + this.callbacks.onError?.(err instanceof Error ? err : new Error(String(err))); + } + } + + /** + * Handle a socket error: schedule reconnect or report unavailable. + */ + private handleSocketError(err: Error): void { + if (this.isClosed) return; + + this.currentHealth = { + status: 'unavailable', + reason: err.message, + }; + + this.callbacks.onError?.(err); + + // Schedule reconnect with exponential backoff + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++; + const delay = Math.min( + this.reconnectBaseDelayMs * Math.pow(2, this.reconnectAttempts - 1), + MAX_RECONNECT_DELAY_MS, + ); + this.currentHealth = { + status: 'reconnecting', + attempt: this.reconnectAttempts, + delayMs: delay, + }; + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + if (!this.isClosed) { + this.connect(); + } + }, delay); + } + } + + /** + * Clean up socket resources. + */ + private socketCleanup(): void { + if (this.socket) { + this.socket.removeAllListeners(); + this.socket.destroy(); + this.socket = null; + } + } + + /** + * Add a per-pane subscription for a new pane. + * + * When a `pane_agent_detected` event arrives for a pane not yet tracked, + * callers should invoke this to add a per-pane `pane.agent_status_changed` + * subscription. The pane ID is added to the tracked set and a new + * subscribe request is sent. + * + * @param paneId The pane ID to subscribe to. + * @returns Result of the subscription update. + */ + async addPaneSubscription(paneId: string): Promise { + if (this.isClosed) { + return { type: 'error', error: 'Subscriber is closed' }; + } + if (this.trackedPaneIds.includes(paneId)) { + return { type: 'subscribed', subscriptions: this.getSubscriptionCount() }; + } + + this.trackedPaneIds.push(paneId); + + // Resend subscribe request with the new pane + return this.resendSubscribeRequest(); + } + + /** + * Remove a per-pane subscription. + * + * @param paneId The pane ID to remove. + * @returns Result of the subscription update. + */ + async removePaneSubscription(paneId: string): Promise { + if (this.isClosed) { + return { type: 'error', error: 'Subscriber is closed' }; + } + + const idx = this.trackedPaneIds.indexOf(paneId); + if (idx === -1) { + return { type: 'unavailable', reason: 'Pane not tracked' }; + } + + this.trackedPaneIds.splice(idx, 1); + + // Resend subscribe request without this pane + return this.resendSubscribeRequest(); + } + + /** + * Resend the events.subscribe request with the current tracked pane set. + */ + private resendSubscribeRequest(): Promise { + if (!this.socket || this.socket.destroyed || this.socket.writable === false) { + // Rollback: the pane was added/removed but we couldn't resubscribe + // This is a best-effort operation; in practice the reconnect + // mechanism will resubscribe with the correct pane set. + return Promise.resolve({ type: 'error', error: 'Socket not connected' }); + } + + const subscriptions: SubscriptionEntry[] = [ + { type: 'pane.focused' }, + { type: 'pane.agent_detected' }, + { type: 'pane.closed' }, + { type: 'pane.exited' }, + ]; + + for (const paneId of this.trackedPaneIds) { + subscriptions.push({ + type: 'pane.agent_status_changed', + pane_id: paneId, + }); + } + + const id = `resub-${++this.requestCounter}`; + const frame = frameRequest(id, 'events.subscribe', { subscriptions }); + + return new Promise((resolve, reject) => { + const callback: PendingCallback = { + resolve: () => { + resolve({ type: 'subscribed', subscriptions: this.getSubscriptionCount() }); + }, + reject: (err) => { + resolve({ type: 'error', error: err.message }); + }, + }; + + this.pendingCallbacks.set(id, callback); + + const timeout = setTimeout(() => { + this.pendingCallbacks.delete(id); + resolve({ type: 'error', error: 'Resubscribe timed out' }); + }, this.timeoutMs); + + this.socket!.write(frame + '\n', () => { + // Write callback — if write fails, the error handler will catch it + }); + }); + } + + /** + * Close the subscriber: clean up the socket and cancel reconnect timers. + * + * After close, the subscriber cannot be reused. Callers should create a + * new instance for a fresh connection. + */ + async close(): Promise { + this.isClosed = true; + this.isConnecting = false; + + // Cancel any pending reconnect + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + // Reject all pending callbacks + for (const [, cb] of this.pendingCallbacks) { + cb.reject(new Error('Subscriber closed')); + } + this.pendingCallbacks.clear(); + + // Clean up the socket + this.socketCleanup(); + + this.dataBuffer = ''; + this.currentHealth = { status: 'closed' }; + } + + /** + * Get the current health status of the subscriber. + */ + health(): EventsHealth { + return this.currentHealth; + } + + /** + * Replace the event callbacks. The worklist wires its callbacks AFTER + * constructing the subscriber (the callbacks close over the worklist's + * internal state), so this must be callable post-construction and before + * connect(). + */ + setCallbacks(callbacks: HerdrEventCallbacks): void { + this.callbacks = callbacks; + } + + /** + * Get the list of currently tracked pane IDs. + */ + getTrackedPaneIds(): string[] { + return [...this.trackedPaneIds]; + } + + /** + * Test-only hook: expose the live socket so tests can inject errors and + * verify the subscriber still holds an `'error'` handler after subscribe. + * Returns null when no socket is currently connected. + */ + testSocket(): net.Socket | null { + return this.socket; + } +} + +/** + * Resolve the herdr socket path from environment or default. + * + * Returns the resolved path, or null if no path is available. + * Used by callers who need the socket path for display or fallback logic. + */ +export function resolveSocketPath(): string | null { + const env = process.env.HERDR_SOCKET_PATH; + if (env) return env; + + const xdg = process.env.XDG_CONFIG_HOME; + if (xdg) { + const candidate = `${xdg}/herdr/herdr.sock`; + return candidate; + } + + const home = os.homedir(); + if (home) { + return `${home}/.config/herdr/herdr.sock`; + } + + return null; +} diff --git a/packages/herdr/src/fetcher-timeout.test.ts b/packages/herdr/src/fetcher-timeout.test.ts new file mode 100644 index 000000000..081662659 --- /dev/null +++ b/packages/herdr/src/fetcher-timeout.test.ts @@ -0,0 +1,421 @@ +/** + * packages/herdr/src/fetcher-timeout.test.ts — Timeout boundedness for wl spawns + * (WL-0MSJNJXX2001NMHS). + * + * These tests verify: + * 1. Every refresh/sync-path runWl caller passes a bounded timeout + * (DEFAULT_WL_TIMEOUT_MS = 60_000) when no override is supplied. + * 2. Explicit per-call overrides (CLAIM_TIMEOUT_MS = 3000) survive. + * 3. checkWlAvailable (wl --version probe) passes a bounded timeout. + * 4. A hung execFile (mock honouring options.timeout) rejects after the + * timeout elapses. + * 5. At the worklist level, a timed-out refresh yields a 'Refresh failed' + * toast, clears the guard, and the next tick proceeds. + * + * Run: npx vitest run packages/herdr/src/fetcher-timeout.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +vi.mock('./auto-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runSync: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +// Hoisted so the worklist module (which imports ./notify.js) sees the SAME +// mock instance the assertions below reference. +const { showToastMock } = vi.hoisted(() => ({ showToastMock: vi.fn() })); + +vi.mock('./notify.js', () => ({ showToast: showToastMock })); + +import { runWorklistTui } from './worklist.js'; +import { + setExecFileAsync, + resetExecFileAsync, + claimWorkItem, + fetchNextItems, + fetchItemsByStage, + fetchItemsByPriority, + fetchActionableCount, + fetchChildrenForItem, + runWlSync, + checkWlAvailable, +} from './fetcher.js'; + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { + value: vi.fn(), + configurable: true, + writable: true, + }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); +}); + +afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_PANE_ID; + delete process.env.HERDR_TAB_ID; + delete process.env.HERDR_WORKSPACE_ID; + delete process.env.HERDR_BIN_PATH; + delete process.env.HERDR_ENV; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Build a basic exec mock that returns empty work-items for normal queries + * and resolves the herdr pane/tab visibility checks (focused). + */ +function makeExecMock(paneFocused = true): Mock { + return vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && (args[0] === 'pane' || args[0] === 'tab')) { + return { + stdout: JSON.stringify({ + id: 'cli:get', + result: { pane: { focused: paneFocused }, tab: { focused: paneFocused } }, + }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }); +} + +/** + * Build an exec mock that HANGS (never resolves) unless the timeout option + * fires. When called with a `timeout` in the options, it sets a timer of + * that many ms and rejects when it elapses — simulating Node's execFile + * timeout handling under fake timers. + */ +function makeHangExecMock(): Mock { + return vi.fn( + async (_bin: string, _args: string[], options?: { timeout?: number }) => { + const timeout = options?.timeout ?? 60_000; + // Never resolve on its own — a hung wl process. Only the timeout can + // reject. Under fake timers, advanceTimersByTimeAsync fires this. + await new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('exec timed out')), timeout); + }); + }, + ); +} + +/** Quit the TUI gracefully. */ +async function quitTui(p: Promise): Promise { + dataHandler?.(Buffer.from('q')); + await p; +} + +// --------------------------------------------------------------------------- +// Test: fetcher callers pass DEFAULT_WL_TIMEOUT_MS +// --------------------------------------------------------------------------- + +describe('fetcher timeout defaults (WL-0MSJNJXX2001NMHS)', () => { + beforeEach(() => { + resetExecFileAsync(); + }); + + afterEach(() => { + resetExecFileAsync(); + }); + + it('fetchNextItems passes a bounded timeout to execFileAsync', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await fetchNextItems(10); + + // fetchNextItems calls runWl once for 'next', then fetchMandatorySubsets + // which calls runWl twice (critical list + in_review list). + // All calls should have a timeout option. + expect(mockFn.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of mockFn.mock.calls) { + const opts = call[2] as { timeout?: number } | undefined; + expect(opts).toBeDefined(); + expect(opts!.timeout).toBe(60_000); + } + }); + + it('fetchItemsByStage passes a bounded timeout to execFileAsync', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await fetchItemsByStage('in_progress'); + + const opts = mockFn.mock.calls[0][2] as { timeout?: number } | undefined; + expect(opts).toBeDefined(); + expect(opts!.timeout).toBe(60_000); + }); + + it('fetchItemsByPriority passes a bounded timeout to execFileAsync', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await fetchItemsByPriority('high'); + + const opts = mockFn.mock.calls[0][2] as { timeout?: number } | undefined; + expect(opts).toBeDefined(); + expect(opts!.timeout).toBe(60_000); + }); + + it('fetchActionableCount passes a bounded timeout to execFileAsync', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ count: 5 }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await fetchActionableCount(); + + const opts = mockFn.mock.calls[0][2] as { timeout?: number } | undefined; + expect(opts).toBeDefined(); + expect(opts!.timeout).toBe(60_000); + }); + + it('fetchChildrenForItem passes a bounded timeout to execFileAsync', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await fetchChildrenForItem('WL-0MS9NPHQU005Y3VE'); + + const opts = mockFn.mock.calls[0][2] as { timeout?: number } | undefined; + expect(opts).toBeDefined(); + expect(opts!.timeout).toBe(60_000); + }); + + it('runWlSync passes a bounded timeout to execFileAsync', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await runWlSync(); + + const opts = mockFn.mock.calls[0][2] as { timeout?: number } | undefined; + expect(opts).toBeDefined(); + expect(opts!.timeout).toBe(60_000); + }); +}); + +// --------------------------------------------------------------------------- +// Test: explicit overrides survive +// --------------------------------------------------------------------------- + +describe('explicit timeout overrides survive (WL-0MSJNJXX2001NMHS)', () => { + beforeEach(() => { + resetExecFileAsync(); + }); + + afterEach(() => { + resetExecFileAsync(); + }); + + it('claimWorkItem uses its own CLAIM_TIMEOUT_MS (3000), not the default', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await claimWorkItem('WL-0MS9NPHQU005Y3VE', 'Map'); + + const opts = mockFn.mock.calls[0][2] as { timeout?: number } | undefined; + expect(opts).toBeDefined(); + expect(opts!.timeout).toBe(3000); + }); +}); + +// --------------------------------------------------------------------------- +// Test: checkWlAvailable passes a bounded timeout +// --------------------------------------------------------------------------- + +describe('checkWlAvailable timeout (WL-0MSJNJXX2001NMHS)', () => { + beforeEach(() => { + resetExecFileAsync(); + }); + + afterEach(() => { + resetExecFileAsync(); + }); + + it('checkWlAvailable passes a bounded timeout to execFileAsync', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: 'wl 1.0.0', + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const result = await checkWlAvailable(); + + expect(result).toBe(true); + const opts = mockFn.mock.calls[0][2] as { timeout?: number } | undefined; + expect(opts).toBeDefined(); + expect(opts!.timeout).toBe(60_000); + }); +}); + +// --------------------------------------------------------------------------- +// Test: hung execFile resolves via timeout (fake timers) +// --------------------------------------------------------------------------- + +describe('hung wl spawn resolves via timeout (WL-0MSJNJXX2001NMHS)', () => { + beforeEach(() => { + vi.useFakeTimers(); + resetExecFileAsync(); + }); + + afterEach(() => { + vi.useRealTimers(); + resetExecFileAsync(); + }); + + it( + 'a hung execFile rejects after DEFAULT_WL_TIMEOUT_MS (60s)', + async () => { + const hangMock = makeHangExecMock(); + setExecFileAsync(hangMock as any); + + // Start the fetch — it will hang forever unless the timeout fires. + // Attach the rejection handler IMMEDIATELY so the timeout rejection + // never escapes as an unhandled promise rejection. + const fetchPromise = fetchNextItems(10); + const rejection = expect(fetchPromise).rejects.toThrow(); + + // Advance past the timeout — should trigger rejection. + await vi.advanceTimersByTimeAsync(60_001); + + // The fetch rejected after the timeout (not before, not never). + await rejection; + }, + 120_000, // vitest default is 30s; this test simulates 60s timeouts + ); +}); + +// --------------------------------------------------------------------------- +// Test: worklist-level — timed-out refresh recovers gracefully +// --------------------------------------------------------------------------- + +describe('worklist recovery from timed-out refresh (WL-0MSJNJXX2001NMHS)', () => { + beforeEach(() => { + vi.useFakeTimers(); + resetExecFileAsync(); + }); + + afterEach(() => { + vi.useRealTimers(); + resetExecFileAsync(); + }); + + it('timed-out refresh → Refresh failed toast, guard cleared, next tick proceeds', async () => { + process.env.HERDR_PANE_ID = 'w1:pCM'; + vi.clearAllMocks(); + + // The pane check resolves; wl spawns hang until their timeout fires + // (simulating the execFile timeout killing a hung wl process). + const execMock = vi.fn(async (bin: string, args: string[], options?: { timeout?: number }) => { + if (bin === 'herdr' && (args[0] === 'pane' || args[0] === 'tab')) { + return { + stdout: JSON.stringify({ id: 'cli:get', result: { pane: { focused: true }, tab: { focused: true } } }), + stderr: '', + }; + } + // A hung wl child: only the execFile timeout can settle it. + await new Promise((_resolve, reject) => { + const timeout = options?.timeout ?? 60_000; + setTimeout(() => reject(new Error('wl child killed: timeout')), timeout); + }); + }); + setExecFileAsync(execMock as any); + + // REAL fetcher so the whole refresh path is exercised end-to-end: + // doRefresh → fetchItemsForView → fetchNextItems → runWl → hung spawn. + const p = runWorklistTui(fetchNextItems, [], undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + + const wlSpawns = (): number => + execMock.mock.calls.filter((c) => c[0] === 'wl').length; + + // t=30s: first tick starts a refresh cycle; the hung wl spawn is in + // flight with its 60s timeout armed. + await vi.advanceTimersByTimeAsync(30_000); + const spawnsBeforeTimeout = wlSpawns(); + expect(spawnsBeforeTimeout).toBeGreaterThan(0); + + // t=90s: the DEFAULT_WL_TIMEOUT_MS (60s) fired mid-cycle → the fetch + // rejected → 'Refresh failed' toast, guard cleared in `finally`. + await vi.advanceTimersByTimeAsync(60_000); + expect(showToastMock).toHaveBeenCalledWith('Refresh failed'); + + // t=150s: the next refresh tick ran to completion (new wl spawns after + // the timeout) — the guard did NOT stick. The second cycle's own wl + // spawn is hung again, but that is the bounded-timeout regression under + // test, not a permanent wedge. + await vi.advanceTimersByTimeAsync(60_000); + expect(wlSpawns()).toBeGreaterThan(spawnsBeforeTimeout); + + await quitTui(p); + }); +}); diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index 5c382f208..be08d4587 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -288,6 +288,15 @@ function extractItems(payload: unknown): WorkItem[] { // ── CLI execution ───────────────────────────────────────────────────── +/** + * Default timeout (in milliseconds) for all wl CLI spawns in the refresh/ + * sync fetch path. 60_000 ms — matches the auto-sync safety timeout and the + * Pi TUI extension convention (auto-sync.ts:227). A hung wl spawn (e.g. + * waiting on a lock during a long `wl sync`) will reject after this period + * instead of blocking the `refreshInFlight` guard forever (WL-0MSJNJXX2001NMHS). + */ +export const DEFAULT_WL_TIMEOUT_MS = 60_000; + const CLI_BINARIES = ['wl', 'worklog']; // ── In-process fetch memoization (F4 — WL-0MSGAEPOJ002824S) ─────────── @@ -351,9 +360,14 @@ async function runWlInner(args: string[], includeJson: boolean, timeoutMs?: numb // Prepend --worklog-dir when set (global option before the subcommand). fullArgs = buildWlArgs(fullArgs); + // Bounded timeout: use the caller's override if supplied, otherwise + // apply DEFAULT_WL_TIMEOUT_MS so a hung wl process cannot block the + // refreshInFlight guard forever (WL-0MSJNJXX2001NMHS). + const timeout = timeoutMs ?? DEFAULT_WL_TIMEOUT_MS; + const result = await execFileAsync(binary, fullArgs, { maxBuffer: 1024 * 1024 * 5, - ...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}), + timeout, }); return result.stdout; } catch (error: any) { @@ -414,11 +428,12 @@ async function runWl(args: string[], includeJson = true, timeoutMs?: number): Pr /** * Check whether the wl CLI is available on PATH. + * Uses DEFAULT_WL_TIMEOUT_MS so a hung --version probe cannot block startup. */ export async function checkWlAvailable(): Promise { for (const binary of CLI_BINARIES) { try { - await execFileAsync(binary, ['--version'], { maxBuffer: 1024 }); + await execFileAsync(binary, ['--version'], { maxBuffer: 1024, timeout: DEFAULT_WL_TIMEOUT_MS }); return true; } catch { continue; @@ -535,6 +550,23 @@ export async function fetchItemsByStage(stage: string): Promise { return regroupWorkItems(extractItems(payload)); } +/** + * Fetch work items filtered by priority (via `wl list`). + * + * Mirrors the stage-filter fetch semantics (WL-0MSDT8X1V003206G): every + * OPEN root item with that priority — `wl list --status open --priority

+ * --root-only` — no `browseItemCount` cap, no `wl next` selection omission; + * children stay hidden and remain reachable via expand exactly as in the + * unfiltered view. Results follow the standard list order and are regrouped + * priority-first (WL-0MSOPHLD1000EWNN) so the group sections stay + * consistent with the default worklist. + */ +export async function fetchItemsByPriority(priority: string): Promise { + const output = await runWl(['list', '--status', 'open', '--priority', priority, '--root-only']); + const payload = extractJson(output); + return regroupWorkItems(extractItems(payload)); +} + /** * Fetch details for a single work item by ID (via `wl show`). */ diff --git a/packages/herdr/src/form-dialog.test.ts b/packages/herdr/src/form-dialog.test.ts index c638b10d8..45f8ea85b 100644 --- a/packages/herdr/src/form-dialog.test.ts +++ b/packages/herdr/src/form-dialog.test.ts @@ -17,6 +17,9 @@ import { extractIdentifiers, getUnknownIdentifiers, substituteIdentifiers, + unwrapBracketedPaste, + BRACKETED_PASTE_START, + BRACKETED_PASTE_END, } from './form-dialog.js'; const visible = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); @@ -201,6 +204,18 @@ describe('FormState.render — downward expansion', () => { const lNonBlank = long.split('\n').filter((l) => visible(l).trim().length > 0).length; expect(lNonBlank).toBeGreaterThan(sNonBlank); }); + + it('renders multi-line field values wrapped without layout breakage', () => { + const out = makeForm({ fields: [{ name: 'status', value: 'line one\nline two\nline three' }] }).render(40, 40); + const vis = out.split('\n').map(visible); + expect(vis.some((l) => /line one/.test(l))).toBe(true); + expect(vis.some((l) => /line two/.test(l))).toBe(true); + expect(vis.some((l) => /line three/.test(l))).toBe(true); + // No line exceeds the pane width. + for (const line of vis) { + expect(line.length).toBeLessThanOrEqual(40); + } + }); }); // ── Interactions preserved ────────────────────────────────────────────── @@ -218,7 +233,7 @@ describe('FormState interactions', () => { for (const ch of 'in') state.handleInput(ch); state.handleInput('\t'); for (const ch of 'prod') state.handleInput(ch); - expect(state.handleInput('\r')).toBe('submitted'); + expect(state.handleInput('\r')).toEqual({ type: 'submitted' }); expect(result).toBe('wl update --status in --stage prod'); }); @@ -227,7 +242,7 @@ describe('FormState interactions', () => { const state = new FormState('cmd ', 'd', [{ name: 'x', default: '' }], () => {}, () => { cancelled = true; }); - expect(state.handleInput('\x1b')).toBe('cancelled'); + expect(state.handleInput('\x1b')).toEqual({ type: 'cancelled' }); expect(cancelled).toBe(true); }); @@ -288,6 +303,132 @@ describe('FormState interactions', () => { }); }); +// ── Paste / cut / newline / bracketed-paste (WL-0MSW6KCTA0092DCV) ──── + +describe('FormState paste & cut', () => { + it('Ctrl+V returns a paste request without touching the field', () => { + const state = makeForm({ fields: [{ name: 'status', value: 'abc' }] }); + expect(state.handleInput('\x16')).toEqual({ type: 'paste' }); + expect(state.fields[0].value).toBe('abc'); + }); + + it('pasteText inserts clipboard text verbatim (newlines preserved)', () => { + const state = makeForm({ fields: [{ name: 'status', value: 'abc' }] }); + state.pasteText('line1\nline2\r\nline3'); + expect(state.fields[0].value).toBe('abcline1\nline2\r\nline3'); + }); + + it('a pasted newline does not submit the form', () => { + let submitted = false; + const state = new FormState('cmd ', 'd', [{ name: 'x', default: '' }], + () => { submitted = true; }, () => {}); + state.pasteText('multi\nline'); + expect(submitted).toBe(false); + expect(state.fields[0].value).toBe('multi\nline'); + // A subsequent plain Enter still submits normally. + expect(state.handleInput('\r')).toEqual({ type: 'submitted' }); + expect(submitted).toBe(true); + }); + + it('Ctrl+X clears the field and returns the copied text', () => { + const state = makeForm({ fields: [{ name: 'status', value: 'copy me' }] }); + const res = state.handleInput('\x18'); + expect(res).toEqual({ type: 'cut', text: 'copy me' }); + expect(state.fields[0].value).toBe(''); + }); + + it('Ctrl+X on an empty field returns empty text and stays empty', () => { + const state = makeForm({ fields: [{ name: 'status', value: '' }] }); + const res = state.handleInput('\x18'); + expect(res).toEqual({ type: 'cut', text: '' }); + expect(state.fields[0].value).toBe(''); + }); + + it('cut operates on the active field only', () => { + const state = makeForm({ fields: [{ name: 'a', value: 'first' }, { name: 'b', value: 'second' }] }); + state.activeFieldIndex = 1; + const res = state.handleInput('\x18'); + expect(res).toEqual({ type: 'cut', text: 'second' }); + expect(state.fields[1].value).toBe(''); + expect(state.fields[0].value).toBe('first'); + }); + + it('notifyPasteFailed exposes the failure reason (additive, no data loss)', () => { + const state = makeForm({ fields: [{ name: 'status', value: 'keep' }] }); + expect(state.notifyPasteFailed('no clipboard reader available')).toBe( + 'no clipboard reader available', + ); + expect(state.fields[0].value).toBe('keep'); + }); +}); + +describe('FormState Ctrl+Enter newline', () => { + it('inserts a newline into the active field instead of submitting', () => { + let submitted = false; + const state = new FormState('cmd ', 'd', [{ name: 'x', default: '' }], + () => { submitted = true; }, () => {}); + state.handleInput('f'); + state.handleInput('i'); + state.handleInput('r'); + state.handleInput('s'); + state.handleInput('t'); + state.handleInput('\x1b[13;5u'); + expect(state.fields[0].value).toBe('first\n'); + expect(submitted).toBe(false); + }); + + it('plain Enter still submits after a Ctrl+Enter newline', () => { + let submitted = false; + const state = new FormState('cmd ', 'd', [{ name: 'x', default: '' }], + () => { submitted = true; }, () => {}); + state.handleInput('a'); + state.handleInput('\x1b[13;5u'); + state.handleInput('b'); + expect(state.handleInput('\r')).toEqual({ type: 'submitted' }); + expect(submitted).toBe(true); + }); +}); + +describe('bracketed-paste unwrapping', () => { + it('extracts inner text from a fully wrapped chunk', () => { + expect(unwrapBracketedPaste(`${BRACKETED_PASTE_START}hello\nworld${BRACKETED_PASTE_END}`)).toBe('hello\nworld'); + }); + + it('returns undefined for a chunk with no wrapper', () => { + expect(unwrapBracketedPaste('plain text')).toBeUndefined(); + }); + + it('inserts a full self-contained bracketed paste verbatim', () => { + const state = makeForm({ fields: [{ name: 'status', value: '' }] }); + state.handleInput(`${BRACKETED_PASTE_START}multi\nline content${BRACKETED_PASTE_END}`); + expect(state.fields[0].value).toBe('multi\nline content'); + }); + + it('accumulates char-by-char bracketed paste across open/close markers', () => { + const state = makeForm({ fields: [{ name: 'status', value: '' }] }); + state.handleInput(BRACKETED_PASTE_START); + for (const ch of ['h', 'i', '\n', 'y', 'o', 'u']) state.handleInput(ch); + state.handleInput(BRACKETED_PASTE_END); + expect(state.fields[0].value).toBe('hi\nyou'); + }); + + it('does not submit on a newline inside a bracketed paste', () => { + let submitted = false; + const state = new FormState('cmd ', 'd', [{ name: 'x', default: '' }], + () => { submitted = true; }, () => {}); + state.handleInput(BRACKETED_PASTE_START); + state.handleInput('\n'); + state.handleInput(BRACKETED_PASTE_END); + expect(submitted).toBe(false); + expect(state.fields[0].value).toBe('\n'); + }); + + it('strips wrapper markers when they bracket the chunk', () => { + expect(unwrapBracketedPaste(`${BRACKETED_PASTE_START}tail`)).toBe('tail'); + expect(unwrapBracketedPaste(`head${BRACKETED_PASTE_END}`)).toBe('head'); + }); +}); + // ── Identifier helpers ────────────────────────────────────────────────── describe('identifier helpers', () => { diff --git a/packages/herdr/src/form-dialog.ts b/packages/herdr/src/form-dialog.ts index ec899ee50..7d69a3fd4 100644 --- a/packages/herdr/src/form-dialog.ts +++ b/packages/herdr/src/form-dialog.ts @@ -110,6 +110,26 @@ export interface FormResult { command: string; } +/** + * Result of processing a single keypress in form mode. + * + * Broadened from the old `'submitted' | 'cancelled' | null` union so the + * caller can route async clipboard work (paste/cut) without ever freezing + * the TUI event loop (WL-0MSW6KCTA0092DCV). + */ +export type FormInputResult = + | { type: 'submitted' } + | { type: 'cancelled' } + | /** Ctrl+V: no data yet — the caller should read the OS clipboard and + * feed the result back via {@link FormState.pasteText} or + * {@link FormState.notifyPasteFailed}. */ + { type: 'paste' } + | /** Ctrl+X: the active field has been cleared; `text` holds the value the + * caller should copy to the OS clipboard (then surface feedback). */ + { type: 'cut'; text: string } + | /** Ordinary keystroke / navigation — nothing for the caller to do. */ + { type: 'none' }; + // ── ANSI helpers ────────────────────────────────────────────────────── const ANSI = { @@ -129,6 +149,47 @@ const SGR_RE = /\x1b\[[0-9;]*m/g; /** Non-global matcher for testing whether a segment is an SGR sequence. */ const IS_SGR_RE = /^\x1b\[[0-9;]*m$/; +// ── Bracketed-paste sequences (WL-0MSW6KCTA0092DCV) ─────────────────── +/** Opening bracketed-paste control sequence. */ +export const BRACKETED_PASTE_START = '\x1b[200~'; +/** Closing bracketed-paste control sequence. */ +export const BRACKETED_PASTE_END = '\x1b[201~'; + +/** + * Ctrl+Enter key encodings that map to a verbatim newline insert. + * + * Terminals that report modified keys (kitty / xterm ʼCSI uʼ) encode + * Ctrl+Enter as `ESC [ 13 ; 5 u`. Herdr may also deliver Enter-with-Ctrl as + * `ESC [ 1 ; 5 A`-style variants, so both are accepted here. + */ +const CTRL_ENTER_SEQS = ['\x1b[13;5u', '\x1b[13;5~', '\x1b[1;5A']; + +/** + * Extract the inner text from bracketed-paste wrapping, if present. + * + * Returns `undefined` when the chunk does not carry a bracketed-paste + * wrapper. A fully wrapped chunk (`ESC [ 200 ~ … ESC [ 201 ~`) yields its + * inner content verbatim; leading/trailing lone wrapper markers are stripped + * so the remaining text can be inserted. Newlines inside are data. + */ +export function unwrapBracketedPaste(chunk: string): string | undefined { + const startIdx = chunk.indexOf(BRACKETED_PASTE_START); + const endIdx = chunk.indexOf(BRACKETED_PASTE_END); + const hasStart = startIdx >= 0; + const hasEnd = endIdx >= 0; + if (!hasStart && !hasEnd) return undefined; + // Full wrap: everything between the markers. + if (hasStart && hasEnd && endIdx > startIdx) { + return chunk.slice(startIdx + BRACKETED_PASTE_START.length, endIdx); + } + // Lone start marker: strip it, keep the rest. + if (hasStart) { + return chunk.slice(startIdx + BRACKETED_PASTE_START.length); + } + // Lone end marker: strip it, keep the rest. + return chunk.slice(0, endIdx); +} + /** * Measure the visible (displayed) width of a string, ignoring ANSI SGR * sequences. Consistent with the convention used elsewhere in this package @@ -266,6 +327,14 @@ export class FormState { /** Called when the user cancels the form */ private onCancel: () => void; + /** + * Bracketed-paste accumulation state (WL-0MSW6KCTA0092DCV). While true, + * every subsequent key is pushed verbatim (newlines stay data) until the + * closing `\x1b[201~` arrives. Herdr may deliver the bracketed sequence + * char-by-char, so the open/close markers are tracked across calls. + */ + private bracketedPasteOpen = false; + constructor( commandTemplate: string, description: string, @@ -289,40 +358,84 @@ export class FormState { * Process a single keypress in form mode. * * @param key - The raw keypress string - * @returns 'submitted' if form was submitted, 'cancelled' if cancelled, - * or null if still editing + * @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. */ - handleInput(key: string): 'submitted' | 'cancelled' | null { + handleInput(key: string): FormInputResult { + // ── 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 + // content verbatim (newlines are data, never submit). + if (this.bracketedPasteOpen) { + if (key === BRACKETED_PASTE_END) { + this.bracketedPasteOpen = false; + return { type: 'none' }; + } + this.insertIntoActiveField(key); + return { type: 'none' }; + } + if (key === BRACKETED_PASTE_START) { + this.bracketedPasteOpen = true; + return { type: 'none' }; + } + const unwrapped = unwrapBracketedPaste(key); + if (unwrapped !== undefined) { + this.insertIntoActiveField(unwrapped); + return { type: 'none' }; + } + if (key === '\r' || key === '\n') { // Submit the form const result = this.getResult(); this.onSubmit(result); - return 'submitted'; + return { type: 'submitted' }; + } + + // Ctrl+Enter: insert a newline (data) instead of submitting. + if (CTRL_ENTER_SEQS.includes(key)) { + this.insertIntoActiveField('\n'); + return { type: 'none' }; + } + + // Ctrl+V: request an OS-clipboard paste (async — done by the caller). + if (key === '\x16') { + return { type: 'paste' }; + } + + // Ctrl+X: copy the whole active-field value to the OS clipboard and + // clear the field. The copy itself is async — performed by the caller. + if (key === '\x18') { + const field = this.fields[this.activeFieldIndex]; + const text = field.value; + field.value = ''; + return { type: 'cut', text }; } if (key === '\x1b') { // Cancel the form this.onCancel(); - return 'cancelled'; + return { type: 'cancelled' }; } if (key === '\t') { // Tab: advance to next field (wrap around) this.activeFieldIndex = (this.activeFieldIndex + 1) % this.fields.length; - return null; + return { type: 'none' }; } if (key === '\x1b[A') { // Arrow up: previous field (wrap around) this.activeFieldIndex = (this.activeFieldIndex - 1 + this.fields.length) % this.fields.length; - return null; + return { type: 'none' }; } if (key === '\x1b[B') { // Arrow down: next field (wrap around) this.activeFieldIndex = (this.activeFieldIndex + 1) % this.fields.length; - return null; + return { type: 'none' }; } if (key === '\x7f' || key === '\b') { @@ -331,18 +444,54 @@ export class FormState { if (field.value.length > 0) { field.value = field.value.slice(0, -1); } - return null; + return { type: 'none' }; } // Regular character input if (key.length === 1 && key.charCodeAt(0) >= 0x20) { const field = this.fields[this.activeFieldIndex]; field.value += key; - return null; + return { type: 'none' }; } // Ignore other control sequences - return null; + return { type: 'none' }; + } + + /** + * Insert text at the end of the active field verbatim (newlines preserved). + * Used by the paste path (Ctrl+V) and bracketed-paste unwrapping. + * + * @param text - The text to append to the active field. + */ + insertIntoActiveField(text: string): void { + const field = this.fields[this.activeFieldIndex]; + field.value += text; + } + + /** + * Feed a successful clipboard read into the active field. + * + * The paste path is asynchronous: {@link handleInput} returns + * `{ type: 'paste' }`, the caller reads the OS clipboard, and on success + * calls this method to commit the text. + * + * @param text - The clipboard contents (newlines preserved verbatim). + */ + pasteText(text: string): void { + this.insertIntoActiveField(text); + } + + /** + * Signal that an OS-clipboard read failed (no reader available, read + * error, or empty content). The form itself is left untouched so the user + * can retry or type; the caller uses the return value to surface a visible + * message (e.g. a toast/hint) without closing the form. + * + * @returns The failure reason, for the caller to display. + */ + notifyPasteFailed(reason: string): string { + return reason; } /** @@ -453,8 +602,12 @@ export class FormState { pushContent(''); // Instructions - const fullHint = '[Tab/↑↓] navigate [Enter] submit [Esc] cancel'; - const hint = visibleWidth(fullHint) <= maxCols ? fullHint : '[Tab] next [Enter] ok [Esc] cancel'; + const fullHint = + '[Tab/↑↓] navigate [Enter] submit [Ctrl+Enter] newline [Ctrl+V] paste [Ctrl+X] cut [Esc] cancel'; + const hint = + visibleWidth(fullHint) <= maxCols + ? fullHint + : '[Tab] next [Enter] ok [Ctrl+V] paste [Ctrl+X] cut [Esc] cancel'; pushContent(truncateToWidth(`${ANSI.dim}${hint}${ANSI.reset}`, maxCols)); // Fill remaining rows with blank lines (never exceeding maxRows). diff --git a/packages/herdr/src/icons.test.ts b/packages/herdr/src/icons.test.ts index 4b7d9dc05..23e0bdeee 100644 --- a/packages/herdr/src/icons.test.ts +++ b/packages/herdr/src/icons.test.ts @@ -15,8 +15,10 @@ import { agentStatusIcon, getIconPrefix, iconsEnabled, + isAuditFresh, + stageDisplayIcon, stringDisplayWidth, -} from './icons.js'; +} from '@worklog/shared/icons'; import { formatItemLine } from './worklist.js'; import type { WorkItem } from './fetcher.js'; @@ -67,6 +69,37 @@ describe('agentStatusIcon', () => { }); }); +describe('stageDisplayIcon — audit-aware stage icon shared by list and metadata (WL-0MSGIXHHI009KFW9)', () => { + const item = (over: Record = {}): Record => ({ + stage: 'in_review', + auditResult: null, + auditedAt: null, + updatedAt: '2026-08-02T10:00:00.000Z', + ...over, + }); + + it('shows the audit-result icon for a fresh audit (✅/❌/❓)', () => { + expect(stageDisplayIcon(item({ auditResult: true, auditedAt: '2026-08-02T10:00:30.000Z' }))).toBe('\u{2705}'); + expect(stageDisplayIcon(item({ auditResult: false, auditedAt: '2026-08-02T10:00:30.000Z' }))).toBe('\u{274C}'); + expect(stageDisplayIcon(item({ auditedAt: '2026-08-02T10:00:30.000Z' }))).toBe('\u{2753}'); + }); + + it('shows the stale-passed hourglass when the audit is stale but passed', () => { + expect(stageDisplayIcon(item({ auditResult: true, auditedAt: '2026-08-01T10:00:00.000Z' }))).toBe('\u{23F3}'); + }); + + it('falls back to the plain stage icon otherwise (stale/no audit, non-in_review stages)', () => { + expect(stageDisplayIcon(item())).toBe('\u{1F50D}'); // 🔍 no audit + expect(stageDisplayIcon(item({ stage: 'idea' }))).toBe('\u{1F4A1}'); // 💡 + expect(stageDisplayIcon({ stage: 'in_progress' })).toBe('\u{1F6E0}\u{FE0F}'); // 🛠️ + }); + + it('honours the noIcons flag like the other icon helpers', () => { + expect(stageDisplayIcon(item({ stage: 'idea' }), { noIcons: true })).toBe('[IDEA]'); + expect(stageDisplayIcon(item({ auditResult: true, auditedAt: '2026-08-02T10:00:30.000Z' }), { noIcons: true })).toBe('[ready]'); + }); +}); + describe('getIconPrefix — fixed-width agent slot (AC3 alignment invariance)', () => { const base = { status: 'open', stage: 'idea' } as const; const withAgent = { status: 'open', stage: 'idea', agentState: 'working' } as const; @@ -127,3 +160,36 @@ describe('getIconPrefix — fixed-width agent slot (AC3 alignment invariance)', expect(idCol(withRow)).toBe(idCol(blockedRow)); }); }); + +describe('isAuditFresh — flag-only updates must not make a valid audit stale (WL-0MSN6ZCTN0027U2R)', () => { + // The worklog core guarantees (packages/shared/src/database.ts update()) + // that flipping needsProducerReview does NOT bump updatedAt; only content + // changes do. These tests pin the herdr-side consequence: a previously + // fresh audit stays fresh and the passed icon is shown (not the stale + // hourglass). herdr itself is unchanged. + it('stays fresh when updatedAt does not move after the flag flip', () => { + const auditedAt = '2026-08-02T10:00:30.000Z'; + const updatedAtAfterFlagFlip = '2026-08-02T10:00:00.000Z'; // unchanged by the flip + expect(isAuditFresh(auditedAt, updatedAtAfterFlagFlip)).toBe(true); + }); + + it('shows the passed icon (not the stale hourglass) after a flag-only flip', () => { + // Audit at 10:00:30, item updated at 10:00:00 — with the worklog + // guarantee the updatedAt is unchanged by a flag flip, so the audit + // remains within the 60 s buffer and the passed icon is shown. + expect( + stageDisplayIcon({ + stage: 'in_review', + auditResult: true, + auditedAt: '2026-08-02T10:00:30.000Z', + updatedAt: '2026-08-02T10:00:00.000Z', + }), + ).toBe('\u{2705}'); // ✅ — not ⏳ (stale hourglass) + }); + + it('still reports stale when updatedAt genuinely moves (content change)', () => { + const auditedAt = '2026-08-02T10:00:30.000Z'; + const updatedAtAfterContentChange = '2026-08-02T10:05:00.000Z'; // content edit bumps + expect(isAuditFresh(auditedAt, updatedAtAfterContentChange)).toBe(false); + }); +}); diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 464703aec..2c831ee2c 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -11,13 +11,18 @@ import { routeCommand, stripAgentPromptPrefix, buildSendToPiArgs, + buildRunInPaneArgs, createDowntimeDeps, capturePaneIdFromFile, parsePaneIdFile, + buildBackgroundLogPath, + spawnBackgroundShell, + spawnBackgroundPi, CAPTURE_TIMEOUT_MS, } from './index.js'; import { appendDowntimeLogEntry, DOWNTIME_LOG_FILE, readDowntimeLogEntries } from './downtime-log.js'; -import { DOWNTIME_WL_TIMEOUT_MS, dispatchDowntimeWork } from './downtime-worker.js'; +import { DOWNTIME_WL_TIMEOUT_MS, dispatchDowntimeWork, type ScheduledPrompt } from './downtime-worker.js'; +import { SCHEDULED_PROMPTS_FILE, scheduledPromptsPath } from './scheduled-prompts.js'; import { fetchItemsByStage, resetExecFileAsync, @@ -31,8 +36,13 @@ import { // --------------------------------------------------------------------------- describe('buildSendToPiArgs', () => { + // Selection-list agent dispatch must NOT steal focus from the list + // (WL-0MSHIA53D009DJOT): every agent-pane spawn passes --no-focus so + // shared/send-to-pi.sh skips its final zoom. The flag is emitted before + // --cwd, mirroring buildDowntimePaneArgs. it('includes --model for agent commands with a model', () => { expect(buildSendToPiArgs('/skill:implement ', '/project', 'code')).toEqual([ + '--no-focus', '--cwd', '/project', '--model', @@ -41,8 +51,9 @@ describe('buildSendToPiArgs', () => { ]); }); - it('omits --model when no model is provided', () => { + it('omits --model when no model is provided (--no-focus retained)', () => { expect(buildSendToPiArgs('/skill:implement ', '/project')).toEqual([ + '--no-focus', '--cwd', '/project', '/skill:implement ', @@ -51,6 +62,7 @@ describe('buildSendToPiArgs', () => { it('strips the /prompt: prefix and keeps the model', () => { expect(buildSendToPiArgs('/prompt:Review the item', '/project', 'author')).toEqual([ + '--no-focus', '--cwd', '/project', '--model', @@ -61,6 +73,7 @@ describe('buildSendToPiArgs', () => { it('passes an empty prompt arg for a bare /prompt: command (blank session)', () => { expect(buildSendToPiArgs('/prompt:', '/project', 'plan')).toEqual([ + '--no-focus', '--cwd', '/project', '--model', @@ -71,6 +84,7 @@ describe('buildSendToPiArgs', () => { it('passes /plan with the plan model', () => { expect(buildSendToPiArgs('/plan ', '/project', 'plan')).toEqual([ + '--no-focus', '--cwd', '/project', '--model', @@ -80,6 +94,38 @@ describe('buildSendToPiArgs', () => { }); }); +// --------------------------------------------------------------------------- +// buildRunInPaneArgs tests (WL-0MSHIA53D009DJOT) — the pane and stdout +// dispatch routes share this helper, so a single unit-test group covers both +// AC2 (!!/! prefixed) and AC3 (plain shell) no-focus behavior. +// --------------------------------------------------------------------------- + +describe('buildRunInPaneArgs', () => { + it('passes --no-focus + --cwd before a !!-prefixed command (pane route)', () => { + expect(buildRunInPaneArgs('wl update --priority high', '/project')).toEqual([ + '--no-focus', + '--cwd', + '/project', + 'wl update --priority high', + ]); + }); + + it('passes --no-focus + --cwd before a plain shell command (stdout route)', () => { + expect(buildRunInPaneArgs('ls -la && pwd', '/project')).toEqual([ + '--no-focus', + '--cwd', + '/project', + 'ls -la && pwd', + ]); + }); + + it('keeps the full command intact after the options', () => { + const args = buildRunInPaneArgs("echo 'quoted arg' --flag", '/project'); + expect(args.slice(0, 3)).toEqual(['--no-focus', '--cwd', '/project']); + expect(args[3]).toBe("echo 'quoted arg' --flag"); + }); +}); + // --------------------------------------------------------------------------- // stripCommandPrefix tests // --------------------------------------------------------------------------- @@ -379,7 +425,7 @@ describe('stripAgentPromptPrefix', () => { // Temp-dir helpers (shared by the configureWorklogTarget tests below) // --------------------------------------------------------------------------- -import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; const tempDirs: string[] = []; @@ -516,6 +562,38 @@ describe('createDowntimeDeps', () => { resetWorklogDir(); }); + // Route-aware wl mock for the critical-tier lookup (F3): `wl list` + // returns the critical batch; `wl dep list` returns the outbound + // depends-on edges for the queried item (its blockers); `wl show` + // enriches a blocker with the full workItem fields. Unblocked items + // (no outbound edges) resolve to themselves in the frontier walk, so + // the F2 expectations carry over unchanged. + const criticalWlMock = ( + workItems: unknown[], + blockersByItem: Record = {}, + showItems: Record> = {}, + ) => + vi.fn().mockImplementation((_bin: string, args: string[]) => { + if (args[0] === 'dep') { + const itemId = args[2]; + return Promise.resolve({ + stdout: JSON.stringify({ success: true, item: itemId, inbound: [], outbound: blockersByItem[itemId] ?? [] }), + stderr: '', + }); + } + if (args[0] === 'show') { + const itemId = args[1]; + return Promise.resolve({ + stdout: JSON.stringify({ success: true, workItem: showItems[itemId] ?? { id: itemId, title: 'blocker', status: 'open', stage: 'intake_complete', risk: 'low', effort: 'small', sortIndex: 10 } }), + stderr: '', + }); + } + return Promise.resolve({ + stdout: JSON.stringify({ success: true, count: workItems.length, workItems }), + stderr: '', + }); + }); + it('getNextItem runs wl next --stage -n 10 and parses the first workItem', async () => { const mockExec = vi.fn().mockResolvedValue({ stdout: JSON.stringify({ success: true, workItem: { id: 'WL-ABC', title: 'Some task', status: 'open' } }), @@ -706,14 +784,54 @@ describe('createDowntimeDeps', () => { const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); const result = await deps.getNextAuditCandidate('/repo'); + // AC1 (WL-0MSTLFW14000KPEC): the audit tier must request --root-only so + // completed/in_review CHILDREN are excluded server-side and only parent + // items are audit candidates. expect(mockExec).toHaveBeenCalledWith( 'wl', - ['list', '--status', 'completed', '--stage', 'in_review', '--json'], + ['list', '--status', 'completed', '--stage', 'in_review', '--root-only', '--json'], expect.anything(), ); expect(result).toEqual({ ok: true, candidate: { id: 'WL-STALE', title: 'Stale audit', stage: 'audit' } }); }); + it('getNextAuditCandidate requests root-only items so child items never enter the audit tier (WL-0MSTLFW14000KPEC)', async () => { + // AC3: child items in completed/in_review must never be dispatched as + // audit candidates — only parent (root) items belong in the producer + // review queue. The exclusion is delegated to the wl server filter + // (`--root-only`, WL-0MS964SIA0057ABR), so the client contract is that + // the `wl list` invocation carries --root-only; the mock output below + // simulates what `wl list --root-only` returns (the child with a + // parentId is filtered out server-side and never reaches selection). + const now = Date.now(); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + count: 2, + workItems: [ + { id: 'WL-PARENT', title: 'Parent epic', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100 }, + { id: 'WL-ROOT', title: 'Standalone root', auditedAt: null, updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 200 }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextAuditCandidate('/repo'); + + // AC1/AC3: the invocation MUST carry --root-only — without it, a child + // item sitting in completed/in_review would be returned by `wl list` and + // dispatched independently, which is exactly the bug being fixed. + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['list', '--status', 'completed', '--stage', 'in_review', '--root-only', '--json'], + expect.anything(), + ); + // Only root-level items (no parentId) can ever be candidates. + expect(result).toEqual({ ok: true, candidate: { id: 'WL-PARENT', title: 'Parent epic', stage: 'audit' } }); + }); + it('getNextAuditCandidate returns ok:true with no candidate when no stale/missing-audit item exists', async () => { const mockExec = vi.fn().mockResolvedValue({ stdout: JSON.stringify({ @@ -917,7 +1035,7 @@ describe('createDowntimeDeps', () => { // starving selection (WL-0MSMAYIKX005LLO4 AC2/AC3). expect(mockExec).toHaveBeenCalledWith( 'wl', - ['next', '--stage', 'plan_complete', '--risk', 'low', '--effort', 'small', '-n', expect.any(String), '--json'], + ['next', '--stage', 'plan_complete', '--risk', 'medium', '--effort', 'medium', '-n', expect.any(String), '--json'], expect.anything(), ); // wl next keeps completed items with a stage filter; the client-side @@ -1085,9 +1203,202 @@ describe('createDowntimeDeps', () => { '--stage', 'plan_complete', '--risk', - 'low', + 'medium', '--effort', - 'small', + 'medium', + '-n', + expect.any(String), + '--json', + ], + expect.anything(), + ); + }); + + it('getNextCriticalCandidate runs wl list --priority critical --status open across ALL stages', async () => { + const mockExec = criticalWlMock([ + { id: 'WL-CRIT-IDEA', title: 'Critical idea', status: 'open', stage: 'idea', risk: 'low', effort: 'small', sortIndex: 100 }, + { id: 'WL-CRIT-READY', title: 'Critical ready', status: 'open', stage: 'intake_complete', risk: 'low', effort: 'small', sortIndex: 200 }, + { id: 'WL-CRIT-PLAN', title: 'Critical planned', status: 'open', stage: 'plan_complete', risk: 'medium', effort: 'medium', sortIndex: 300 }, + ]); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextCriticalCandidate('/repo'); + + // The critical lookup must NOT be stage-gated: every open critical + // stage is enumerated, and the lowest sortIndex wins deterministically. + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['list', '--priority', 'critical', '--status', 'open', '-n', expect.any(String), '--json'], + expect.anything(), + ); + expect(result).toEqual({ + ok: true, + candidate: { id: 'WL-CRIT-IDEA', title: 'Critical idea', stage: 'idea' }, + }); + }); + + it('getNextCriticalCandidate includes dependency-blocked items (no wl next exclusion)', async () => { + const mockExec = criticalWlMock([ + { id: 'WL-CRIT-BLOCKED', title: 'Blocked critical', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'small', sortIndex: 10 }, + ]); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextCriticalCandidate('/repo'); + + const [, args] = mockExec.mock.calls[0]; + // wl list does not exclude dependency-blocked items — the critical + // lookup must never opt into wl next's --include-blocked machinery or + // add a stage filter, or blocked critical items would vanish from the + // dispatch queue (Q3 needs them for frontier resolution). + expect(args).not.toContain('--include-blocked'); + expect(args).not.toContain('--stage'); + }); + + it('getNextCriticalCandidate honors the plan_complete implement caps (Q2: above caps not selected)', async () => { + const mockExec = criticalWlMock([ + { id: 'WL-CRIT-HIRISK', title: 'High-risk critical', status: 'open', stage: 'plan_complete', risk: 'high', effort: 'small', sortIndex: 10 }, + { id: 'WL-CRIT-READY', title: 'Critical ready', status: 'open', stage: 'intake_complete', risk: 'low', effort: 'small', sortIndex: 900 }, + ]); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextCriticalCandidate('/repo'); + + // The plan_complete critical is above the risk cap → never + // implement-dispatched; the intake_complete critical wins instead. + expect(result).toEqual({ + ok: true, + candidate: { id: 'WL-CRIT-READY', title: 'Critical ready', stage: 'intake_complete' }, + }); + }); + + it('getNextCriticalCandidate excludes items still at their dispatched-at stage (change-guard)', async () => { + const mockExec = criticalWlMock([ + { id: 'WL-CRIT-ONCE', title: 'already dispatched at idea', status: 'open', stage: 'idea', risk: 'low', effort: 'small', sortIndex: 5 }, + { id: 'WL-CRIT-NEXT', title: 'next critical', status: 'open', stage: 'intake_complete', risk: 'low', effort: 'small', sortIndex: 20 }, + ]); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-CRIT-ONCE', kind: 'intake', stage: 'idea', dispatchedAt: new Date(Date.now() - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextCriticalCandidate(cwd); + + // WL-CRIT-ONCE was dispatched for /skill:intake at idea and is still at + // idea → excluded by the change-guard; the next critical is selected. + expect(result).toEqual({ + ok: true, + candidate: { id: 'WL-CRIT-NEXT', title: 'next critical', stage: 'intake_complete' }, + }); + }); + + it('getNextCriticalCandidate releases an item whose stage advanced past its dispatched-at stage', async () => { + const mockExec = criticalWlMock([ + { id: 'WL-CRIT-ADV', title: 'advanced critical', status: 'open', stage: 'plan_complete', risk: 'low', effort: 'small', sortIndex: 5 }, + ]); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + join(cwd, '.worklog', DOWNTIME_LOG_FILE), + JSON.stringify({ itemId: 'WL-CRIT-ADV', kind: 'plan', stage: 'intake_complete', dispatchedAt: new Date(Date.now() - 3_600_000).toISOString(), cwd }) + '\n', + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextCriticalCandidate(cwd); + + // Marked at intake_complete, now at plan_complete → released. + expect(result).toEqual({ + ok: true, + candidate: { id: 'WL-CRIT-ADV', title: 'advanced critical', stage: 'plan_complete' }, + }); + }); + + it('getNextCriticalCandidate treats a missing dispatch log as empty (fail-safe, dispatch still works)', async () => { + const mockExec = criticalWlMock([ + { id: 'WL-CRIT-FRESH', title: 'fresh worklog critical', status: 'open', stage: 'idea', risk: 'low', effort: 'small', sortIndex: 5 }, + ]); + setExecFileAsync(mockExec as never); + + const cwd = makeTempDir(); // no .worklog/downtime-dispatches.log + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextCriticalCandidate(cwd); + expect(result).toEqual({ + ok: true, + candidate: { id: 'WL-CRIT-FRESH', title: 'fresh worklog critical', stage: 'idea' }, + }); + }); + + it('getNextCriticalCandidate reports ok:true with no candidate when the critical tier is empty', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextCriticalCandidate('/repo')).toEqual({ ok: true, candidate: null }); + }); + + it('getNextCriticalCandidate fails closed ({ok:false}) on wl error (never a silent empty)', async () => { + setExecFileAsync(vi.fn().mockRejectedValue(new Error('wl boom')) as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextCriticalCandidate('/repo')).toEqual({ ok: false }); + }); + + it('getNextCriticalCandidate fails closed ({ok:false}) on malformed wl output', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: 'not json', stderr: '' }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextCriticalCandidate('/repo')).toEqual({ ok: false }); + }); + + it('getNextCriticalCandidate passes a bounded timeout so a hung wl fails closed', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextCriticalCandidate('/repo'); + + const [, , options] = mockExec.mock.calls[0]; + expect(options).toMatchObject({ timeout: DOWNTIME_WL_TIMEOUT_MS }); + }); + + it('getNextCriticalCandidate applies --worklog-dir when the tab resolved a worklog root', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, count: 0, workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + setWorklogDir('/home/user/projects/SorraAgents/.worklog'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.getNextCriticalCandidate('/repo'); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + [ + '--worklog-dir', + '/home/user/projects/SorraAgents/.worklog', + 'list', + '--priority', + 'critical', + '--status', + 'open', '-n', expect.any(String), '--json', @@ -1096,12 +1407,105 @@ describe('createDowntimeDeps', () => { ); }); + it('getNextCriticalCandidate returns the frontier blocker when the selected critical is dependency-blocked (F3 Q3)', async () => { + // The selected critical item depends on an OPEN blocker (outbound + // depends-on edge). Per decision Q3 the dep follows the blocking + // chain and returns the nearest open blocker with ITS stage — the + // dispatch tier then runs the blocker's stage-appropriate skill. + const mockExec = criticalWlMock( + [ + { id: 'WL-CRIT-CHILD', title: 'Critical child', status: 'open', stage: 'idea', risk: 'low', effort: 'small', sortIndex: 5 }, + ], + { + 'WL-CRIT-CHILD': [ + { id: 'WL-BLOCKER', title: 'Blocking critical', status: 'open', priority: 'critical', direction: 'depends-on' }, + ], + }, + ); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextCriticalCandidate('/repo'); + + // The dep queries the candidate's edges with `wl dep list`, then + // enriches the blocker via `wl show` (dep edges lack stage/risk/ + // effort/sortIndex), and returns the frontier blocker — unblocked + // BLOCKER resolves to itself, carrying its intake_complete stage. + expect(result).toEqual({ + ok: true, + candidate: { id: 'WL-BLOCKER', title: 'blocker', stage: 'intake_complete' }, + }); + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['dep', 'list', 'WL-CRIT-CHILD', '--json'], + expect.anything(), + ); + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['show', 'WL-BLOCKER', '--json'], + expect.anything(), + ); + }); + + it('getNextCriticalCandidate returns null candidate when the blocking chain bottoms in a closed item (F3: fall through to tiers)', async () => { + // The selected critical item's blocker is closed (completed) — the + // frontier chain bottoms and the dep reports a null candidate so the + // critical tier falls through to the normal tier order. + const mockExec = criticalWlMock( + [ + { id: 'WL-CRIT-CHILD', title: 'Critical child', status: 'open', stage: 'idea', risk: 'low', effort: 'small', sortIndex: 5 }, + ], + { + 'WL-CRIT-CHILD': [ + { id: 'WL-CLOSED-BLOCKER', title: 'Closed blocker', status: 'completed', priority: 'critical', direction: 'depends-on' }, + ], + }, + { + 'WL-CLOSED-BLOCKER': { id: 'WL-CLOSED-BLOCKER', title: 'Closed blocker', status: 'completed', stage: 'completed', risk: 'low', effort: 'small', sortIndex: 3 }, + }, + ); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextCriticalCandidate('/repo'); + + expect(result).toEqual({ ok: true, candidate: null }); + }); + + it('getNextCriticalCandidate fails closed to a strike when wl dep list errors (F3 AC5: no silent fall-through)', async () => { + const mockExec = vi.fn().mockImplementation((_bin: string, args: string[]) => { + if (args[0] === 'dep') return Promise.reject(new Error('wl dep boom')); + return Promise.resolve({ + stdout: JSON.stringify({ + success: true, + count: 1, + workItems: [ + { id: 'WL-CRIT-CHILD', title: 'Critical child', status: 'open', stage: 'idea', risk: 'low', effort: 'small', sortIndex: 5 }, + ], + }), + stderr: '', + }); + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const result = await deps.getNextCriticalCandidate('/repo'); + + // A dependency look-up failure must NOT look like an empty tier — it + // is a wl-error strike. + expect(result).toEqual({ ok: false }); + }); + it('end-to-end: two consecutive idle windows dispatch a single unaudited candidate exactly once', async () => { // AC5 (parent): with one unaudited completed/in_review candidate and two // consecutive idle windows (no audit recorded between), the worker // dispatches exactly once. The first dispatch writes the durable marker - // (kind:audit) to the shared log; the second window's candidate selection - // reads the marker back and excludes the item, so no second dispatch. + // (kind:audit) to the shared log. The second window's ACTIVE-AUDIT + // single-flight check (WL-0MT3PHW4I002SNOV) then sees the non-stale + // marker mapping to the still-in_progress item and skips the audit tier + // outright — reporting reason 'audit-in-flight' (never 'no-candidate', + // so the no-candidate cooldown is not entered while the audit runs) — + // so no second dispatch. const now = Date.now(); const candidate = { id: 'WL-ONCE', @@ -1110,9 +1514,18 @@ describe('createDowntimeDeps', () => { updatedAt: new Date(now - 60_000).toISOString(), sortIndex: 100, }; - // wl list always returns the same single candidate; wl next returns no - // candidate so a second (wrong) audit dispatch would be the only path. + // wl list always returns the same single candidate (both for the + // audit-candidate lookup and, as the still-in_progress item, for the + // active-audit check); wl next returns no candidate so a second (wrong) + // audit dispatch would be the only path. const mockExec = vi.fn((_bin: string, args: string[]) => { + if (args.includes('--status') && args.includes('in_progress')) { + // Active-audit check: the dispatched item is still in_progress. + return Promise.resolve({ + stdout: JSON.stringify({ success: true, count: 1, workItems: [candidate] }), + stderr: '', + }); + } if (args[0] === 'list') { return Promise.resolve({ stdout: JSON.stringify({ success: true, count: 1, workItems: [candidate] }), @@ -1136,10 +1549,12 @@ describe('createDowntimeDeps', () => { const entries = await readDowntimeLogEntries(cwd); expect(entries.some((e) => e.itemId === 'WL-ONCE' && e.kind === 'audit')).toBe(true); - // Idle window 2: the marker excludes the item; nothing else to dispatch. + // Idle window 2: the active-audit check skips the audit tier + // (audit-in-flight); nothing else to dispatch. const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd }); expect(second.dispatched).toBe(false); - expect(second.reason).toBe('no-candidate'); + expect(second.reason).toBe('audit-in-flight'); + expect(second.kind).toBeUndefined(); expect(spawnFn).toHaveBeenCalledTimes(1); }); @@ -1249,6 +1664,32 @@ describe('createDowntimeDeps', () => { expect.anything(), ); }); + + it('spawnAgentPane honours an explicit paneName (scheduled prompts run as Downtime )', async () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn(), once: vi.fn() })); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map', spawnFn); + + await deps.spawnAgentPane('/skill:refactor', { + model: 'plan', + cwd: '/repo', + paneName: 'Downtime refactor', + }); + + expect(spawnFn).toHaveBeenCalledWith( + '/path/to/send-to-pi.sh', + [ + '--pane-name', + 'Downtime refactor', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + '/skill:refactor', + ], + { cwd: '/repo' }, + ); + }); }); describe('createDowntimeDeps recordDispatch', () => { @@ -1379,6 +1820,32 @@ describe('createDowntimeDeps recordDispatch', () => { }), ).resolves.toBe(false); }); + + it('skips the wl comment for scheduled-prompt dispatches (noItemComment, AC4)', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + setExecFileAsync(mockExec as never); + const cwd = makeTempDir(); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.recordDispatch({ + itemId: 'refactor', + kind: 'scheduled', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd, + noItemComment: true, + }); + + // There is no work item — no wl comment is attempted; the rolling-log + // marker is the only trace. + expect(mockExec).not.toHaveBeenCalled(); + const raw = readFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), 'utf8'); + const entry = JSON.parse(raw.split('\n').filter((l) => l.trim() !== '')[0]) as { + kind?: string; + itemId?: string; + }; + expect(entry.kind).toBe('scheduled'); + expect(entry.itemId).toBe('refactor'); + }); }); describe('createDowntimeDeps recordError', () => { @@ -1492,6 +1959,158 @@ describe('createDowntimeDeps recordDispatchFailure', () => { }); }); +// --------------------------------------------------------------------------- +// createDowntimeDeps scheduled-prompts wiring (WL-0MSS1Q5ER007QDKX) +// +// The scheduled-prompts tier reads the project-local config at +// /.worklog/scheduled-prompts.json through getDueScheduledPrompt and +// persists lastTriggeredAt through recordScheduledPromptTrigger (atomic +// tmp+rename). Both are fail-closed: absent/malformed config ⇒ no dispatch. +// --------------------------------------------------------------------------- + +describe('createDowntimeDeps getDueScheduledPrompt', () => { + afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; + }); + + const refactor: ScheduledPrompt = { + id: 'refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, + }; + + it('reads the config and returns the first due entry (null lastTriggeredAt = due)', async () => { + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + scheduledPromptsPath(cwd), + JSON.stringify({ entries: [refactor] }), + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const due = await deps.getDueScheduledPrompt(cwd); + + expect(due).toEqual(refactor); + }); + + it('returns null when the only entries are not yet due', async () => { + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + scheduledPromptsPath(cwd), + JSON.stringify({ + entries: [ + { + ...refactor, + lastTriggeredAt: new Date(Date.now() - 86_400_000).toISOString(), // 1 day ago, due in 2 + }, + ], + }), + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getDueScheduledPrompt(cwd)).toBeNull(); + }); + + it('returns null for an absent config (fail-closed, logged)' , async () => { + const cwd = makeTempDir(); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getDueScheduledPrompt(cwd)).toBeNull(); + }); + + it('returns null for a malformed config (fail-closed, logged)', async () => { + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync(scheduledPromptsPath(cwd), '{broken json', 'utf8'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getDueScheduledPrompt(cwd)).toBeNull(); + }); + + it('skips invalid entries (fail-closed) and still returns the first valid due one', async () => { + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + scheduledPromptsPath(cwd), + JSON.stringify({ + entries: [ + { id: 'broken', prompt: '', intervalDays: 0, lastTriggeredAt: null }, + refactor, + ], + }), + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getDueScheduledPrompt(cwd)).toEqual(refactor); + }); +}); + +describe('createDowntimeDeps recordScheduledPromptTrigger', () => { + afterEach(() => { + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; + }); + + const refactor: ScheduledPrompt = { + id: 'refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, + }; + const at = '2026-08-17T12:00:00.000Z'; + + it('persists lastTriggeredAt atomically and preserves the other entries', async () => { + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + scheduledPromptsPath(cwd), + JSON.stringify({ entries: [refactor, { ...refactor, id: 'weekly', intervalDays: 7 }] }), + 'utf8', + ); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await expect(deps.recordScheduledPromptTrigger(cwd, 'refactor', at)).resolves.toBe(true); + + const parsed = JSON.parse(readFileSync(scheduledPromptsPath(cwd), 'utf8')) as { + entries: ScheduledPrompt[]; + }; + expect(parsed.entries.find((e) => e.id === 'refactor')?.lastTriggeredAt).toBe(at); + expect(parsed.entries.find((e) => e.id === 'weekly')?.lastTriggeredAt).toBeNull(); + // No tmp file left behind (atomic tmp+rename). + expect(readdirSync(join(cwd, '.worklog'))).toEqual([SCHEDULED_PROMPTS_FILE]); + }); + + it('resolves false when the config is absent or malformed (fail-closed, never throws)', async () => { + const cwd = makeTempDir(); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await expect(deps.recordScheduledPromptTrigger(cwd, 'refactor', at)).resolves.toBe(false); + + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync(scheduledPromptsPath(cwd), '{broken', 'utf8'); + await expect(deps.recordScheduledPromptTrigger(cwd, 'refactor', at)).resolves.toBe(false); + }); + + it('resolves false for an unknown entry id (fail-closed)', async () => { + const cwd = makeTempDir(); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync(scheduledPromptsPath(cwd), JSON.stringify({ entries: [refactor] }), 'utf8'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await expect(deps.recordScheduledPromptTrigger(cwd, 'nope', at)).resolves.toBe(false); + }); +}); + // --------------------------------------------------------------------------- // Agent-pane association capture (WL-0MSBQUJQX005RAT9) // @@ -1502,10 +2121,11 @@ describe('createDowntimeDeps recordDispatchFailure', () => { // --------------------------------------------------------------------------- describe('buildSendToPiArgs — pane-id capture flag', () => { - it('forwards --pane-id-file when provided', () => { + it('forwards --pane-id-file when provided (with --no-focus)', () => { expect( buildSendToPiArgs('/skill:implement ', '/project', 'code', '/tmp/pane.json'), ).toEqual([ + '--no-focus', '--cwd', '/project', '--model', @@ -1516,8 +2136,9 @@ describe('buildSendToPiArgs — pane-id capture flag', () => { ]); }); - it('omits --pane-id-file when not provided (backward compatible)', () => { + it('omits --pane-id-file when not provided (backward compatible, --no-focus retained)', () => { expect(buildSendToPiArgs('/skill:implement ', '/project')).toEqual([ + '--no-focus', '--cwd', '/project', '/skill:implement ', @@ -1626,3 +2247,199 @@ describe('capturePaneIdFromFile — dispatch-time association capture', () => { expect(record).not.toHaveBeenCalled(); }); }); + +describe('createDowntimeDeps rotation wiring (WL-0MSSRED76008LGB6)', () => { + afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + }); + + it('getNextItem rotates within a tied-priority group via the shared cursor file', async () => { + const cwd = makeTempDir(); + const worklogDir = join(cwd, '.worklog'); + mkdirSync(worklogDir, { recursive: true }); + // Two same-priority candidates; a pre-advanced cursor file selects the second first. + writeFileSync( + join(worklogDir, 'downtime-round-robin.json'), + JSON.stringify({ high: { cursor: 1, version: 2 } }), + ); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + workItems: [ + { workItem: { id: 'WL-A', title: 'A', status: 'open', priority: 'high', sortIndex: 10 } }, + { workItem: { id: 'WL-B', title: 'B', status: 'open', priority: 'high', sortIndex: 20 } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + // Cursor 1 % 2 = 1 → selects WL-B first + const result = await deps.getNextItem('intake_complete', cwd); + expect(result).toEqual({ + ok: true, + candidate: { id: 'WL-B', title: 'B', stage: 'intake_complete', status: 'open', sortIndex: 20, priority: 'high' }, + }); + }); + + it('getNextItem persists the cursor advance so a second call rotates to the next tied item', async () => { + const cwd = makeTempDir(); + const worklogDir = join(cwd, '.worklog'); + mkdirSync(worklogDir, { recursive: true }); + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + success: true, + workItems: [ + { workItem: { id: 'WL-A', title: 'A', status: 'open', priority: 'high', sortIndex: 10 } }, + { workItem: { id: 'WL-B', title: 'B', status: 'open', priority: 'high', sortIndex: 20 } }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const first = await deps.getNextItem('intake_complete', cwd); + expect(first.ok && first.candidate?.id).toBe('WL-A'); // cursor 0 % 2 = 0 + + const second = await deps.getNextItem('intake_complete', cwd); + expect(second.ok && second.candidate?.id).toBe('WL-B'); // cursor advanced → 1 % 2 = 1 + }); +}); + +// --------------------------------------------------------------------------- +// Background (no-pane) dispatch helpers (WL-0MSJLD1I70045ZUL) +// --------------------------------------------------------------------------- + +describe('buildBackgroundLogPath', () => { + it('places logs under the tmpdir herdr-background-logs directory', () => { + const path = buildBackgroundLogPath('wl update --priority high'); + expect(path).toContain('herdr-background-logs'); + expect(path.endsWith('.log')).toBe(true); + }); + + it('produces distinct paths for different commands', () => { + const a = buildBackgroundLogPath('wl update --priority high'); + const b = buildBackgroundLogPath('wl update --priority low'); + expect(a).not.toBe(b); + }); +}); + +describe('spawnBackgroundShell', () => { + it('spawns bash -c detached with stdout/stderr redirected to the log file', () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const openSyncFn = vi.fn(() => 7); + const child = spawnBackgroundShell( + 'wl reviewed WL-1 false', + '/project', + '/tmp/herdr-background-logs/x.log', + { spawn: spawnFn as never, openSync: openSyncFn }, + ); + expect(spawnFn).toHaveBeenCalledWith( + 'bash', + ['-c', 'wl reviewed WL-1 false'], + expect.objectContaining({ + detached: true, + stdio: ['ignore', 7, 7], + cwd: '/project', + }), + ); + expect(openSyncFn).toHaveBeenCalledWith('/tmp/herdr-background-logs/x.log', 'a'); + expect(child.unref).toHaveBeenCalled(); + }); + + it('fires onExit when the background child exits (completion → refresh)', () => { + let exitHandler: ((code: number | null, signal: string | null) => void) | undefined; + const child = { + unref: vi.fn(), + on: vi.fn((_event: string, cb: (code: number | null, signal: string | null) => void) => { + exitHandler = cb; + return child; + }), + } as never; + const onExit = vi.fn(); + spawnBackgroundShell('wl reviewed WL-1 false', '/project', '/tmp/log', { + spawn: vi.fn(() => child), + openSync: () => 7, + onExit, + }); + expect(child.on).toHaveBeenCalledWith('exit', expect.any(Function)); + // Simulate the child exiting with code 0 — the refresh is triggered. + exitHandler?.(0, null); + expect(onExit).toHaveBeenCalledWith(0, null); + }); + + it('fires onExit for a non-zero exit (failure still refreshes)', () => { + let exitHandler: ((code: number | null, signal: string | null) => void) | undefined; + const child = { + unref: vi.fn(), + on: vi.fn((_event: string, cb: (code: number | null, signal: string | null) => void) => { + exitHandler = cb; + return child; + }), + } as never; + const onExit = vi.fn(); + spawnBackgroundShell('false', '/project', '/tmp/log', { + spawn: vi.fn(() => child), + openSync: () => 7, + onExit, + }); + exitHandler?.(1, null); + expect(onExit).toHaveBeenCalledWith(1, null); + }); +}); + +describe('spawnBackgroundPi', () => { + it('spawns pi -p --mode json with the model and prompt, detached with log redirect', () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const openSyncFn = vi.fn(() => 9); + const child = spawnBackgroundPi( + '/skill:audit WL-1', + '/project', + 'plan', + '/tmp/herdr-background-logs/y.log', + { spawn: spawnFn as never, openSync: openSyncFn }, + ); + expect(spawnFn).toHaveBeenCalledWith( + 'pi', + ['-p', '--mode', 'json', '--model', 'plan', '/skill:audit WL-1'], + expect.objectContaining({ + detached: true, + stdio: ['ignore', 9, 9], + cwd: '/project', + }), + ); + expect(child.unref).toHaveBeenCalled(); + }); + + it('omits --model when no model is provided', () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const openSyncFn = vi.fn(() => 9); + spawnBackgroundPi( + '/intake My item', + '/project', + undefined, + '/tmp/herdr-background-logs/z.log', + { spawn: spawnFn as never, openSync: openSyncFn }, + ); + expect(spawnFn).toHaveBeenCalledWith( + 'pi', + ['-p', '--mode', 'json', '/intake My item'], + expect.any(Object), + ); + }); + + it('registers an exit handler when onExit is provided (completion → refresh)', () => { + const on = vi.fn(); + const child = { on, unref: vi.fn() } as never; + const onExit = vi.fn(); + spawnBackgroundPi('/skill:audit WL-1', '/project', undefined, '/tmp/log', { + spawn: vi.fn(() => child), + openSync: () => 9, + onExit, + }); + expect(on).toHaveBeenCalledWith('exit', expect.any(Function)); + }); +}); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index b9e329996..9eff5f651 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -20,11 +20,11 @@ * 1 - wl CLI not found */ -import { spawn } from 'child_process'; +import { spawn, type ChildProcess, type SpawnOptions } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, resolve, join } from 'path'; import { tmpdir } from 'node:os'; -import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'node:fs'; import { resolveWorklogRoot } from '@worklog/shared/worklog-paths'; import { checkWlAvailable, @@ -36,6 +36,7 @@ import { buildWlArgs, } from './fetcher.js'; import { AgentTracker, AGENT_PANES_FILE, mergeAgentStates } from './agent-tracker.js'; +import { HerdrEventSubscriber, resolveSocketPath } from './events.js'; import { runWorklistTui, getTermSize } from './worklist.js'; import { loadShortcutConfig } from './shortcut-config.js'; import { readCodeFreezeStatusForRoot } from './code-freeze.js'; @@ -48,15 +49,21 @@ import { parseNextCandidatesOutput, parseAuditCandidatesOutput, parseImplementCandidatesOutput, + parseCriticalCandidatesOutput, + parseDepListBlockersOutput, + parseShownWorkItem, selectAuditCandidate, selectImplementCandidate, + selectCriticalCandidate, selectNextCandidate, + resolveDependencyFrontier, toDowntimeCandidate, toImplementCandidate, skillKindFromPrompt, type DowntimeWorker, type DowntimeWorkerDeps, type DowntimeStage, + type CriticalCandidate, type DowntimeCandidate, type DowntimeDispatchEvent, type DowntimeDispatchFailureEvent, @@ -66,18 +73,36 @@ import { type DowntimeClaimResult, type DowntimeSpawn, type DowntimeSpawnResult, + type ScheduledPrompt, + type DowntimeItemResult, + parseShowItemOutput, defaultDowntimeSpawn, buildDowntimeDispatchComment, DOWNTIME_WL_TIMEOUT_MS, + DOWNTIME_AUDIT_STALE_WINDOW_MS, + parseInProgressOutput, + type DowntimeActiveAuditResult, } from './downtime-worker.js'; +import { + createModeSwitchWorker, + type ModeSwitchWorker, +} from './mode-switch-worker.js'; +import { createRoundRobinRegistry, type RoundRobinRegistry } from './downtime-round-robin.js'; import { appendDowntimeLogEntry, auditDispatchedItemIds, implementDispatchedItemIds, planDispatchedItemStages, intakeDispatchedItemStages, + dispatchedItemStages, readDowntimeLogEntries, + recentAuditDispatchedItemIds, } from './downtime-log.js'; +import { + getDueScheduledPrompt as getFirstDuePrompt, + loadScheduledPrompts, + updateScheduledPromptLastTriggered, +} from './scheduled-prompts.js'; // Resolve path to the send-to-pi.sh script (relative to this source file) // At runtime (tsx or dist), __dirname equivalent from import.meta.url @@ -163,6 +188,11 @@ export function stripAgentPromptPrefix(command: string): string { * WL-0MSBQUJQX005RAT9), `--pane-id-file ` is forwarded so the script * writes the new pane ID immediately after the split succeeds — the plugin * reads it back to record the work-item ↔ pane association. + * + * Every selection-list agent dispatch passes `--no-focus` (WL-0MSHIA53D009DJOT) + * so shared/send-to-pi.sh skips its final zoom and the selection list keeps + * focus while the pi agent pane opens in the background. The shared script's + * own default (focus on) is unchanged for its other consumers. */ export function buildSendToPiArgs( command: string, @@ -171,7 +201,7 @@ export function buildSendToPiArgs( paneIdFile?: string, ): string[] { const agentPrompt = stripAgentPromptPrefix(command); - const args = ['--cwd', targetCwd]; + const args = ['--no-focus', '--cwd', targetCwd]; if (model) { args.push('--model', model); } @@ -182,6 +212,19 @@ export function buildSendToPiArgs( return args; } +/** + * Build the argument vector for spawning `scripts/run-in-pane.sh` for a + * command-output pane (`!!`/`!`-prefixed pane route and plain shell stdout + * route). Mirrors `buildSendToPiArgs`: `--no-focus` (WL-0MSHIA53D009DJOT) is + * always passed so opening the command-output pane does not steal focus from + * the selection list, followed by `--cwd ` so the pane starts in + * the resolved project root. `run-in-pane.sh` parses both options at the + * head of argv; everything else is the command itself. + */ +export function buildRunInPaneArgs(command: string, targetCwd: string): string[] { + return ['--no-focus', '--cwd', targetCwd, command]; +} + /** * Parse the pane-ID file written by send-to-pi.sh (`--pane-id-file`). * @@ -201,6 +244,141 @@ export function parsePaneIdFile(raw: string): string | undefined { } } +// ── Background (no-pane) dispatch (WL-0MSJLD1I70045ZUL) ──────────────── + +/** + * Directory (under the OS tmpdir) holding per-run background-dispatch logs. + * A single known directory so logs can be located for inspection; cleanup + * of old logs is out of scope (each filename carries a timestamp + pid). + */ +export const BACKGROUND_LOG_DIR = 'herdr-background-logs'; + +/** + * Build a per-run log file path for a background (no-pane) shortcut + * dispatch (WL-0MSJLD1I70045ZUL). + * + * Returns `/herdr-background-logs/herdr---.log` + * where the slug is a sanitised prefix of the command — concurrent + * dispatches never collide (timestamp + pid) and each run is individually + * inspectable. The caller logs the returned path to stderr so the user can + * locate the file. + */ +export function buildBackgroundLogPath(command: string): string { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const slug = + command.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || + 'command'; + return join(tmpdir(), BACKGROUND_LOG_DIR, `herdr-${stamp}-${process.pid}-${slug}.log`); +} + +/** + * Injectable spawn/filesystem dependencies for the background spawn + * helpers (tests replace these so no real subprocess or log file is needed). + */ +export interface BackgroundSpawnDeps { + spawn?: (command: string, args: string[], options: SpawnOptions) => ChildProcess; + openSync?: (path: string, flags: string) => number; + /** Called when the background child process exits — triggers a TUI refresh. */ + onExit?: (exitCode: number | null, signal: string | null) => void; +} + +/** + * Open a background log file for append, creating the directory first. + * Returns the fd, or -1 when the log cannot be opened (caller falls back to + * 'ignore' stdio so a logging failure never blocks dispatch). + */ +function openBackgroundLog(logPath: string, open: (path: string, flags: string) => number): number { + try { + mkdirSync(dirname(logPath), { recursive: true }); + return open(logPath, 'a'); + } catch (err) { + process.stderr.write( + `[worklog-plugin] Could not open background log ${logPath}: ${(err as Error).message}\n`, + ); + return -1; + } +} + +/** + * Spawn a shell command detached with stdout/stderr redirected to a log + * file — the `open_pane: false` execution path for `!!`/`!` shell commands + * (WL-0MSJLD1I70045ZUL). + * + * Mirrors the existing pane-spawn pattern (`detached: true` + `unref()`) + * so the TUI event loop and process lifecycle are unaffected: the parent + * exits independently while the child runs to completion, appending its + * output to the log. When the log file cannot be opened, stdio falls back + * to 'ignore' (logged) — a logging failure never blocks dispatch. + */ +export function spawnBackgroundShell( + command: string, + targetCwd: string, + logPath: string, + deps: BackgroundSpawnDeps = {}, +): ChildProcess { + const spawnFn = deps.spawn ?? spawn; + const open = deps.openSync ?? openSync; + const fd = openBackgroundLog(logPath, open); + const child = spawnFn('bash', ['-c', command], { + detached: true, + stdio: fd >= 0 ? (['ignore', fd, fd] as const) : 'ignore', + cwd: targetCwd, + env: { ...process.env, HERDR_RESOLVED_CWD: targetCwd }, + }); + child.unref(); // Allow the parent to exit independently + + // On exit, fire the refresh callback (fire-and-forget — never blocks). + if (deps.onExit) { + child.on('exit', deps.onExit); + } + + return child; +} + +/** + * Spawn a headless pi run detached with stdout/stderr redirected to a log + * file — the `open_pane: false` execution path for agent commands + * (`/skill:*`, `/intake`, `/plan`, `/prompt:`) (WL-0MSJLD1I70045ZUL). + * + * Uses the established headless pattern `pi -p --mode json` (see + * `~/.pi/agent/skills/audit/scripts/audit_runner.py`, WL-0MSG4VSP10020NL7): + * no pane is created, so the work-item ↔ pane association + * (WL-0MSBQUJQX005RAT9) is skipped. The entry's `model` is honored via + * `--model `; free-form `/prompt:` commands have their routing + * prefix stripped so pi receives only the prompt text. Detached + unref'd + * like the pane-spawn paths; log open failure falls back to 'ignore'. + */ +export function spawnBackgroundPi( + prompt: string, + targetCwd: string, + model: string | undefined, + logPath: string, + deps: BackgroundSpawnDeps = {}, +): ChildProcess { + const spawnFn = deps.spawn ?? spawn; + const open = deps.openSync ?? openSync; + const fd = openBackgroundLog(logPath, open); + const args = ['-p', '--mode', 'json']; + if (model) { + args.push('--model', model); + } + args.push(prompt); + const child = spawnFn('pi', args, { + detached: true, + stdio: fd >= 0 ? (['ignore', fd, fd] as const) : 'ignore', + cwd: targetCwd, + env: { ...process.env, HERDR_RESOLVED_CWD: targetCwd }, + }); + child.unref(); // Allow the parent to exit independently + + // On exit, fire the refresh callback (fire-and-forget — never blocks). + if (deps.onExit) { + child.on('exit', deps.onExit); + } + + return child; +} + // ── Pane-ID capture (WL-0MSBQUJQX005RAT9) ───────────────────────────── /** Poll interval for the pane-ID file capture loop. */ @@ -341,6 +519,39 @@ export async function claimItemForAgentCommand(command: string): Promise { } } +/** + * Resolve the dependency blockers of a work item via `wl dep list` + * (F3, decision Q3). The outbound `depends-on` edges of the queried item + * are its blockers; the dep-list edges carry only id/title/status/ + * priority, so each blocker is enriched via `wl show` to obtain the full + * stage/risk/effort/sortIndex fields the frontier resolution needs + * (stage→skill mapping + implement caps, Q2). THROWS on a wl or parse + * failure — the caller's fail-closed catch converts it into a strike + * (`{ok:false}`), so a dependency look-up failure never masquerades as an + * empty frontier (AC5: no silent fall-through). + */ +async function fetchCriticalBlockers(cwd: string, itemId: string): Promise { + const { stdout } = await getExecFileAsync()( + 'wl', + buildWlArgs(['dep', 'list', itemId, '--json']), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const blockerRefs = parseDepListBlockersOutput(stdout); + if (blockerRefs === null) throw new Error('wl dep list parse failure'); + const blockers: CriticalCandidate[] = []; + for (const ref of blockerRefs) { + const { stdout: showOut } = await getExecFileAsync()( + 'wl', + buildWlArgs(['show', ref.id, '--json']), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const full = parseShownWorkItem(showOut); + if (full === null) throw new Error(`wl show parse failure for ${ref.id}`); + blockers.push(full); + } + return blockers; +} + /** * Build the real downtime-worker dependencies (WL-0MSF49FMW009M06K): * `wl next --stage --json` for dispatch selection, `wl update @@ -354,6 +565,21 @@ export function createDowntimeDeps( assignee: string, spawnFn: DowntimeSpawn = defaultDowntimeSpawn, ): DowntimeWorkerDeps { + // Shared round-robin registry (WL-0MSSRED76008LGB6): one per worklog root + // (`/.worklog/downtime-round-robin.json`), created lazily so each + // selection re-reads the durable cursor from disk — cross-instance + // rotation works because the file is the source of truth. Fail-open: a + // missing/unreadable file degrades to no rotation (cursor 0). + const registries = new Map(); + const registryFor = (cwd: string): RoundRobinRegistry => { + const worklogDir = join(cwd, '.worklog'); + let registry = registries.get(worklogDir); + if (!registry) { + registry = createRoundRobinRegistry({ worklogDir }); + registries.set(worklogDir, registry); + } + return registry; + }; return { async getNextItem(stage: DowntimeStage, cwd: string): Promise { try { @@ -388,7 +614,7 @@ export function createDowntimeDeps( : stage === 'idea' ? intakeDispatchedItemStages(entries) : new Map(); - const selected = selectNextCandidate(candidates, dispatched); + const selected = selectNextCandidate(candidates, dispatched, registryFor(cwd)); return { ok: true, candidate: selected }; } catch { // Transient wl failure → fail closed to busy: no dispatch, and the @@ -406,15 +632,19 @@ export function createDowntimeDeps( try { // Audit tier (WL-0MSI8H3HP000K0RG): select the first completed / // in_review item WITHOUT a valid audit so the producer-review queue - // (the release gate) is drained during idle time. Same fail-closed - // semantics as getNextItem, with the same error channel + // (the release gate) is drained during idle time. --root-only + // (WL-0MSTLFW14000KPEC): only PARENT items are audit candidates — + // completed/in_review children (sub-tasks) are never dispatched + // independently; the producer reviews deliverable units (parents), + // whose audits cover their children. Same fail-closed semantics as + // getNextItem, with the same error channel // (WL-0MSLWJ2KP0002SV0): a wl/parse failure resolves {ok:false} — a // CLI-error strike — NOT a null that is indistinguishable from a // genuinely empty audit tier. The bounded timeout // (WL-0MSJIPHD0001L1J9) applies here too. const { stdout } = await getExecFileAsync()( 'wl', - buildWlArgs(['list', '--status', 'completed', '--stage', 'in_review', '--json']), + buildWlArgs(['list', '--status', 'completed', '--stage', 'in_review', '--root-only', '--json']), { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, ); const candidates = parseAuditCandidatesOutput(stdout); @@ -430,7 +660,7 @@ export function createDowntimeDeps( // the audit tier). const entries = await readDowntimeLogEntries(cwd); const dispatchedAuditIds = auditDispatchedItemIds(entries); - const selected = selectAuditCandidate(candidates, Date.now(), dispatchedAuditIds); + const selected = selectAuditCandidate(candidates, Date.now(), dispatchedAuditIds, registryFor(cwd)); // Genuinely empty audit tier → ok:true with no candidate (unchanged // empty behaviour); a selected item → ok:true with the candidate. return selected === null @@ -443,11 +673,58 @@ export function createDowntimeDeps( return { ok: false }; } }, + async getActiveAudit(cwd: string): Promise { + try { + // Active-audit single-flight (WL-0MT3PHW4I002SNOV): does any + // non-stale kind=audit dispatch marker map to an item still + // `in_progress`? Dispatch-log-first (per plan decision Q2): read the + // shared rolling dispatch log — the cross-instance source of truth + // (an audit dispatched by ANY instance, leader or not, is seen + // here) — and keep only markers within the 2h stale window + // (DOWNTIME_AUDIT_STALE_WINDOW_MS). A marker older than the window + // is treated as stale (the audit pane may have crashed without + // updating the work item) and ignored. Fail-safe: readDowntimeLog + // Entries never throws — a missing or unreadable log is empty, so + // a fresh worklog never reports a phantom active audit. + const entries = await readDowntimeLogEntries(cwd); + const auditCandidateIds = recentAuditDispatchedItemIds( + entries, + DOWNTIME_AUDIT_STALE_WINDOW_MS, + ); + if (auditCandidateIds.size === 0) { + // Cheap fast-path: no non-stale audit markers → no active audit + // (no worklog query needed). + return { ok: true, active: false }; + } + // Intersect with the worklog's in_progress items: a marker only + // counts as an ACTIVE audit while its item is still in_progress + // (dispatched but not yet completed/reviewed — the audit pane + // transitions the item when it finishes). The bounded timeout + // (WL-0MSJIPHD0001L1J9) kills a hung wl child. + const { stdout } = await getExecFileAsync()( + 'wl', + buildWlArgs(['list', '--status', 'in_progress', '--json']), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const inProgress = parseInProgressOutput(stdout); + if (inProgress === null) { + // Unparseable query → the check cannot complete → fail-open. + return { ok: false }; + } + const active = [...auditCandidateIds].some((id) => inProgress.has(id)); + return { ok: true, active }; + } catch { + // Fail-open: a wl failure yields {ok:false} — the dispatcher skips + // the audit tier and falls through to the next tier; dispatch is + // never blocked by an unanswerable check (fail-safe). + return { ok: false }; + } + }, async getNextImplementCandidate(cwd: string): Promise { try { // Implement tier (WL-0MSMAYPQP001FLR6): select the highest-priority - // open plan_complete item with risk Low / effort Small|XS. The wl - // next server-side at-most filters (--risk low --effort small, + // open plan_complete item with risk ≤ Medium / effort ≤ Medium. The wl + // next server-side at-most filters (--risk medium --effort medium, // delivered by WL-0MSMAIP5F003WAGG) do the heavy lifting; a // generous batch (-n 10) is fetched so completed epics (which wl // next keeps under a stage filter) can be filtered out client-side @@ -462,9 +739,9 @@ export function createDowntimeDeps( '--stage', 'plan_complete', '--risk', - 'low', + 'medium', '--effort', - 'small', + 'medium', '-n', '10', '--json', @@ -481,13 +758,143 @@ export function createDowntimeDeps( // implement-tier dispatch still works on a fresh worklog. const entries = await readDowntimeLogEntries(cwd); const dispatchedImplementIds = implementDispatchedItemIds(entries); - const selected = selectImplementCandidate(candidates, dispatchedImplementIds); + const selected = selectImplementCandidate(candidates, dispatchedImplementIds, registryFor(cwd)); return selected === null ? null : toImplementCandidate(selected); } catch { // Fail-closed: a wl failure yields no candidate (no dispatch). return null; } }, + async getNextCriticalCandidate(cwd: string): Promise { + try { + // Critical-first tier (WL-0MT3FM8VA005XBHE): enumerate open + // critical items across ALL stages via `wl list --priority critical + // --status open -n 10 --json`. Unlike `wl next --stage X` (which + // excludes dependency-blocked items by default), `wl list` returns + // blocked critical items too — the looked-up candidate may be + // dependency-blocked, and the frontier resolution (F3) decides the + // dispatch target if it is. A generous batch (-n 10) is fetched so + // a change-guard-excluded top candidate does not starve selection + // of the next one. The bounded timeout (WL-0MSJIPHD0001L1J9) kills + // a hung wl child so the lookup fails closed to a strike instead + // of wedging the dispatch task. + const { stdout } = await getExecFileAsync()( + 'wl', + buildWlArgs(['list', '--priority', 'critical', '--status', 'open', '-n', '10', '--json']), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const candidates = parseCriticalCandidatesOutput(stdout); + if (candidates === null) return { ok: false }; + // Dispatched-marker change-guard (WL-0MSRBFFLN005W3VT design point + // 3 semantics): read the shared rolling dispatch log for THIS + // worklog root and exclude any critical item the downtime worker + // has already dispatched for its stage-appropriate skill (kind + // intake/plan/implement markers) while the item is still at its + // dispatched-at stage; a stage advancement releases it. Fail-safe: + // readDowntimeLogEntries never throws — a missing or unreadable + // log is treated as empty. + const entries = await readDowntimeLogEntries(cwd); + // Any dispatched kind (intake/plan/implement) guards the critical + // item while it is still at its dispatched-at stage. + const dispatched = new Map(); + for (const kind of ['intake', 'plan', 'implement'] as const) { + for (const [id, stageAt] of dispatchedItemStages(entries, kind)) { + dispatched.set(id, stageAt); + } + } + const selected = selectCriticalCandidate(candidates, dispatched, registryFor(cwd)); + if (selected === null) return { ok: true, candidate: null }; + // Dependency-frontier resolution (F3, decision Q3): when the + // selected critical candidate is dependency-blocked, the dispatch + // target is the NEAREST OPEN blocker (with the blocker's own + // stage-appropriate skill). The lookup returns that frontier + // blocker — the same contract the dispatch tier already consumes + // (like getNextItem returns a wl next candidate). + const frontier = await resolveDependencyFrontier(selected, (itemId) => + fetchCriticalBlockers(cwd, itemId), + ); + if (frontier === null) { + // Chain bottomed in closed / non-dispatchable items (or a + // cycle): no critical dispatch this tick — fall through to the + // normal tier order. A wl failure NEVER lands here: the fetcher + // throws and the catch below fails closed to a strike. + return { ok: true, candidate: null }; + } + return { + ok: true, + candidate: { + id: frontier.id, + title: frontier.title, + // The frontier blocker's WORKLOG stage (idea / intake_complete + // / plan_complete) rides on the field the tier maps through + // `criticalSkillKind` — the blocker is dispatched with ITS + // stage-appropriate skill (Q3). + stage: frontier.stage as DowntimeStage, + }, + }; + } catch { + // Fail-closed: a wl failure yields a CLI-error outcome, never a + // candidate and never a null that looks like an empty tier — the + // caller counts it as a strike. + return { ok: false }; + } + }, + // Leader-coordination item fetch (parent WL-0MST3OJ8S0001ROL AC4): + // resolve ONE item by id for the leader's per-entry tier + // classification. `wl show` does not serve the audit timestamp, so for + // completed/in_review items the audit tier's list query (which DOES + // enrich auditedAt) is run against the entry's worklog root to attach + // the freshness signal — otherwise an item with a valid audit could + // be re-audited on classification alone. Fail-closed: {ok:false} on + // any wl failure or unparseable output — the coordinator treats it as + // a strike (never a silent skip). + async fetchItem(itemId: string, cwd: string): Promise { + try { + const { stdout } = await getExecFileAsync()( + 'wl', + buildWlArgs(['show', itemId, '--json']), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const info = parseShowItemOutput(stdout); + if (info === null) return { ok: false }; + if (info.status === 'completed' && info.stage === 'in_review') { + // Enrich auditedAt via the audit-tier list query (the only CLI + // shape that carries it). A wl failure here fails closed — the + // item cannot be classified confidently. + const { stdout: listOut } = await getExecFileAsync()( + 'wl', + buildWlArgs(['list', '--status', 'completed', '--stage', 'in_review', '--root-only', '--json']), + { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS }, + ); + const audits = parseAuditCandidatesOutput(listOut); + if (audits === null) return { ok: false }; + const found = audits.find((a) => a.id === itemId); + info.auditedAt = found?.auditedAt ?? null; + } + return { ok: true, info }; + } catch { + return { ok: false }; + } + }, + // Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX): read the project-local + // config at /.worklog/scheduled-prompts.json and select the first + // DUE entry in config order. Fail-closed: an absent or malformed config + // resolves null (logged notice/error inside loadScheduledPrompts) — no + // scheduled dispatch and the existing tiers are unaffected; `wl init` is + // the provisioning path (AC2). + async getDueScheduledPrompt(cwd: string): Promise { + const { entries } = loadScheduledPrompts(cwd); + return getFirstDuePrompt(entries); + }, + // Scheduled-prompts persist (WL-0MSS1Q5ER007QDKX): atomic tmp+rename + // write of the prompt's lastTriggeredAt AFTER a successful dispatch so a + // delayed dispatch never fires more often than its frequency. Resolves + // false on any failure (absent/malformed file, unknown id, I/O error) — + // the dispatcher ABORTS the spawn (an unrecorded dispatch never runs) + // and the entry stays due for the next idle slot (AC4). Never throws. + async recordScheduledPromptTrigger(cwd: string, promptId: string, at: string): Promise { + return updateScheduledPromptLastTriggered(cwd, promptId, at); + }, async claimItem(itemId: string, expected: DowntimeClaimExpected): Promise { // CAS claim (RCA WL-0MSRBFFLN005W3VT design point 1): the transition // only applies while the item is still in the state the tier selected @@ -508,7 +915,10 @@ export function createDowntimeDeps( ? { ok: false, reason: 'stale' } : { ok: false, reason: 'error' }; }, - async spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise { + async spawnAgentPane( + prompt: string, + opts: { model: string; cwd: string; paneName?: string }, + ): Promise { const kind = skillKindFromPrompt(prompt); return spawnDowntimePane( scriptPath, @@ -523,24 +933,28 @@ export function createDowntimeDeps( // comment lands on the item in ITS project's DB, not the plugin // process's own cwd (WL-0MSI7DQL10016QYX). A comment failure is // tolerated — the comment is the durable cross-machine trail, not the - // marker. - try { - await getExecFileAsync()( - 'wl', - buildWlArgs([ - 'comment', - 'add', - event.itemId, - '--comment', - buildDowntimeDispatchComment(event.itemId, event.kind, event.dispatchedAt), - '--author', - 'herdr-downtime', - '--json', - ]), - { timeout: 5000 }, - ); - } catch { - // fail-closed: audit logging must never crash the worker + // marker. Scheduled-prompt dispatches (noItemComment, AC4) have no + // work item — the comment is skipped entirely (there is no item to + // comment on). + if (!event.noItemComment) { + try { + await getExecFileAsync()( + 'wl', + buildWlArgs([ + 'comment', + 'add', + event.itemId, + '--comment', + buildDowntimeDispatchComment(event.itemId, event.kind, event.dispatchedAt), + '--author', + 'herdr-downtime', + '--json', + ]), + { timeout: 5000 }, + ); + } catch { + // fail-closed: audit logging must never crash the worker + } } // 2. Rolling local log (bounded JSONL under /.worklog) — the // dispatched MARKER. Written BEFORE the pane spawns (RCA design point @@ -669,6 +1083,24 @@ async function main(): Promise { stateFile: wlRoot ? join(wlRoot, '.worklog', AGENT_PANES_FILE) : undefined, }); + // Event subscriber (WL-0MSHB7DHO004RHBJ): subscribes to herdr window + // events (pane focus/visibility + agent status) over the herdr socket so + // the worklist reacts immediately instead of polling. Fail-open: an + // unreachable socket keeps today's polling cadence (the worklist gates + // the resume-poll and icon updates behind the events-health flag). The + // subscriber is constructed here (once per plugin instance) and handed to + // the worklist, which wires the event callbacks to its internal state and + // closes it on TUI exit. Initial per-pane subscriptions are synced from + // the tracker's shared state file when the subscription starts. + const socketPath = resolveSocketPath(); + const eventSubscriber = socketPath + ? new HerdrEventSubscriber({ + socketPath, + callbacks: {}, + trackedPaneIds: agentTracker.snapshot().map((e) => e.paneId), + }) + : null; + // Create a fetcher that loads items using the current browseItemCount setting // Each call reads from settings so changes take effect on next auto-refresh // Smart selection (see fetchNextItems) guarantees all critical and @@ -702,28 +1134,48 @@ async function main(): Promise { // Settings are re-read so browseItemCount (per fetch) and showHelpText // (per render) changes apply without a plugin restart. const runSettings = loadSettings(); - // Downtime worker (local-LLM idle dispatch, WL-0MSF49FMW009M06K): built - // when enabled; settings are re-read every tick via `config()` so changes - // apply without a plugin restart. The dispatch panes open in the resolved - // worklog root (--cwd). + // Downtime worker (local-LLM idle dispatch, WL-0MSF49FMW009M06K): created + // UNCONDITIONALLY (parent WL-0MSZ4NSOE007AQEF) so the `d` shortcut can also + // force dispatch on for one pane when the global setting is off — the + // per-instance in-memory override gates the effective enabled state, and + // `tick()` short-circuits (no proxy polling, no idle tracking, no dispatch) + // while the effective state is off. Settings are re-read every tick via + // `config()` so changes apply without a plugin restart. The dispatch panes + // open in the resolved worklog root (--cwd). const targetCwd = wlRoot ?? resolvedCwd ?? process.cwd(); - const downtimeWorker: DowntimeWorker | undefined = runSettings.downtimeEnabled - ? createDowntimeWorker({ - poller: createDowntimePoller(runSettings.downtimeProxyUrl), - deps: createDowntimeDeps(SEND_TO_PI_SCRIPT, AGENT_ASSIGNEE), - config: () => { - const s = loadSettings(); - return { - enabled: s.downtimeEnabled, - thresholdMs: s.downtimeIdleThresholdMs, - requiredFreeSlots: s.downtimeRequiredFreeSlots, - model: s.downtimeModel, - cwd: targetCwd, - noCandidateCooldownMs: s.downtimeNoCandidateCooldownMs, - }; - }, - }) - : undefined; + const downtimeWorker: DowntimeWorker = createDowntimeWorker({ + poller: createDowntimePoller(runSettings.downtimeProxyUrl), + deps: createDowntimeDeps(SEND_TO_PI_SCRIPT, AGENT_ASSIGNEE), + registry: createRoundRobinRegistry({ + worklogDir: join(targetCwd, '.worklog'), + }), + // Leader-election + coordination refactor (parent WL-0MST3OJ8S0001ROL): + // the shared coordination dir is THIS worklog's .worklog (single-machine + // v1 — every herdr instance contributing to the same list passes its own + // root; the coordination file + leader lock/lease live there). The + // instance id is auto-generated at worker construction (stable for the + // process lifetime; re-offers under a fresh id after a restart, the dead + // lease expiring in the TTL). + coordinationDir: join(targetCwd, '.worklog'), + config: () => { + const s = loadSettings(); + return { + enabled: s.downtimeEnabled, + thresholdMs: s.downtimeIdleThresholdMs, + requiredFreeSlots: s.downtimeRequiredFreeSlots, + model: s.downtimeModel, + cwd: targetCwd, + noCandidateCooldownMs: s.downtimeNoCandidateCooldownMs, + }; + }, + }); + + // Mode-switch worker: automatically switches the llama-proxy between fast + // (cloud) and cheap (local) modes based on operator activity and proxy + // idle state. Created with settings.downtimeProxyUrl (reuse, no new URL + // key). Passes `enabled` via the settings flag (modeSwitchEnabled). + const modeSwitchWorker: ModeSwitchWorker = createModeSwitchWorker(); + const selectedItem = await runWorklistTui( fetcher, undefined, @@ -737,6 +1189,10 @@ async function main(): Promise { showIcons: runSettings.showIcons, downtimeWorker, downtimePollIntervalMs: runSettings.downtimePollIntervalMs, + // Per-instance downtime toggle (`d` shortcut, parent WL-0MSZ4NSOE007AQEF): + // flips the in-memory worker override; the header re-renders via the + // worker's enabled state on the next render (internal action, no pane). + onDowntimeToggle: () => downtimeWorker.toggle(), // Re-read on every render so a showHelpText change applies on the next // refresh (no plugin restart needed), matching browseItemCount behavior. getShowHelpText: () => loadSettings().showHelpText ?? true, @@ -747,7 +1203,12 @@ async function main(): Promise { // expanded children) on every refresh cycle (WL-0MSBQUJQX005RAT9). // Fail-open: herdr errors yield no icons; the list keeps working. mergeAgentStates: (items) => mergeAgentStates(items, agentTracker), - onCommand: async (command: string, model?: string) => { + subscriber: eventSubscriber, + agentTracker, + modeSwitchWorker, + modeSwitchPollIntervalMs: runSettings.modeSwitchPollIntervalMs, + modeSwitchEnabled: runSettings.modeSwitchEnabled, + onCommand: async (command: string, model?: string, openPane?: boolean, onRefresh?: () => Promise) => { // Agent commands (/skill:*, /intake, /plan) are routed to a new pi agent // pane opened to the right. Commands prefixed with `!!`/`!` (shell-executed // shortcuts like audit approve/reject, priority updates, close/delete) are @@ -756,6 +1217,14 @@ async function main(): Promise { // user dismisses it with Enter or herdr prefix+x (close_pane). // Everything else is written to stdout with the CMD: prefix for // the calling framework (Herdr) to execute. + // + // A shortcut entry with `open_pane: false` (WL-0MSJLD1I70045ZUL) + // opts out of the visible pane entirely: the command runs in the + // background (headless `pi -p --mode json` for agent commands, + // `bash -c` for shell commands) with stdout/stderr captured to a + // per-run log file whose path is logged to stderr for inspection. + // The work-item ↔ pane association is skipped when no pane opens. + const shouldOpenPane = openPane ?? true; const route = routeCommand(command); // The new pane must start in the correct project root. herdr's // "follow" CWD policy would otherwise inherit the source pane's CWD @@ -768,14 +1237,42 @@ async function main(): Promise { // process CWD is the herdr extension directory. const targetCwd = wlRoot ?? resolvedCwd ?? process.cwd(); if (route === 'agent') { - // Claim the referenced work-item BEFORE spawning the agent pane so it - // appears in_progress immediately. Non-blocking: failures are logged - // to stderr and never prevent the pane from opening (AC2). + // Agent-route hook: record operator activity and fire fast-switch + // (fail-open: never blocks command dispatch). Only runs for agent + // commands — /skill:*, /intake, /plan, /prompt:. Shell shortcuts + // (!!/!) and plain commands do NOT count as operator activity + // (AC2). The worker's onOperatorCommand updates the idle clock + // and POSTs mode=fast (when not already fast) fire-and-forget. + modeSwitchWorker.onOperatorCommand(runSettings.downtimeProxyUrl); + // Claim the referenced work-item BEFORE dispatching so it appears + // in_progress immediately — for both the pane and the headless + // (no-pane) path. Non-blocking: failures are logged to stderr and + // never prevent the dispatch (AC2). try { await claimItemForAgentCommand(command); } catch { // Belt-and-suspenders: a claim failure must never block the pane. } + if (!shouldOpenPane) { + // Background (no-pane) agent dispatch: run pi headless with the + // entry's model, output captured to a per-run log file. No pane + // is created, so the work-item ↔ pane association + // (WL-0MSBQUJQX005RAT9) is skipped entirely. + const prompt = stripAgentPromptPrefix(command); + const logPath = buildBackgroundLogPath(command); + process.stderr.write( + `[worklog-plugin] Background dispatch (no pane): ${command}\n` + + `[worklog-plugin] Log file: ${logPath}\n`, + ); + spawnBackgroundPi( + prompt, + targetCwd, + model, + logPath, + { onExit: () => onRefresh?.() }, + ); + return; + } // Agent-pane association capture (WL-0MSBQUJQX005RAT9): when the // command carries a work-item ID, ask send-to-pi.sh to write the // new pane ID to a temp file (--pane-id-file) right after the split @@ -809,11 +1306,30 @@ async function main(): Promise { } } else if (route === 'pane') { // Strip `!!` / `!` bash history-expansion prefixes, then run the - // command visibly in a new herdr pane via run-in-pane.sh. + // command visibly in a new herdr pane via run-in-pane.sh. The pane + // opens WITHOUT stealing focus from the selection list (--no-focus, + // WL-0MSHIA53D009DJOT) so the user can keep browsing/dispatching; + // command-send feedback is surfaced via toast notifications. + // With `open_pane: false` the command instead runs detached in the + // background with output captured to a log file (no pane). const clean = stripCommandPrefix(command); + if (!shouldOpenPane) { + const logPath = buildBackgroundLogPath(command); + process.stderr.write( + `[worklog-plugin] Background dispatch (no pane): ${command}\n` + + `[worklog-plugin] Log file: ${logPath}\n`, + ); + spawnBackgroundShell( + clean, + targetCwd, + logPath, + { onExit: () => onRefresh?.() }, + ); + return; + } const child = spawn( RUN_IN_PANE_SCRIPT, - ['--cwd', targetCwd, clean], + buildRunInPaneArgs(clean, targetCwd), { detached: true, stdio: 'ignore', @@ -826,10 +1342,27 @@ async function main(): Promise { // Plain (non-!!) shell commands: run them visibly in a new herdr pane // from the resolved project root so they always execute in the tab's // working directory (herdr v0.7.5 has no CMD: handling, so the stdout - // CMD: protocol is not a reliable execution path). + // CMD: protocol is not a reliable execution path). Like the pane + // route, the command-output pane opens without stealing focus + // (--no-focus, WL-0MSHIA53D009DJOT). With `open_pane: false` the + // command runs detached in the background with a log file instead. + if (!shouldOpenPane) { + const logPath = buildBackgroundLogPath(command); + process.stderr.write( + `[worklog-plugin] Background dispatch (no pane): ${command}\n` + + `[worklog-plugin] Log file: ${logPath}\n`, + ); + spawnBackgroundShell( + command, + targetCwd, + logPath, + { onExit: () => onRefresh?.() }, + ); + return; + } const child = spawn( RUN_IN_PANE_SCRIPT, - ['--cwd', targetCwd, command], + buildRunInPaneArgs(command, targetCwd), { detached: true, stdio: 'ignore', diff --git a/packages/herdr/src/integration-leader-dispatch.test.ts b/packages/herdr/src/integration-leader-dispatch.test.ts new file mode 100644 index 000000000..b4240f9d5 --- /dev/null +++ b/packages/herdr/src/integration-leader-dispatch.test.ts @@ -0,0 +1,259 @@ +/** + * Full integration tests for the leader-election → coordination → dispatch + * flow (child WL-0MSXHAKDT005H6VQ, parent WL-0MST3OJ8S0001ROL). + * + * End-to-end scenario coverage: + * - Multiple instances, leader election, lease expiry, re-election + * - Coordination file updates, entry pruning, re-queueing after dispatch + * - Full dispatch flow: leader polls → idle detected → dispatch → + * remove entry → owner re-queues at its next check-in + * - Regression: the pre-dispatch CAS claim prevents double-dispatch + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createDowntimeWorker, + createDowntimePoller, + type DowntimeWorker, + type DowntimeWorkerDeps, + type DowntimeItemInfo, +} from './downtime-worker.js'; +import { createLeaderElectionManager, LEASE_FILE } from './leader-election.js'; +import { + writeCoordinationFile, + getEntry, + readCoordinationFile, + COORDINATION_FILE, + type CoordinationEntry, +} from './coordination.js'; + +// ── Fixtures ────────────────────────────────────────────────────────── + +let dirA: string; +let dirB: string; +let sharedCoord: string; + +beforeEach(() => { + // Two worklog roots + ONE shared coordination dir (single-machine v1: + // the coordination file lives in the FIRST instance's .worklog). Each + // instance operates on its own worklog directory. + dirA = mkdtempSync(join(tmpdir(), 'herdr-it-a-')); + dirB = mkdtempSync(join(tmpdir(), 'herdr-it-b-')); + sharedCoord = join(dirA, '.worklog'); +}); + +afterEach(() => { + rmSync(dirA, { recursive: true, force: true }); + rmSync(dirB, { recursive: true, force: true }); +}); + +function itemInfo(id: string, stage: string, extra: Partial = {}): DowntimeItemInfo { + return { + id, + title: `Item ${id}`, + status: 'open', + stage, + risk: extra.risk, + effort: extra.effort, + updatedAt: extra.updatedAt, + auditedAt: extra.auditedAt, + }; +} + +function makeEntry(instanceId: string, workItemId: string, directory: string): CoordinationEntry { + const now = new Date().toISOString(); + return { instanceId, workItemId, directory, assignedAt: now, lastUpdated: now }; +} + +function idlePayload(): Record { + return { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; +} + +/** Base deps with tier lookups sequenced via overrides. */ +function baseDeps(overrides: Partial = {}): DowntimeWorkerDeps { + return { + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + recordDispatchFailure: vi.fn().mockResolvedValue(undefined), + recordError: vi.fn().mockResolvedValue(undefined), + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + recordScheduledPromptTrigger: vi.fn().mockResolvedValue(true), + readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo('WL-X', 'idea') }), + ...overrides, + } as DowntimeWorkerDeps; +} + +function makeWorker(opts: { + coordinationDir: string; + instanceId: string; + cwd: string; + deps: DowntimeWorkerDeps; +}): DowntimeWorker { + const fetcher = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => idlePayload(), + })); + return createDowntimeWorker({ + coordinationDir: opts.coordinationDir, + instanceId: opts.instanceId, + poller: createDowntimePoller('http://localhost:8000', fetcher as never, 5000), + deps: opts.deps, + config: () => ({ + enabled: true, + thresholdMs: 60_000, + requiredFreeSlots: 1, + model: 'plan', + cwd: opts.cwd, + noCandidateCooldownMs: 3_600_000, + }), + leaseTtlSeconds: 300, + checkInIntervalMs: 30 * 60 * 1000, + }); +} + +// ── Integration scenarios ───────────────────────────────────────────── + +describe('integration: leader election → coordination → dispatch', () => { + it('full flow: two instances, one leader, dispatch removes the entry, owner re-queues (AC1/AC2/AC3)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(20_000_000); + try { + // Instance A is the leader; instance B offers its item. + // Sequence B's most-important item: first WL-B1, then (after its item + // is dispatched) the NEXT item WL-B2 at the following check-in. + const depsB = baseDeps({ + getNextCriticalCandidate: vi.fn() + .mockResolvedValueOnce({ ok: true, candidate: { id: 'WL-B1', title: 'B one', stage: 'intake_complete', status: 'open' } }) + .mockResolvedValue({ ok: true, candidate: { id: 'WL-B2', title: 'B two', stage: 'plan_complete', status: 'open' } }), + }); + const depsA = baseDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + // The leader classifies the coordination entries by re-fetching them. + fetchItem: vi.fn().mockImplementation(async (id: string) => { + if (id === 'WL-B1') return { ok: true, info: itemInfo('WL-B1', 'intake_complete') }; + if (id === 'WL-B2') return { ok: true, info: itemInfo('WL-B2', 'plan_complete', { risk: 'Low', effort: 'S' }) }; + return { ok: false }; + }), + }); + const workerB = makeWorker({ coordinationDir: sharedCoord, instanceId: 'inst-b', cwd: dirB, deps: depsB }); + const workerA = makeWorker({ coordinationDir: sharedCoord, instanceId: 'inst-a', cwd: dirA, deps: depsA }); + + // Tick 1: A wins the election and both instances check in. + await workerA.tick(); + await workerB.tick(); + expect(workerA.isLeader).toBe(true); + expect(workerB.isLeader).toBe(false); + // B offered its most-important item WL-B1 in the shared file. + expect(getEntry(sharedCoord, 'inst-b')?.workItemId).toBe('WL-B1'); + + // A becomes idle across the threshold; the leader dispatches B's item. + await workerA.tick(); // second poll — idle run continues + vi.setSystemTime(20_000_000 + 60_001); + const at = await workerA.tick(); // threshold met → dispatch + expect(at.dispatched).toBe(true); + // The dispatched entry was REMOVED from the coordination file. + expect(getEntry(sharedCoord, 'inst-b')).toBe(null); + // The dispatch targeted B's worklog directory (multi-worklog support). + const spawnArgs = (depsA.spawnAgentPane as ReturnType).mock.calls[0]; + expect(String(spawnArgs[0])).toContain('/skill:plan WL-B1'); + expect(spawnArgs[1].cwd).toBe(dirB); + + // B's next 30-min check-in: its old item was dispatched — it re-queues + // its NEXT most-important item (AC8 re-queue). + vi.setSystemTime(20_000_000 + 31 * 60_000); + await workerB.tick(); + expect(getEntry(sharedCoord, 'inst-b')?.workItemId).toBe('WL-B2'); + } finally { + vi.useRealTimers(); + } + }); + + it('lease expiry + re-election: a dead leader is replaced and dispatch resumes (AC2)', async () => { + // Pre-seed a stale lease for a dead leader. + const dead = createLeaderElectionManager({ worklogDir: sharedCoord, instanceId: 'dead', leaseTtlSeconds: 300 }); + expect(dead.attemptElection()).toBe(true); + dead.releaseLeadership(); + const stale = { leaderId: 'dead', acquiredAt: new Date(Date.now() - 400_000).toISOString(), ttlSeconds: 300 }; + writeFileSync(join(sharedCoord, LEASE_FILE), JSON.stringify(stale), 'utf-8'); + + const deps = baseDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + }); + const worker = makeWorker({ coordinationDir: sharedCoord, instanceId: 'inst-new', cwd: dirA, deps }); + await worker.tick(); + expect(worker.isLeader).toBe(true); + // The lease now names the new leader. + const lease = JSON.parse(readFileSync(join(sharedCoord, LEASE_FILE), 'utf-8')) as { leaderId: string }; + expect(lease.leaderId).toBe('inst-new'); + }); + + it('rejects double dispatch through the CAS claim: a claimed item is never re-spawned (AC5 regression)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(30_000_000); + try { + const entry = makeEntry('inst-b', 'WL-B1', dirB); + writeCoordinationFile(sharedCoord, { version: 1, entries: [entry] }); + const depsA = baseDeps({ + // Instance B's item is ALREADY claimed by another pane (in_progress). + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo('WL-B1', 'in_progress') }), + spawnAgentPane: vi.fn(), + }); + const workerA = makeWorker({ coordinationDir: sharedCoord, instanceId: 'inst-a', cwd: dirA, deps: depsA }); + await workerA.tick(); // elect A + await workerA.tick(); // idle run + vi.setSystemTime(30_000_000 + 60_001); + const at = await workerA.tick(); + // An already-in-progress item is classified as not dispatchable — + // never re-dispatched (no pane, no marker). + expect(at.dispatched).toBe(false); + expect(depsA.spawnAgentPane).not.toHaveBeenCalled(); + expect(getEntry(sharedCoord, 'inst-b')).not.toBe(null); // entry intact + } finally { + vi.useRealTimers(); + } + }); + + it('stale coordination entries are pruned by the leader dispatch cycle', async () => { + vi.useFakeTimers(); + vi.setSystemTime(40_000_000); + try { + const stale = makeEntry('inst-dead', 'WL-DEAD', dirB); + const staleEntry = { ...stale, lastUpdated: new Date(40_000_000 - 400_000).toISOString() }; + writeCoordinationFile(sharedCoord, { version: 1, entries: [staleEntry] }); + + const depsA = baseDeps({ + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + fetchItem: vi.fn().mockResolvedValue({ ok: true, info: itemInfo('WL-DEAD', 'idea') }), + spawnAgentPane: vi.fn(), + }); + const workerA = makeWorker({ coordinationDir: sharedCoord, instanceId: 'inst-a', cwd: dirA, deps: depsA }); + await workerA.tick(); // elect A + await workerA.tick(); // idle + vi.setSystemTime(40_000_000 + 60_001); + await workerA.tick(); // dispatch cycle — prunes the stale entry + // The stale (crashed-instance) entry was pruned during the cycle and + // never dispatched (its owner is gone; lastUpdated > lease TTL). + expect(getEntry(sharedCoord, 'inst-dead')).toBe(null); + expect(depsA.spawnAgentPane).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); \ No newline at end of file diff --git a/packages/herdr/src/leader-election.test.ts b/packages/herdr/src/leader-election.test.ts new file mode 100644 index 000000000..c61a2dbde --- /dev/null +++ b/packages/herdr/src/leader-election.test.ts @@ -0,0 +1,447 @@ +/** + * Unit tests for leader-election.ts — file-lock leader election with + * 5-minute lease management (WL-0MSXH3UF5000HJUS, parent + * WL-0MST3OJ8S0001ROL). + * + * Tests cover: + * - Lock acquisition (first to write wins) + * - Lease creation and reading + * - Lease expiry detection + * - Stale leader detection + * - Re-election after expiry + * - Graceful degradation when coordination file is missing + * - Leadership release + * - Instance ID generation + * - Cleanup of stale election files + */ + +import { describe, it, expect, afterEach, beforeEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createLeaderElectionManager, + runElectionWithRetry, + checkForStaleLeader, + cleanupStaleElection, + isLeaseExpired, + isLeaseValid, + DEFAULT_LEASE_TTL_SECONDS, + LEADER_LOCK_FILE, + LEASE_FILE, +} from './leader-election.js'; + +// ── Test fixtures ────────────────────────────────────────────────────── + +let testDir: string; + +beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'herdr-leader-election-')); +}); + +afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); +}); + +/** Helper: create a manager for the test directory. */ +function makeManager(opts?: { + instanceId?: string; + leaseTtlSeconds?: number; +}): ReturnType { + return createLeaderElectionManager({ + worklogDir: testDir, + instanceId: opts?.instanceId, + leaseTtlSeconds: opts?.leaseTtlSeconds, + }); +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('createLeaderElectionManager', () => { + it('creates a manager with a generated instance ID', () => { + const manager = makeManager(); + expect(manager.getInstanceId()).toBeTruthy(); + expect(typeof manager.getInstanceId()).toBe('string'); + expect(manager.worklogDir).toBe(testDir); + manager.close(); + }); + + it('creates a manager with a provided instance ID', () => { + const manager = makeManager({ instanceId: 'test-instance-1' }); + expect(manager.getInstanceId()).toBe('test-instance-1'); + manager.close(); + }); +}); + +describe('isLeader', () => { + it('returns false when no lease exists', () => { + const manager = makeManager(); + expect(manager.isLeader()).toBe(false); + manager.close(); + }); + + it('returns true after successful election', async () => { + const manager = makeManager({ instanceId: 'test-instance-1' }); + const result = await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'test-instance-1', + }); + expect(result.isLeader).toBe(true); + // Check directly + expect(manager.isLeader()).toBe(true); + manager.close(); + }); + + it('returns false when another instance is leader', async () => { + // Instance 1 becomes leader + const result1 = await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'test-instance-1', + }); + expect(result1.isLeader).toBe(true); + + // Instance 2 tries election — should fail + const result2 = await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'test-instance-2', + }); + expect(result2.isLeader).toBe(false); + expect(result2.leaderId).toBe('test-instance-1'); + }); + + it('returns false after close', () => { + const manager = makeManager(); + manager.close(); + expect(manager.isLeader()).toBe(false); + }); +}); + +describe('refreshLease', () => { + it('refreshes the lease for the leader', async () => { + const manager = makeManager({ instanceId: 'test-instance-1' }); + await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'test-instance-1', + }); + expect(manager.isLeader()).toBe(true); + + // Refresh + manager.refreshLease(); + + // Read the lease file directly + const leasePath = join(testDir, LEASE_FILE); + expect(existsSync(leasePath)).toBe(true); + const lease = JSON.parse(readFileSync(leasePath, 'utf-8')) as { leaderId: string; acquiredAt: string }; + expect(lease.leaderId).toBe('test-instance-1'); + manager.close(); + }); + + it('does nothing when not leader', () => { + const manager = makeManager({ instanceId: 'test-instance-1' }); + manager.refreshLease(); // Should silently do nothing + const leasePath = join(testDir, LEASE_FILE); + expect(existsSync(leasePath)).toBe(false); + manager.close(); + }); +}); + +describe('detectStaleLeader', () => { + it('returns false when no lease exists', () => { + const manager = makeManager(); + expect(manager.detectStaleLeader()).toBe(false); + manager.close(); + }); + + it('returns false when we are the leader', async () => { + const manager = makeManager({ instanceId: 'test-instance-1' }); + await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'test-instance-1', + }); + expect(manager.detectStaleLeader()).toBe(false); + manager.close(); + }); + + it('returns false when another instance is leader and lease is valid', async () => { + // Write a lease for another instance + const leasePath = join(testDir, LEASE_FILE); + const lease = { + leaderId: 'other-instance', + acquiredAt: new Date(Date.now() - 60_000).toISOString(), // 1 min ago + ttlSeconds: 300, // 5 min TTL + }; + writeFileSync(leasePath, JSON.stringify(lease), 'utf-8'); + + const manager = makeManager(); + expect(manager.detectStaleLeader()).toBe(false); + manager.close(); + }); + + it('returns true when leader lease is expired', async () => { + // Write a stale lease for another instance + const leasePath = join(testDir, LEASE_FILE); + const lease = { + leaderId: 'other-instance', + acquiredAt: new Date(Date.now() - 400_000).toISOString(), // 6+ min ago + ttlSeconds: 300, // 5 min TTL + }; + writeFileSync(leasePath, JSON.stringify(lease), 'utf-8'); + + const manager = makeManager(); + expect(manager.detectStaleLeader()).toBe(true); + manager.close(); + }); +}); + +describe('hasLease', () => { + it('returns false when no lease exists', () => { + const manager = makeManager(); + expect(manager.hasLease()).toBe(false); + manager.close(); + }); + + it('returns true after a successful election', async () => { + const manager = makeManager({ instanceId: 'inst-1' }); + await runElectionWithRetry({ worklogDir: testDir, instanceId: 'inst-1' }); + expect(manager.hasLease()).toBe(true); + manager.close(); + }); + + it('returns true for an expired lease (stale detection still sees it)', () => { + const leasePath = join(testDir, LEASE_FILE); + writeFileSync(leasePath, JSON.stringify({ + leaderId: 'dead', + acquiredAt: new Date(Date.now() - 400_000).toISOString(), + ttlSeconds: 300, + }), 'utf-8'); + const manager = makeManager(); + expect(manager.hasLease()).toBe(true); + expect(manager.detectStaleLeader()).toBe(true); + manager.close(); + }); +}); + +describe('attemptElection', () => { + it('wins election when no one else is leader', () => { + const manager = makeManager({ instanceId: 'test-instance-1' }); + const result = manager.attemptElection(); + expect(result).toBe(true); + expect(manager.isLeader()).toBe(true); + manager.close(); + }); + + it('loses election when lock is held by another', () => { + // Pre-existing lock file + const lockPath = join(testDir, LEADER_LOCK_FILE); + writeFileSync(lockPath, 'other-instance', 'utf-8'); + + const manager = makeManager({ instanceId: 'test-instance-1' }); + const result = manager.attemptElection(); + expect(result).toBe(false); + expect(manager.isLeader()).toBe(false); + manager.close(); + }); +}); + +describe('releaseLeadership', () => { + it('releases leadership and cleans up files', async () => { + const manager = makeManager({ instanceId: 'test-instance-1' }); + await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'test-instance-1', + }); + expect(manager.isLeader()).toBe(true); + + const lockPath = join(testDir, LEADER_LOCK_FILE); + const leasePath = join(testDir, LEASE_FILE); + expect(existsSync(lockPath)).toBe(true); + expect(existsSync(leasePath)).toBe(true); + + manager.releaseLeadership(); + expect(manager.isLeader()).toBe(false); + expect(existsSync(lockPath)).toBe(false); + expect(existsSync(leasePath)).toBe(false); + }); +}); + +describe('runElectionWithRetry', () => { + it('succeeds when no other instance is running', async () => { + const result = await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'test-instance-1', + }); + expect(result.isLeader).toBe(true); + expect(result.leaderId).toBe('test-instance-1'); + }); + + it('fails when another instance already holds leadership', async () => { + // First, make instance-1 the leader + const lockPath = join(testDir, LEADER_LOCK_FILE); + const leasePath = join(testDir, LEASE_FILE); + writeFileSync(lockPath, 'instance-1', 'utf-8'); + writeFileSync(leasePath, JSON.stringify({ + leaderId: 'instance-1', + acquiredAt: new Date().toISOString(), + ttlSeconds: 300, + }), 'utf-8'); + + // Instance-2 tries election + const result = await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'instance-2', + }); + expect(result.isLeader).toBe(false); + }); + + it('detects stale leader and wins re-election', async () => { + // Write a stale lease + const leasePath = join(testDir, LEASE_FILE); + const lockPath = join(testDir, LEADER_LOCK_FILE); + writeFileSync(leasePath, JSON.stringify({ + leaderId: 'stale-instance', + acquiredAt: new Date(Date.now() - 400_000).toISOString(), // Expired + ttlSeconds: 300, + }), 'utf-8'); + writeFileSync(lockPath, 'stale-instance', 'utf-8'); + + let staleDetected = false; + const result = await runElectionWithRetry({ + worklogDir: testDir, + instanceId: 'new-instance', + onStaleDetect: () => { staleDetected = true; }, + }); + + expect(result.isLeader).toBe(true); + expect(staleDetected).toBe(true); + }); +}); + +describe('checkForStaleLeader', () => { + it('returns false when no lease exists', () => { + expect(checkForStaleLeader({ worklogDir: testDir })).toBe(false); + }); + + it('returns false when lease is valid', () => { + const leasePath = join(testDir, LEASE_FILE); + writeFileSync(leasePath, JSON.stringify({ + leaderId: 'current-leader', + acquiredAt: new Date().toISOString(), + ttlSeconds: 300, + }), 'utf-8'); + + expect(checkForStaleLeader({ worklogDir: testDir })).toBe(false); + }); + + it('returns true when lease is expired', () => { + const leasePath = join(testDir, LEASE_FILE); + writeFileSync(leasePath, JSON.stringify({ + leaderId: 'stale-leader', + acquiredAt: new Date(Date.now() - 400_000).toISOString(), + ttlSeconds: 300, + }), 'utf-8'); + + expect(checkForStaleLeader({ worklogDir: testDir })).toBe(true); + }); +}); + +describe('cleanupStaleElection', () => { + it('removes orphaned lock and expired lease', () => { + const lockPath = join(testDir, LEADER_LOCK_FILE); + const leasePath = join(testDir, LEASE_FILE); + + writeFileSync(lockPath, 'stale-instance', 'utf-8'); + writeFileSync(leasePath, JSON.stringify({ + leaderId: 'stale-instance', + acquiredAt: new Date(Date.now() - 400_000).toISOString(), + ttlSeconds: 300, + }), 'utf-8'); + + expect(existsSync(lockPath)).toBe(true); + expect(existsSync(leasePath)).toBe(true); + + cleanupStaleElection({ worklogDir: testDir }); + + expect(existsSync(lockPath)).toBe(false); + expect(existsSync(leasePath)).toBe(false); + }); + + it('does not remove active election files', () => { + const lockPath = join(testDir, LEADER_LOCK_FILE); + const leasePath = join(testDir, LEASE_FILE); + + writeFileSync(lockPath, 'active-instance', 'utf-8'); + writeFileSync(leasePath, JSON.stringify({ + leaderId: 'active-instance', + acquiredAt: new Date().toISOString(), + ttlSeconds: 300, + }), 'utf-8'); + + cleanupStaleElection({ worklogDir: testDir }); + + expect(existsSync(lockPath)).toBe(true); + expect(existsSync(leasePath)).toBe(true); + }); +}); + +describe('isLeaseExpired', () => { + it('returns true for expired lease', () => { + const lease = { + leaderId: 'test', + acquiredAt: new Date(Date.now() - 400_000).toISOString(), // 6+ min ago + ttlSeconds: 300, // 5 min TTL + }; + expect(isLeaseExpired(lease)).toBe(true); + }); + + it('returns false for valid lease', () => { + const lease = { + leaderId: 'test', + acquiredAt: new Date().toISOString(), + ttlSeconds: 300, + }; + expect(isLeaseExpired(lease)).toBe(false); + }); +}); + +describe('isLeaseValid', () => { + it('returns true for a lease with plenty of time remaining', () => { + const lease = { + leaderId: 'test', + acquiredAt: new Date().toISOString(), + ttlSeconds: 300, + }; + expect(isLeaseValid(lease)).toBe(true); + }); + + it('returns false for an expired lease', () => { + const lease = { + leaderId: 'test', + acquiredAt: new Date(Date.now() - 400_000).toISOString(), + ttlSeconds: 300, + }; + expect(isLeaseValid(lease)).toBe(false); + }); + + it('returns false for null lease', () => { + expect(isLeaseValid(null as unknown as ReturnType extends { isLeader(): boolean } ? any : any)).toBe(false); + }); +}); + +describe('graceful degradation', () => { + it('manages work without any election files', () => { + // Empty directory — no files + const manager = makeManager({ instanceId: 'test-instance-1' }); + expect(manager.isLeader()).toBe(false); + expect(manager.detectStaleLeader()).toBe(false); + manager.close(); + }); + + it('handles missing coordination file scenario', () => { + // No lease or lock files — should not throw + const manager = makeManager(); + expect(() => manager.isLeader()).not.toThrow(); + expect(() => manager.detectStaleLeader()).not.toThrow(); + expect(() => manager.refreshLease()).not.toThrow(); + manager.close(); + }); +}); diff --git a/packages/herdr/src/leader-election.ts b/packages/herdr/src/leader-election.ts new file mode 100644 index 000000000..006f7cf22 --- /dev/null +++ b/packages/herdr/src/leader-election.ts @@ -0,0 +1,455 @@ +/** + * packages/herdr/src/leader-election.ts — Leader election with file-lock + 5-minute lease + * + * Parent: WL-0MSXH3UF5000HJUS (Leader Election Module), + * parent of WL-0MST3OJ8S0001ROL (Refactor Downtime Dispatcher). + * + * Implements file-lock-based leader election with lease management: + * + * - A file lock at `/.worklog/downtime-leader.lock` elects + * a single leader per worklog directory. + * - The leader holds a 5-minute lease (TTL), refreshed on each proxy-poll + * cycle. If the lease expires (leader crashed or idle), instances detect + * the expiry and run a new election. + * - Non-leader instances detect the lock is held and retry with exponential + * backoff (1–2s initial). + * - When the coordination file is missing or unreadable, instances degrade + * gracefully (no dispatch). + * + * Fail-safe: missing/unreadable coordination file or failed leader election + * degrades to no-dispatch from that instance. + * + * Single-machine only for v1 — coordination file access uses flock. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; + +// ── Constants ────────────────────────────────────────────────────────── + +/** Lock file name (inside the worklog directory). */ +export const LEADER_LOCK_FILE = 'downtime-leader.lock'; + +/** Lease file name (inside the worklog directory). */ +export const LEASE_FILE = 'downtime-leader-lease.json'; + +/** Default lease TTL in seconds (5 minutes). */ +export const DEFAULT_LEASE_TTL_SECONDS = 300; + +/** Initial backoff for re-election attempts (milliseconds). */ +export const DEFAULT_REELECTION_BACKOFF_MS = 1000; + +/** Maximum backoff for re-election attempts (milliseconds). */ +export const MAX_REELECTION_BACKOFF_MS = 2000; + +/** Minimum lease TTL to consider valid (10 seconds — allows for clock skew). */ +const MIN_VALID_LEASE_TTL_MS = 10_000; + +// ── Types ────────────────────────────────────────────────────────────── + +/** + * Lease data stored in the lease file. + * `leaderId` is a unique identifier for the leader instance. + * `acquiredAt` is the ISO-8601 timestamp when the lease was acquired. + * `ttlSeconds` is the lease TTL in seconds. + */ +export interface LeaderLease { + leaderId: string; + acquiredAt: string; + ttlSeconds: number; +} + +/** + * Election result. `isLeader: true` means this instance won the election; + * `isLeader: false` means another instance is the leader (or the election + * is ongoing). + */ +export interface ElectionResult { + isLeader: boolean; + leaderId?: string; +} + +/** + * Leader election manager interface. + * + * The manager is constructed with a worklog directory and an optional + * unique instance ID (generated if not provided). It owns the lock file + * and lease file and provides methods to manage the election state. + */ +export interface LeaderElectionManager { + /** + * True if this instance is the current leader. + * Checks the lease file for expiry and the lock file for ownership. + */ + isLeader(): boolean; + + /** + * Refresh the lease. Called by the leader on each proxy-poll cycle + * to extend the lease TTL. + */ + refreshLease(): void; + + /** + * Detect a stale leader lease and clear it if expired. + * Called by non-leader instances to determine if a re-election should + * be attempted. Returns true if the lease was detected as stale. + */ + detectStaleLeader(): boolean; + + /** + * True when a lease file exists at all (held by ANY instance, current + * or expired). Callers use it to decide between "no leader yet — run a + * fresh election" and "a leader exists with a valid lease — stay + * non-leader" (the worker attempts an election on no-lease as well as + * on stale-lease). + */ + hasLease(): boolean; + + /** + * Attempt to acquire leadership. Runs a new election: tries to + * acquire the file lock and write a new lease. Returns true if this + * instance became the leader. + */ + attemptElection(): boolean; + + /** + * Release leadership (the leader voluntarily steps down). + * Clears the lease file and releases the file lock. + */ + releaseLeadership(): void; + + /** + * Generate a unique instance ID. Called on construction if no + * instance ID is provided. + */ + getInstanceId(): string; + + /** + * Clean up any lock/lease files. Called on shutdown. + * + * NOTE: does NOT delete the lease/lock files — a departing leader's + * files must remain so other instances detect the lease expiry and + * run a new election (crash-recovery design). Use + * `releaseLeadership()` for an explicit voluntary hand-off. + */ + close(): void; + + /** + * The worklog directory this manager operates on. + */ + readonly worklogDir: string; +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +/** Generate a unique instance ID (UUID v4). */ +function generateInstanceId(): string { + return crypto.randomUUID(); +} + +/** Resolve the lock file path. */ +function lockFilePath(worklogDir: string): string { + return path.join(worklogDir, LEADER_LOCK_FILE); +} + +/** Resolve the lease file path. */ +function leaseFilePath(worklogDir: string): string { + return path.join(worklogDir, LEASE_FILE); +} + +/** Read the lock file contents. Returns null if missing or unreadable. */ +function readLockFile(worklogDir: string): string | null { + try { + const fp = lockFilePath(worklogDir); + return fs.readFileSync(fp, 'utf-8').trim() || null; + } catch { + return null; + } +} + +/** Write the lock file with the current instance ID. Returns true on success. + * Uses `O_CREAT | O_EXCL` for atomic exclusive file creation — if the + * file already exists, the open fails and the caller knows another + * instance holds the lock. + */ +function writeLockFile(worklogDir: string, instanceId: string): boolean { + try { + const fp = lockFilePath(worklogDir); + // Ensure the coordination dir exists (a fresh worklog may not have it + // yet — same provisioning as writeCoordinationFile's recursive mkdir). + fs.mkdirSync(worklogDir, { recursive: true }); + // O_CREAT|O_EXCL: atomic exclusive creation — fails if file exists + const fd = fs.openSync(fp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o644); + fs.writeSync(fd, instanceId); + fs.closeSync(fd); + return true; + } catch { + // File exists (another instance holds the lock) or I/O error + return false; + } +} + +/** Delete the lock file. */ +function deleteLockFile(worklogDir: string): void { + try { + fs.unlinkSync(lockFilePath(worklogDir)); + } catch { + // Ignore — file may not exist or be locked + } +} + +/** Read the lease file. Returns null if missing or unreadable. */ +function readLeaseFile(worklogDir: string): LeaderLease | null { + try { + const fp = leaseFilePath(worklogDir); + const content = fs.readFileSync(fp, 'utf-8').trim(); + if (!content) return null; + return JSON.parse(content) as LeaderLease; + } catch { + return null; + } +} + +/** Write the lease file. Returns true on success. */ +function writeLeaseFile(worklogDir: string, lease: LeaderLease): boolean { + try { + const fp = leaseFilePath(worklogDir); + const tmpPath = fp + '.tmp'; + fs.mkdirSync(worklogDir, { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(lease), 'utf-8'); + fs.renameSync(tmpPath, fp); + return true; + } catch { + return false; + } +} + +/** Delete the lease file. */ +function deleteLeaseFile(worklogDir: string): void { + try { + fs.unlinkSync(leaseFilePath(worklogDir)); + } catch { + // Ignore — file may not exist + } +} + +/** Check if a lease is expired. */ +export function isLeaseExpired(lease: LeaderLease, nowMs: number = Date.now()): boolean { + const acquiredAt = new Date(lease.acquiredAt).getTime(); + const expiryMs = acquiredAt + lease.ttlSeconds * 1000; + return nowMs >= expiryMs; +} + +/** Check if a lease is still valid (not expired and close to expiry). */ +export function isLeaseValid(lease: LeaderLease, nowMs: number = Date.now()): boolean { + if (!lease) return false; + const acquiredAt = new Date(lease.acquiredAt).getTime(); + const expiryMs = acquiredAt + lease.ttlSeconds * 1000; + const remaining = expiryMs - nowMs; + // Consider valid if more than MIN_VALID_LEASE_TTL_MS remains + return remaining > MIN_VALID_LEASE_TTL_MS; +} + +// ── Leader Election Manager ──────────────────────────────────────────── + +/** + * Create a leader election manager. + * + * @param options - Configuration + * @param options.worklogDir - Path to the worklog directory + * @param options.instanceId - Optional unique instance ID (generated if not provided) + * @param options.leaseTtlSeconds - Optional lease TTL in seconds (default: 300) + * @param options.reelectionBackoffMs - Optional initial re-election backoff in ms (default: 1000) + * @returns A LeaderElectionManager instance + */ +export function createLeaderElectionManager(options: { + worklogDir: string; + instanceId?: string; + leaseTtlSeconds?: number; + reelectionBackoffMs?: number; +}): LeaderElectionManager { + const { + worklogDir, + instanceId = generateInstanceId(), + leaseTtlSeconds = DEFAULT_LEASE_TTL_SECONDS, + reelectionBackoffMs = DEFAULT_REELECTION_BACKOFF_MS, + } = options; + + let closed = false; + + return { + get worklogDir(): string { + return worklogDir; + }, + + getInstanceId(): string { + return instanceId; + }, + + isLeader(): boolean { + if (closed) return false; + // Check if we hold the lease and it is still valid + const lease = readLeaseFile(worklogDir); + if (!lease) return false; + if (lease.leaderId !== instanceId) return false; + return isLeaseValid(lease); + }, + + refreshLease(): void { + if (closed) return; + if (!this.isLeader()) return; // Only the leader refreshes + + const lease: LeaderLease = { + leaderId: instanceId, + acquiredAt: new Date().toISOString(), + ttlSeconds: leaseTtlSeconds, + }; + writeLeaseFile(worklogDir, lease); + }, + + detectStaleLeader(): boolean { + if (closed) return false; + const lease = readLeaseFile(worklogDir); + if (!lease) return false; + // If the current leader is us, we are not stale + if (lease.leaderId === instanceId) return false; + // Check if the lease is expired + return isLeaseExpired(lease); + }, + + hasLease(): boolean { + if (closed) return false; + return readLeaseFile(worklogDir) !== null; + }, + + attemptElection(): boolean { + if (closed) return false; + // Try to acquire the lock file (atomic write) + if (!writeLockFile(worklogDir, instanceId)) return false; + // Write our lease + const lease: LeaderLease = { + leaderId: instanceId, + acquiredAt: new Date().toISOString(), + ttlSeconds: leaseTtlSeconds, + }; + if (!writeLeaseFile(worklogDir, lease)) { + deleteLockFile(worklogDir); + return false; + } + return true; + }, + + releaseLeadership(): void { + if (closed) return; + deleteLeaseFile(worklogDir); + deleteLockFile(worklogDir); + }, + + close(): void { + closed = true; + }, + }; +} + +// ── Election helpers ─────────────────────────────────────────────────── + +/** + * Run a leader election with retry. Non-leader instances detect if the + * lock is held by another instance and retry with exponential backoff. + * + * @param options - Election options + * @param options.worklogDir - Worklog directory + * @param options.instanceId - Instance ID + * @param options.maxAttempts - Maximum election attempts before giving up + * @param options.backoffMs - Initial backoff in ms + * @param options.leaseTtlSeconds - Lease TTL in seconds + * @param options.onStaleDetect - Callback when a stale leader is detected (triggers re-election) + * @returns ElectionResult indicating leadership status + */ +export async function runElectionWithRetry(options: { + worklogDir: string; + instanceId?: string; + maxAttempts?: number; + backoffMs?: number; + leaseTtlSeconds?: number; + onStaleDetect?: () => void; +}): Promise { + const { + maxAttempts = 5, + backoffMs = DEFAULT_REELECTION_BACKOFF_MS, + } = options; + + let currentBackoff = backoffMs; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const manager = createLeaderElectionManager({ + worklogDir: options.worklogDir, + instanceId: options.instanceId, + leaseTtlSeconds: options.leaseTtlSeconds, + }); + + // Check if we are already the leader (e.g., from a previous election) + if (manager.isLeader()) { + return { isLeader: true, leaderId: options.instanceId }; + } + + // Check if the current leader is stale — if so, clean up stale files + // so we can win the election on the next attempt. + if (manager.detectStaleLeader()) { + options.onStaleDetect?.(); + // Clean up the stale lock + lease so we can acquire them + cleanupStaleElection({ worklogDir: options.worklogDir }); + } + + // Attempt election + if (manager.attemptElection()) { + return { isLeader: true, leaderId: options.instanceId }; + } + + // Check if we hold the lock but our lease was overwritten + const lockHolder = readLockFile(options.worklogDir); + if (lockHolder === options.instanceId) { + return { isLeader: true, leaderId: options.instanceId }; + } + + // Lock is held by another instance — wait and retry + if (attempt < maxAttempts - 1) { + await sleep(currentBackoff); + currentBackoff = Math.min(currentBackoff * 2, MAX_REELECTION_BACKOFF_MS); + } + } + + // Could not become leader + return { isLeader: false, leaderId: readLockFile(options.worklogDir) || undefined }; +} + +/** Check if a stale leader exists without attempting election. */ +export function checkForStaleLeader(options: { + worklogDir: string; +}): boolean { + const lease = readLeaseFile(options.worklogDir); + if (!lease) return false; + return isLeaseExpired(lease); +} + +/** Clean up stale election files (orphaned lock + expired lease). */ +export function cleanupStaleElection(options: { + worklogDir: string; +}): void { + const lockContent = readLockFile(options.worklogDir); + if (lockContent) { + const lease = readLeaseFile(options.worklogDir); + if (!lease || isLeaseExpired(lease)) { + deleteLockFile(options.worklogDir); + deleteLeaseFile(options.worklogDir); + } + } +} + +// ── Utilities ────────────────────────────────────────────────────────── + +/** Sleep for a given number of milliseconds. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/herdr/src/mode-switch-integration.test.ts b/packages/herdr/src/mode-switch-integration.test.ts new file mode 100644 index 000000000..58af8b79b --- /dev/null +++ b/packages/herdr/src/mode-switch-integration.test.ts @@ -0,0 +1,144 @@ +/** + * Integration tests for agent-route hook + scheduler task wiring + * (parent WL-0MSN3FWV5008KQE9). + * + * These tests verify: + * 1. The mode-switch worker is created with settings.downtimeProxyUrl + * 2. The agent-route hook is wired into index.ts command dispatch + * 3. The scheduler task for the mode-switch worker is registered in worklist.ts + * 4. Agent commands (/skill:*, /intake, /plan, /prompt:) trigger onOperatorCommand + * 5. Non-agent commands do NOT trigger the mode-switch hook + * + * Run: npx vitest run packages/herdr/src/mode-switch-integration.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { defaultSettings } from './settings.js'; +import { routeCommand } from './index.js'; +import { + createModeSwitchWorker, + DEFAULT_MODE_SWITCH_POLL_INTERVAL_MS, + MODE_SWITCH_POLL_INTERVAL_FLOOR_MS, + MODE_SWITCH_POLL_INTERVAL_CAP_MS, + DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS, + MODE_SWITCH_IDLE_THRESHOLD_FLOOR_MS, + MODE_SWITCH_RUN_TIMEOUT_MS, + ADMIN_API_TIMEOUT_MS, +} from './mode-switch-worker.js'; + +// ── Settings integration ────────────────────────────────────────────── + +describe('settings → worker creation integration', () => { + it('defaultSettings contains modeSwitch keys', () => { + expect(defaultSettings).toHaveProperty('modeSwitchEnabled'); + expect(defaultSettings).toHaveProperty('modeSwitchIdleThresholdMs'); + expect(defaultSettings).toHaveProperty('modeSwitchPollIntervalMs'); + expect(typeof defaultSettings.modeSwitchEnabled).toBe('boolean'); + expect(typeof defaultSettings.modeSwitchIdleThresholdMs).toBe('number'); + expect(typeof defaultSettings.modeSwitchPollIntervalMs).toBe('number'); + }); + + it('downtimeProxyUrl is available for the mode-switch worker (reuse, no new URL key)', () => { + expect(defaultSettings).toHaveProperty('downtimeProxyUrl'); + expect(typeof defaultSettings.downtimeProxyUrl).toBe('string'); + }); +}); + +// ── Agent-route hook wiring ────────────────────────────────────────── + +describe('agent-route hook wiring', () => { + it('agent command prefixes are correctly identified', () => { + const agentCommands: string[] = [ + '/skill:implement WL-0ABC', + '/skill:audit', + '/skill:plan', + '/intake WL-0ABC', + '/plan WL-0ABC', + '/prompt: review code', + ]; + const nonAgentCommands: string[] = [ + '/wl next', + '! ls -la', + '!! rm -rf /', + '/downtime toggle', + '/reviewed 1', + ]; + + for (const cmd of agentCommands) { + const isAgent = + cmd.startsWith('/skill:') || + cmd.startsWith('/intake') || + cmd.startsWith('/plan') || + cmd.startsWith('/prompt:'); + expect(isAgent, `Expected "${cmd}" to be an agent command`).toBe(true); + } + + for (const cmd of nonAgentCommands) { + const isAgent = + cmd.startsWith('/skill:') || + cmd.startsWith('/intake') || + cmd.startsWith('/plan') || + cmd.startsWith('/prompt:'); + expect(isAgent, `Expected "${cmd}" NOT to be an agent command`).toBe(false); + } + }); + + it('routeCommand correctly classifies agent commands as "agent"', () => { + // routeCommand from index.ts must correctly classify agent commands. + // The actual agent-route hook (onOperatorCommand call) is wired in the + // dispatch path — this test validates the classification is correct. + expect(routeCommand('/skill:implement WL-0ABC')).toBe('agent'); + expect(routeCommand('/skill:audit')).toBe('agent'); + expect(routeCommand('/intake WL-0ABC')).toBe('agent'); + expect(routeCommand('/plan WL-0ABC')).toBe('agent'); + expect(routeCommand('/prompt: review code')).toBe('agent'); + + // Non-agent commands + expect(routeCommand('/wl next')).toBe('stdout'); + expect(routeCommand('! ls -la')).toBe('pane'); + expect(routeCommand('!! rm -rf /')).toBe('pane'); + expect(routeCommand('/downtime toggle')).toBe('stdout'); + }); +}); + +// ── Scheduler task configuration ───────────────────────────────────── + +describe('scheduler task configuration', () => { + it('mode-switch worker interval constants are properly bounded', () => { + expect(DEFAULT_MODE_SWITCH_POLL_INTERVAL_MS).toBeGreaterThanOrEqual(MODE_SWITCH_POLL_INTERVAL_FLOOR_MS); + expect(DEFAULT_MODE_SWITCH_POLL_INTERVAL_MS).toBeLessThanOrEqual(MODE_SWITCH_POLL_INTERVAL_CAP_MS); + expect(MODE_SWITCH_POLL_INTERVAL_FLOOR_MS).toBe(5_000); + expect(MODE_SWITCH_POLL_INTERVAL_CAP_MS).toBe(60_000); + expect(DEFAULT_MODE_SWITCH_POLL_INTERVAL_MS).toBe(10_000); + }); + + it('idle threshold floor is at least 60s', () => { + expect(MODE_SWITCH_IDLE_THRESHOLD_FLOOR_MS).toBeGreaterThanOrEqual(60_000); + expect(DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS).toBe(900_000); // 15 minutes + }); + + it('run timeout watchdog exceeds the admin API timeout (hung ticks get abandoned)', () => { + // The scheduler task has a runTimeoutMs watchdog (mirror of the downtime + // task): a hung tick must be abandoned so the single-flight flag resets + // and the next tick retries. The watchdog must be generous enough to + // outlive the admin API's own 5s AbortController plus a slow mode-switch + // restart while the proxy reloads its model pool. + expect(MODE_SWITCH_RUN_TIMEOUT_MS).toBeGreaterThan(ADMIN_API_TIMEOUT_MS); + expect(MODE_SWITCH_RUN_TIMEOUT_MS).toBe(30_000); + }); +}); + +// ── Worker interface integration ───────────────────────────────────── + +describe('worker interface integration', () => { + it('createModeSwitchWorker exports the ModeSwitchWorker interface', () => { + const worker = createModeSwitchWorker({ + fetcher: async () => ({ ok: false, status: 500, json: async () => ({ error: 'test' }) }), + }) as { onOperatorCommand: Function; tick: Function }; + + // onOperatorCommand — called by the agent-route hook in index.ts + expect(typeof worker.onOperatorCommand).toBe('function'); + // tick — called by the scheduler task in worklist.ts + expect(typeof worker.tick).toBe('function'); + }); +}); diff --git a/packages/herdr/src/mode-switch-worker.test.ts b/packages/herdr/src/mode-switch-worker.test.ts new file mode 100644 index 000000000..6cb826ad1 --- /dev/null +++ b/packages/herdr/src/mode-switch-worker.test.ts @@ -0,0 +1,654 @@ +/** + * Unit tests for the mode-switch worker core (parent WL-0MSN3FWV5008KQE9): + * idle-window evaluation, last-command tracking, cheap/fast switch triggers, + * restart reset, and fail-closed paths. + * + * Run: npx vitest run packages/herdr/src/mode-switch-worker.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + createModeSwitchWorker, + getAdminMode, + setAdminMode, + fetchProxyStatus, + clampModeSwitchIdleThresholdMs, + clampModeSwitchPollIntervalMs, + DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS, + MODE_SWITCH_IDLE_THRESHOLD_FLOOR_MS, + MODE_SWITCH_POLL_INTERVAL_FLOOR_MS, + MODE_SWITCH_POLL_INTERVAL_CAP_MS, + ADMIN_MODE_PATH, + ADMIN_SET_MODE_PATH, + PROXY_STATUS_PATH, + type AdminApiFetcher, + type ProxyMode, +} from './mode-switch-worker.js'; +import type { LlamaStatus } from './downtime-worker.js'; + +// ── Test helpers ────────────────────────────────────────────────────── + +let clock: number; + +function now(): number { + return clock; +} + +function advance(ms: number): void { + clock += ms; +} + +/** Build an idle `/llama/local/status` payload (all slots free). */ +function idleStatus(overrides: Partial = {}): LlamaStatus { + return { + llama_server_running: true, + active_query: false, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 3, + ...overrides, + }; +} + +interface MockAdminApi { + fetcher: AdminApiFetcher; + calls: { url: string; method: string; body?: string }[]; + getMode: () => ProxyMode | null; + setModeResult: () => { status: number; body: unknown }; + setGetMode: (fn: () => ProxyMode | null) => void; + setSetMode: (fn: () => { status: number; body: unknown }) => void; + reset: () => void; +} + +function mockAdminApi(): MockAdminApi { + const calls: MockAdminApi['calls'] = []; + let getMode: () => ProxyMode | null = () => 'fast'; + let setModeResult: () => { status: number; body: unknown } = () => ({ + status: 200, + body: { ok: true }, + }); + const fetcher: AdminApiFetcher = async (url: string, init?: { method?: string; body?: string; signal?: unknown }) => { + const method = init?.method ?? 'GET'; + calls.push({ url, method, body: init?.body }); + if (method === 'POST') { + const r = setModeResult(); + return { + ok: r.status >= 200 && r.status < 300, + status: r.status, + json: async () => r.body, + }; + } + const mode = getMode(); + return { + ok: mode !== null, + status: mode !== null ? 200 : 500, + json: async () => (mode !== null ? { mode } : { error: 'unreachable' }), + }; + }; + return { + fetcher, + calls, + getMode: () => getMode(), + setModeResult: () => setModeResult(), + setGetMode: (fn) => { + getMode = fn; + }, + setSetMode: (fn) => { + setModeResult = fn; + }, + reset: () => { + calls.length = 0; + }, + }; +} + +/** Flush fire-and-forget promise chains (onOperatorCommand posts without await). */ +async function flushAsync(): Promise { + await new Promise((r) => setTimeout(r, 0)); +} + +/** POSTs targeting the set-mode endpoint recorded so far. */ +function setModePosts(api: MockAdminApi): MockAdminApi['calls'] { + return api.calls.filter((c) => c.method === 'POST' && c.url.endsWith(ADMIN_SET_MODE_PATH)); +} + +// ── Restart reset (fail-safe fast) ─────────────────────────────────── + +describe('restart reset', () => { + it('construction sets lastOperatorCommandAt to now — a fresh worker never cheap-switches immediately', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + + // Idle window is 600s; the worker was just constructed, so the elapsed + // time is 0 — the cheap switch must NOT fire even though the proxy is idle. + await worker.tick({ + enabled: true, + idleThresholdMs: 600_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + + // After a FULL idle window elapses (and the proxy is still idle) the + // cheap switch fires — restart never shortcuts the fresh window. + advance(600_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 600_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + expect(setModePosts(api)[0].body).toContain('"cheap"'); + }); +}); + +// ── Idle-window evaluation ─────────────────────────────────────────── + +describe('idle-window evaluation', () => { + it('a timestamp older than the threshold triggers the cheap path', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + // Operator command issued 15 min ago. + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + const posts = setModePosts(api); + expect(posts).toHaveLength(1); + expect(posts[0].url.endsWith(ADMIN_SET_MODE_PATH)).toBe(true); + expect(posts[0].body).toBe(JSON.stringify({ mode: 'cheap' })); + }); + + it('a fresh timestamp within the threshold does NOT trigger the cheap path', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + worker.onOperatorCommand('http://proxy'); // now + await flushAsync(); + api.reset(); + advance(60_000); // well inside a 15-min threshold + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); + + it('disabled worker never switches even when the idle window is met', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: false, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); +}); + +// ── Last-command tracking ──────────────────────────────────────────── + +describe('last-command tracking', () => { + it('an agent-route command updates the activity clock (resets the idle window)', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); // idle long enough + worker.onOperatorCommand('http://proxy'); // operator returns — clock resets + await flushAsync(); + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + // Fresh command → no cheap switch (only the fast switch from + // onOperatorCommand was recorded, and api.reset cleared it). + expect(setModePosts(api)).toHaveLength(0); + }); +}); + +// ── Fast switch on command ─────────────────────────────────────────── + +describe('fast switch on command', () => { + it('a new operator agent-route command fires POST /admin/set-mode {"mode":"fast"} fire-and-forget', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + const posts = setModePosts(api); + expect(posts).toHaveLength(1); + expect(posts[0].body).toBe(JSON.stringify({ mode: 'fast' })); + }); + + it('skips the fast POST when the last known mode is already fast (no redundant switching)', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + api.reset(); + + // Second command while already fast — no second POST. + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); + + it('a failed fast switch never throws and never blocks the command (fail-open)', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + api.setSetMode(() => ({ status: 500, body: { error: 'boom' } })); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + // The switch was attempted (fire-and-forget) and the failure was + // swallowed — the command dispatch itself is never blocked or thrown. + expect(setModePosts(api)).toHaveLength(1); + }); +}); + +// ── Cheap switch trigger ───────────────────────────────────────────── + +describe('cheap switch trigger', () => { + it('idle window met AND proxy idle → POST /admin/set-mode {"mode":"cheap"}', 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: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + expect(setModePosts(api)[0].body).toBe(JSON.stringify({ mode: 'cheap' })); + }); + + it('a busy proxy (active local query) delays the switch', 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: idleStatus({ local_active_query: true, active_query: true }), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + + // Once the proxy goes idle again, the switch fires (delayed, not blocked). + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + }); + + it('skips the cheap POST when the freshly-read persisted mode is already cheap (no redundant switching)', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + api.setGetMode(() => 'cheap'); // proxy is already in cheap mode + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); + + it('after switching to cheap, the mode is tracked — the next tick does not re-POST', 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: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); // cheap switch happened + + // Next tick: GET reports cheap (persisted) → no redundant POST. + api.setGetMode(() => 'cheap'); + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); +}); + +// ── Fail-closed paths ──────────────────────────────────────────────── + +describe('fail-closed paths', () => { + it('GET /admin/mode endpoint failure yields no switch and never throws', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + api.setGetMode(() => { + throw new Error('network down'); + }); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await expect( + worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }), + ).resolves.toBeUndefined(); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); + + it('GET /admin/mode timeout/network error resolves null → fail-closed no-op', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + api.setGetMode(() => null); // unreachable proxy + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); + + it('POST /admin/set-mode network failure yields no switch, never throws', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + api.setSetMode(() => { + throw new Error('timeout'); + }); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await expect( + worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }), + ).resolves.toBeUndefined(); + await flushAsync(); + expect(api.calls.some((c) => c.url.endsWith(ADMIN_SET_MODE_PATH))).toBe(true); + }); + + it('409 (mode-switch restart in progress) is a no-op and retried on a later tick', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + api.setSetMode(() => ({ status: 409, body: { error: 'restarting' } })); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + // 409 → no switch accepted; the POST was attempted once and NOT recorded + // as a succeeded switch. + expect(setModePosts(api)).toHaveLength(1); + + // Retried on a later tick: the restart completed, the switch lands. + api.setSetMode(() => ({ status: 200, body: { ok: true } })); + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + expect(setModePosts(api)[0].body).toBe(JSON.stringify({ mode: 'cheap' })); + }); + + it('proxy status endpoint failure (null status) → treated as busy, no switch', 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: null, // endpoint failure / timeout / ambiguous + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); + + it('ambiguous mode response (non fast/cheap) yields no switch', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + api.setGetMode(() => 'bogus' as ProxyMode); // ambiguous — getAdminMode nulls it + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: idleStatus(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(0); + }); + + it('per-process single-flight: overlapping switch attempts coalesce to one POST', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + // Two immediate operator commands: the first fires the fast switch, the + // second either sees switchInFlight or the just-set lastKnownMode='fast' + // — either way at most ONE fast POST lands (no hammering). + worker.onOperatorCommand('http://proxy'); + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + }); +}); + +// ── Settings clamps (settings contract tie-in) ─────────────────────── + +describe('settings clamps (contract)', () => { + it('modeSwitchIdleThresholdMs clamps below its floor and defaults when absent/invalid', () => { + expect(clampModeSwitchIdleThresholdMs(1_000)).toBe(MODE_SWITCH_IDLE_THRESHOLD_FLOOR_MS); + expect(clampModeSwitchIdleThresholdMs(NaN)).toBe(DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS); + expect(clampModeSwitchIdleThresholdMs(-5)).toBe(DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS); + expect(clampModeSwitchIdleThresholdMs(900_000)).toBe(900_000); + }); + + it('modeSwitchPollIntervalMs clamps below its floor and above its cap', () => { + expect(clampModeSwitchPollIntervalMs(1_000)).toBe(MODE_SWITCH_POLL_INTERVAL_FLOOR_MS); + expect(clampModeSwitchPollIntervalMs(300_000)).toBe(MODE_SWITCH_POLL_INTERVAL_CAP_MS); + expect(clampModeSwitchPollIntervalMs(NaN)).toBe(10_000); + expect(clampModeSwitchPollIntervalMs(10_000)).toBe(10_000); + }); +}); + +// ── Admin API client ───────────────────────────────────────────────── + +describe('fetchProxyStatus', () => { + it('fetchProxyStatus parses a valid idle status payload', async () => { + const fetcher: AdminApiFetcher = async () => ({ + ok: true, + status: 200, + json: async () => ({ + llama_server_running: true, + active_query: false, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 3, + }), + }); + const status = await fetchProxyStatus('http://proxy', fetcher); + expect(status).not.toBeNull(); + expect(status!.llama_server_running).toBe(true); + expect(status!.available_slots).toBe(3); + }); + + it('fetchProxyStatus returns null on non-2xx response', async () => { + const fetcher: AdminApiFetcher = async () => ({ + ok: false, + status: 500, + json: async () => ({ error: 'boom' }), + }); + expect(await fetchProxyStatus('http://proxy', fetcher)).toBeNull(); + }); + + it('fetchProxyStatus returns null on network error', async () => { + const fetcher: AdminApiFetcher = async () => { + throw new Error('ECONNREFUSED'); + }; + expect(await fetchProxyStatus('http://proxy', fetcher)).toBeNull(); + }); + + it('tick uses fetchProxyStatus when proxyStatus is null', async () => { + clock = 1_000_000; + // Use a single counting fetcher that tracks all calls. + const fetcherCount = { status: 0, adminMode: 0, setMode: 0 }; + const countingFetcher: AdminApiFetcher = async (url, init) => { + if (url.endsWith(PROXY_STATUS_PATH)) { + fetcherCount.status++; + return { + ok: true, + status: 200, + json: async () => ({ + llama_server_running: true, + active_query: false, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 3, + }), + }; + } + if (url.endsWith(ADMIN_MODE_PATH)) { + fetcherCount.adminMode++; + return { ok: true, status: 200, json: async () => ({ mode: 'fast' }) }; + } + if (url.endsWith(ADMIN_SET_MODE_PATH)) { + fetcherCount.setMode++; + return { ok: true, status: 200, json: async () => ({ ok: true }) }; + } + return { ok: false, status: 404, json: async () => ({ error: 'not found' }) }; + }; + const worker = createModeSwitchWorker({ fetcher: countingFetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: null, // null → worker fetches its own status + }); + await flushAsync(); + expect(fetcherCount.status).toBe(1); // fetched its own proxy status + expect(fetcherCount.adminMode).toBeGreaterThanOrEqual(1); // read mode + expect(fetcherCount.setMode).toBeGreaterThanOrEqual(1); // switched to cheap + }); +}); + +describe('admin API client', () => { + it('getAdminMode parses a valid mode payload', async () => { + const fetcher: AdminApiFetcher = async (url) => ({ + ok: true, + status: 200, + json: async () => ({ mode: 'cheap' }), + }); + expect(await getAdminMode('http://proxy', fetcher)).toBe('cheap'); + }); + + it('getAdminMode nulls ambiguous/malformed payloads (fail-closed)', async () => { + const fetcher: AdminApiFetcher = async () => ({ + ok: true, + status: 200, + json: async () => ({ mode: 'turbo' }), + }); + expect(await getAdminMode('http://proxy', fetcher)).toBeNull(); + }); + + it('getAdminMode nulls non-2xx responses', async () => { + const fetcher: AdminApiFetcher = async () => ({ + ok: false, + status: 500, + json: async () => ({ error: 'boom' }), + }); + expect(await getAdminMode('http://proxy', fetcher)).toBeNull(); + }); + + it('setAdminMode posts a JSON body and resolves true on 2xx', async () => { + let seen: { url: string; init?: { method?: string; body?: string } } | undefined; + const fetcher: AdminApiFetcher = async (url, init) => { + seen = { url, init }; + return { ok: true, status: 200, json: async () => ({ ok: true }) }; + }; + expect(await setAdminMode('http://proxy', 'fast', fetcher)).toBe(true); + expect(seen?.url.endsWith(ADMIN_SET_MODE_PATH)).toBe(true); + expect(seen?.init?.method).toBe('POST'); + expect(seen?.init?.body).toBe(JSON.stringify({ mode: 'fast' })); + }); + + it('setAdminMode resolves false on 409 (no-throw no-op)', async () => { + const fetcher: AdminApiFetcher = async () => ({ + ok: false, + status: 409, + json: async () => ({ error: 'restarting' }), + }); + expect(await setAdminMode('http://proxy', 'cheap', fetcher)).toBe(false); + }); + + it('setAdminMode resolves false on network errors (never throws)', async () => { + const fetcher: AdminApiFetcher = async () => { + throw new Error('ECONNREFUSED'); + }; + await expect(setAdminMode('http://proxy', 'cheap', fetcher)).resolves.toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/herdr/src/mode-switch-worker.ts b/packages/herdr/src/mode-switch-worker.ts new file mode 100644 index 000000000..2cf5361cb --- /dev/null +++ b/packages/herdr/src/mode-switch-worker.ts @@ -0,0 +1,360 @@ +/** + * packages/herdr/src/mode-switch-worker.ts — Activity-gated proxy mode switching + * + * A worker that tracks operator activity and switches the llama-proxy between + * **fast** (cloud-backed, 3-slot pool) and **cheap** (2-slot local-first + * pool) operating modes: + * + * - **Fast switch on command:** when an agent-route command is routed through + * the plugin (WL-0MSN3FWV5008KQE9), the worker records the operator + * timestamp and fire-and-forget POSTs `/admin/set-mode {"mode":"fast"}`. + * A failed switch never blocks or delays the command dispatch (fail-open: + * the pane opens regardless). + * - **Cheap switch on idle:** on each tick, when the operator has been idle + * (no agent-route commands) for ≥ `modeSwitchIdleThresholdMs` **AND** the + * proxy reports idle (reusing `evaluateIdle` from downtime-worker.ts with + * `requiredFreeSlots = 0` — llama-server running, no active local query, + * no model switch, no local lease, all required slots free), the worker + * POSTs `/admin/set-mode {"mode":"cheap"}`. A busy proxy (active local + * query) **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 + * state change; a `409` (mode-switch restart in progress) is treated as a + * no-op and retried on a later tick. + * - **Fail-closed everywhere:** endpoint failures, timeouts, network errors, + * and ambiguous responses yield no switch and never crash or block the + * plugin. On plugin restart the idle clock resets to "active now" + * (fail-safe: the proxy stays fast until a fresh full idle window passes). + * - **Coexistence with the proxy's built-in schedule:** the plugin's + * switches are manual overrides (the API's native semantics, + * LP-0MSMF25V9002AY1J); the proxy's time-based schedule (cheap + * 01:00–10:00, fast 10:00–01:00) reclaims control at its next boundary. + * Documented behavior, not a conflict. + * + * The proxy URL is shared with the downtime worker (`downtimeProxyUrl` — no + * separate URL key), and the existing `/llama/local/status` idle evaluation + * from downtime-worker.ts is reused. + */ + +import { evaluateIdle, type LlamaStatus } from './downtime-worker.js'; + +// ── Constants ───────────────────────────────────────────────────────── + +/** Proxy mode-read endpoint path (`GET /admin/mode` only READS; the switch + * endpoint is `/admin/set-mode`). */ +export const ADMIN_MODE_PATH = '/admin/mode'; + +/** Proxy mode-switch endpoint path. */ +export const ADMIN_SET_MODE_PATH = '/admin/set-mode'; + +/** Default idle window before switching to cheap mode: 15 minutes. */ +export const DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS = 900_000; + +/** + * Defensive floor for the idle threshold (60s): a trivially small window + * would defeat the operator-presence gating (never switch to cheap right + * after the operator looked away). + */ +export const MODE_SWITCH_IDLE_THRESHOLD_FLOOR_MS = 60_000; + +/** Default poll interval for the mode-switch worker (same as the downtime poller). */ +export const DEFAULT_MODE_SWITCH_POLL_INTERVAL_MS = 10_000; + +/** Hard floor for the poll interval (never hammer the admin endpoints). */ +export const MODE_SWITCH_POLL_INTERVAL_FLOOR_MS = 5_000; + +/** Sane cap for the poll interval (keeps idle detection responsive). */ +export const MODE_SWITCH_POLL_INTERVAL_CAP_MS = 60_000; + +/** Timeout for admin API calls (an unresponsive proxy must fail closed). */ +export const ADMIN_API_TIMEOUT_MS = 5_000; + +/** + * Scheduler watchdog for the mode-switch task: a tick run that hangs (e.g. a + * stuck admin fetch that outlived its 5s AbortController) is abandoned after + * this and the single-flight flag resets so the next tick retries — a hung + * run can never permanently wedge the task until a pane restart (mirror of + * DOWNTIME_RUN_TIMEOUT_MS). Generous: a mode-switch restart may take longer + * than ADMIN_API_TIMEOUT_MS while the proxy reloads its model pool. + */ +export const MODE_SWITCH_RUN_TIMEOUT_MS = 30_000; + +// ── Settings clamps ─────────────────────────────────────────────────── + +/** + * Clamp the mode-switch idle threshold: reject negative/non-finite (fall + * back to the 15-minute default) and floor at 60s so the operator cannot + * configure an immediate cheap-switch. + */ +export function clampModeSwitchIdleThresholdMs(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS; + return Math.max(Math.round(value), MODE_SWITCH_IDLE_THRESHOLD_FLOOR_MS); +} + +/** + * Clamp the mode-switch poll interval: reject negative/non-finite (fall + * back to the 10s default) and clamp into [5s, 60s] so the worker neither + * hammers the proxy nor goes dormant (settings contract child + * WL-0MT1EDYS0002CMUK). + */ +export function clampModeSwitchPollIntervalMs(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_MODE_SWITCH_POLL_INTERVAL_MS; + return Math.min( + Math.max(Math.round(value), MODE_SWITCH_POLL_INTERVAL_FLOOR_MS), + MODE_SWITCH_POLL_INTERVAL_CAP_MS, + ); +} + +// ── Types ───────────────────────────────────────────────────────────── + +/** Proxy operating mode: 'fast' (cloud-backed) or 'cheap' (local-first). */ +export type ProxyMode = 'fast' | 'cheap'; + +/** + * Injectable HTTP fetcher for the admin API (tests replace this so no real + * HTTP calls are made). Structured to satisfy both `GET /admin/mode` and + * `POST /admin/set-mode`. + */ +export type AdminApiFetcher = ( + url: string, + init?: { method?: string; body?: string; signal?: unknown }, +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +// ── Admin API client ────────────────────────────────────────────────── + +/** + * Read the current proxy mode via `GET /admin/mode`. Returns null on any + * failure mode — network error, timeout, non-2xx status, invalid JSON, + * ambiguous payload (fail-closed: an unreadable mode is treated as + * "unknown", which the caller resolves to a safe no-op). + */ +export async function getAdminMode( + proxyUrl: string, + fetcher: AdminApiFetcher = globalThis.fetch as unknown as AdminApiFetcher, +): Promise { + const url = `${proxyUrl.replace(/\/+$/, '')}${ADMIN_MODE_PATH}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ADMIN_API_TIMEOUT_MS); + try { + const res = await fetcher(url, { signal: controller.signal }); + if (!res.ok) return null; + let raw: unknown; + try { + raw = await res.json(); + } catch { + return null; // invalid JSON → ambiguous + } + if (typeof raw !== 'object' || raw === null) return null; + const o = raw as Record; + if (o.mode !== 'fast' && o.mode !== 'cheap') return null; + return o.mode as ProxyMode; + } catch { + return null; // network errors / timeouts → unknown, never throw + } finally { + clearTimeout(timer); + } +} + +/** + * Switch the proxy mode via `POST /admin/set-mode`. Resolves true when the + * switch was accepted (2xx); false on every other outcome — including a + * `409` (a mode-switch restart is already in progress: treated as a no-op + * and retried on a later tick) and all failure modes (timeout, network + * error, non-2xx, ambiguous response). Never throws. + */ +export async function setAdminMode( + proxyUrl: string, + mode: ProxyMode, + fetcher: AdminApiFetcher = globalThis.fetch as unknown as AdminApiFetcher, +): Promise { + const url = `${proxyUrl.replace(/\/+$/, '')}${ADMIN_SET_MODE_PATH}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ADMIN_API_TIMEOUT_MS); + try { + const res = await fetcher(url, { + method: 'POST', + body: JSON.stringify({ mode }), + signal: controller.signal, + }); + if (res.status === 409) { + // Mode-switch restart in progress — no-op for this tick; the poller + // retries on a later tick. + return false; + } + return res.ok; + } catch { + return false; // network errors / timeouts → no switch, never throw + } finally { + clearTimeout(timer); + } +} + +// ── Mode-switch worker ──────────────────────────────────────────────── + +/** Path for the llama-proxy's local status endpoint (idle evaluation). */ +export const PROXY_STATUS_PATH = '/llama/local/status'; + +/** + * Fetch and parse the llama-proxy's `/llama/local/status` payload for idle + * evaluation. Returns null on any failure mode (network error, timeout, + * non-2xx, invalid JSON) — propagated as proxyStatus null ⇒ fail-closed + * (treated as busy, no cheap switch). + */ +export async function fetchProxyStatus( + proxyUrl: string, + fetcher: AdminApiFetcher = globalThis.fetch as unknown as AdminApiFetcher, +): Promise { + const url = `${proxyUrl.replace(/\/+$/, '')}${PROXY_STATUS_PATH}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ADMIN_API_TIMEOUT_MS); + try { + const res = await fetcher(url, { signal: controller.signal }); + if (!res.ok) return null; + const raw = (await res.json()) as LlamaStatus; + if (typeof raw !== 'object' || raw === null) return null; + return raw as LlamaStatus; + } catch { + return null; // network errors / timeouts → unknown, never throw + } finally { + clearTimeout(timer); + } +} + + +/** Tick inputs for the mode-switch worker (settings re-read per tick). */ +export interface ModeSwitchTickOptions { + /** Whether activity-gated mode switching is enabled (modeSwitchEnabled). */ + enabled: boolean; + /** Idle window in ms (modeSwitchIdleThresholdMs, clamped). */ + idleThresholdMs: number; + /** Proxy base URL (reused from downtimeProxyUrl). */ + proxyUrl: string; + /** + * Latest parsed `/llama/local/status` payload, or null when the status + * endpoint failed / timed out / was ambiguous. null ⇒ proxy treated as + * BUSY (fail-closed: no cheap switch). + */ + proxyStatus: LlamaStatus | null; +} + +/** Injectable time source (tests use a deterministic clock). */ +export type Now = () => number; + +/** + * The mode-switch worker interface. Used by the scheduler (worklist.ts) to + * tick the worker and by index.ts (agent-route hook) to record operator + * activity and fire fast-switches. + */ +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 + * already fast. Never blocks or delays the command dispatch (fail-open). + * `proxyUrl` is the shared `downtimeProxyUrl`. + */ + onOperatorCommand(proxyUrl: string): void; + /** + * One poll tick: when enabled and the idle window is met and the proxy + * reports idle, POST `/admin/set-mode {"mode":"cheap"}` — skipping when + * the freshly-read persisted mode is already cheap. All failures are + * fail-closed (no switch) and never throw. + */ + tick(opts: ModeSwitchTickOptions): Promise; +} + +/** + * Create the mode-switch worker. Construction sets + * `lastOperatorCommandAt = now()` — the idle clock resets to "active now", + * so after a plugin restart the proxy stays fast until a fresh full idle + * window passes (fail-safe, operator decision Q3: "a"). + * + * @param deps - Injectable fetcher/time (tests). + */ +export function createModeSwitchWorker(deps?: { + fetcher?: AdminApiFetcher; + now?: Now; +}): ModeSwitchWorker { + const fetcher = deps?.fetcher ?? (globalThis.fetch as unknown as AdminApiFetcher); + const now = deps?.now ?? Date.now; + + /** Last operator agent-route command timestamp; construction = "now" (restart reset). */ + let lastOperatorCommandAt: number = now(); + /** Last known proxy mode (refreshed via GET /admin/mode on each poll). */ + let lastKnownMode: ProxyMode | null = null; + /** True while a mode-switch POST is in flight (per-process single-flight). */ + let switchInFlight = false; + + /** + * Fire-and-forget mode switch with per-process single-flight and + * last-known-mode dedup. Resolves when the switch attempt settles; used + * both by the fast path (operator command) and the cheap path (idle + * tick). Never throws. + */ + const fireSwitch = async (proxyUrl: string, target: ProxyMode): Promise => { + if (switchInFlight) return; // at most one switch in flight per process + if (lastKnownMode === target) return; // no redundant switching + switchInFlight = true; + try { + const ok = await setAdminMode(proxyUrl, target, fetcher); + if (ok) { + lastKnownMode = target; + } + // A 409 (restart in progress) or any failure leaves lastKnownMode + // unchanged — the poller retries on a later tick (fail-closed). + } catch { + // fail-closed: a throwing admin client (regression) never crashes. + } finally { + switchInFlight = false; + } + }; + + return { + onOperatorCommand(proxyUrl: string): void { + // Record operator activity (agent-route command). + lastOperatorCommandAt = now(); + // Fire-and-forget fast switch (fail-open: never blocks dispatch). + void fireSwitch(proxyUrl, 'fast'); + }, + + async tick(opts: ModeSwitchTickOptions): Promise { + if (!opts.enabled) return; + if (switchInFlight) return; // single-flight: a switch is settling + + // Idle window: has the operator issued no agent-route command for ≥ + // the threshold? (lastOperatorCommandAt can never be null — restart + // resets to "active now", so a fresh full window is always required.) + const elapsed = now() - lastOperatorCommandAt; + if (elapsed < opts.idleThresholdMs) 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 + // proxyStatus ⇒ fetch it; a fetch failure is fail-closed. + const proxyStatus = + opts.proxyStatus !== null + ? opts.proxyStatus + : await fetchProxyStatus(opts.proxyUrl, fetcher); + + // Proxy idle gate: a busy proxy (active local query / lease / model + // switch / missing fields) DELAYS the switch — never kills in-flight + // work (operator decision Q4b). Reuses evaluateIdle with + // requiredFreeSlots = 0 (all slots free). proxyStatus null (endpoint + // failure, timeout, ambiguous) ⇒ busy (fail-closed). + if (proxyStatus === null || !evaluateIdle(proxyStatus, 0)) return; + + // Refresh last-known mode via GET /admin/mode before deciding (AC: + // "track last-known mode; refresh via GET /admin/mode on poll"). + // A failed read keeps lastKnownMode as-is (fail-closed, no switch). + const currentMode = await getAdminMode(opts.proxyUrl, fetcher); + if (currentMode !== null) { + 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; + + await fireSwitch(opts.proxyUrl, 'cheap'); + }, + }; +} \ No newline at end of file diff --git a/packages/herdr/src/scheduled-prompts.test.ts b/packages/herdr/src/scheduled-prompts.test.ts new file mode 100644 index 000000000..574a71f9d --- /dev/null +++ b/packages/herdr/src/scheduled-prompts.test.ts @@ -0,0 +1,353 @@ +/** + * Unit tests for scheduled-prompts.ts — project-local scheduled-prompts + * config for the downtime dispatcher (WL-0MSS1Q5ER007QDKX). + * + * Covers the AC8 matrix for the scheduled-prompts tier: config + * parse/defaults, due-check arithmetic (null lastTriggeredAt, boundary, + * not-yet-due), absent/malformed-config fail-closed, invalid-entry + * skipping, config-order selection, and the atomic tmp+rename persist + * (updateScheduledPromptLastTriggered). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdirSync, + writeFileSync, + existsSync, + readFileSync, + readdirSync, + rmSync, + mkdtempSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + DAY_MS, + SCHEDULED_PROMPTS_FILE, + scheduledPromptsPath, + parseScheduledPrompt, + isValidScheduledPrompt, + isDueScheduledPrompt, + getDueScheduledPrompt, + loadScheduledPrompts, + saveScheduledPrompts, + updateScheduledPromptLastTriggered, + type ScheduledPrompt, +} from './scheduled-prompts.js'; + +const tempDirs: string[] = []; +function makeTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'scheduled-prompts-test-')); + tempDirs.push(dir); + return dir; +} + +const refactorPrompt: ScheduledPrompt = { + id: 'refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, +}; + +describe('scheduledPromptsPath', () => { + it('resolves the config file inside /.worklog', () => { + expect(scheduledPromptsPath('/repo')).toBe( + join('/repo', '.worklog', SCHEDULED_PROMPTS_FILE), + ); + }); +}); + +// ── Parsing (AC7: invalid entries are rejected fail-closed) ────────── + +describe('parseScheduledPrompt', () => { + it('accepts a well-formed entry', () => { + expect(parseScheduledPrompt(refactorPrompt)).toEqual(refactorPrompt); + }); + + it('rejects a missing/empty id', () => { + expect(parseScheduledPrompt({ ...refactorPrompt, id: '' })).toBeNull(); + expect(parseScheduledPrompt({ ...refactorPrompt, id: 42 })).toBeNull(); + }); + + it('rejects a missing or empty prompt text', () => { + expect(parseScheduledPrompt({ ...refactorPrompt, prompt: '' })).toBeNull(); + expect(parseScheduledPrompt({ ...refactorPrompt, prompt: ' ' })).toBeNull(); + expect(parseScheduledPrompt({ ...refactorPrompt, prompt: 7 })).toBeNull(); + }); + + it('rejects missing/non-finite/zero/negative intervalDays', () => { + expect(parseScheduledPrompt({ ...refactorPrompt, intervalDays: undefined })).toBeNull(); + expect(parseScheduledPrompt({ ...refactorPrompt, intervalDays: Number.NaN })).toBeNull(); + expect(parseScheduledPrompt({ ...refactorPrompt, intervalDays: 0 })).toBeNull(); + expect(parseScheduledPrompt({ ...refactorPrompt, intervalDays: -3 })).toBeNull(); + expect(parseScheduledPrompt({ ...refactorPrompt, intervalDays: '3' })).toBeNull(); + }); + + it('accepts a missing lastTriggeredAt and normalizes it to null (treated as due, AC7)', () => { + const { lastTriggeredAt: _omit, ...rest } = refactorPrompt; + const parsed = parseScheduledPrompt(rest); + expect(parsed).toEqual({ ...refactorPrompt, lastTriggeredAt: null }); + }); + + it('rejects a non-string or unparseable lastTriggeredAt (fail-closed)', () => { + expect(parseScheduledPrompt({ ...refactorPrompt, lastTriggeredAt: 123 })).toBeNull(); + expect(parseScheduledPrompt({ ...refactorPrompt, lastTriggeredAt: 'not-a-date' })).toBeNull(); + }); + + it('rejects non-object entries', () => { + expect(parseScheduledPrompt(null)).toBeNull(); + expect(parseScheduledPrompt('refactor')).toBeNull(); + expect(parseScheduledPrompt(42)).toBeNull(); + }); +}); + +// ── Due-check arithmetic (AC3: due iff null or now - last >= intervalDays) ── + +describe('isDueScheduledPrompt', () => { + const now = Date.parse('2026-08-17T00:00:00.000Z'); + + it('null lastTriggeredAt means never run → due', () => { + expect(isDueScheduledPrompt({ ...refactorPrompt, lastTriggeredAt: null }, now)).toBe(true); + }); + + it('is exactly due when now - last == intervalDays (boundary, >=)', () => { + const last = new Date(now - 3 * DAY_MS).toISOString(); + expect(isDueScheduledPrompt({ ...refactorPrompt, lastTriggeredAt: last }, now)).toBe(true); + }); + + it('is not yet due one ms before the boundary', () => { + const last = new Date(now - 3 * DAY_MS + 1).toISOString(); + expect(isDueScheduledPrompt({ ...refactorPrompt, lastTriggeredAt: last }, now)).toBe(false); + }); + + it('is due once past the interval', () => { + const last = new Date(now - 10 * DAY_MS).toISOString(); + expect(isDueScheduledPrompt({ ...refactorPrompt, lastTriggeredAt: last }, now)).toBe(true); + }); + + it('a lastTriggeredAt in the future (clock skew) is not due', () => { + const last = new Date(now + DAY_MS).toISOString(); + expect(isDueScheduledPrompt({ ...refactorPrompt, lastTriggeredAt: last }, now)).toBe(false); + }); + + it('applies the entry-specific intervalDays', () => { + const weekly = { ...refactorPrompt, id: 'weekly', intervalDays: 7 }; + const last = new Date(now - 3 * DAY_MS).toISOString(); + // 3 days elapsed; due for the 3-day entry, NOT for the weekly entry. + expect(isDueScheduledPrompt({ ...refactorPrompt, lastTriggeredAt: last }, now)).toBe(true); + expect(isDueScheduledPrompt({ ...weekly, lastTriggeredAt: last }, now)).toBe(false); + }); +}); + +// ── Selection (AC6/AC7: first due entry in config order) ───────────── + +describe('getDueScheduledPrompt', () => { + const now = Date.parse('2026-08-17T00:00:00.000Z'); + const due = { ...refactorPrompt, lastTriggeredAt: null }; + const notDue = { + ...refactorPrompt, + id: 'not-yet', + lastTriggeredAt: new Date(now - DAY_MS).toISOString(), // due in 2 days + }; + + it('returns null when no entry is due', () => { + expect(getDueScheduledPrompt([notDue], now)).toBeNull(); + expect(getDueScheduledPrompt([], now)).toBeNull(); + }); + + it('returns the FIRST due entry in config order when several are due', () => { + const first = { ...due, id: 'first' }; + const second = { ...due, id: 'second' }; + expect(getDueScheduledPrompt([first, second], now)?.id).toBe('first'); + // Unconfigured order is preserved — the input order is authoritative. + expect(getDueScheduledPrompt([second, first], now)?.id).toBe('second'); + }); + + it('skips non-due entries before the first due one', () => { + expect(getDueScheduledPrompt([notDue, due], now)?.id).toBe('refactor'); + }); +}); + +// ── Load (AC2: absent ⇒ empty set + notice; malformed ⇒ empty set + error) ── + +describe('loadScheduledPrompts', () => { + let cwd: string; + const logs: string[] = []; + const collectLog = (msg: string) => logs.push(msg); + + beforeEach(() => { + cwd = makeTempDir(); + logs.length = 0; + }); + + afterEach(() => { + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; + }); + + it('treats an absent config as an empty set (fail-closed) and logs a notice', () => { + const result = loadScheduledPrompts(cwd, collectLog); + expect(result.entries).toEqual([]); + expect(result.absent).toBe(true); + expect(result.malformed).toBe(false); + expect(logs.join('\n')).toContain('absent'); + }); + + it('treats malformed JSON as an empty set (fail-closed) and logs an error', () => { + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync(scheduledPromptsPath(cwd), '{not json', 'utf8'); + const result = loadScheduledPrompts(cwd, collectLog); + expect(result.entries).toEqual([]); + expect(result.absent).toBe(false); + expect(result.malformed).toBe(true); + expect(logs.join('\n')).toContain('fail-closed'); + }); + + it('treats a config without an entries list as malformed (fail-closed)', () => { + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync(scheduledPromptsPath(cwd), JSON.stringify({ version: 1 }), 'utf8'); + const result = loadScheduledPrompts(cwd, collectLog); + expect(result.entries).toEqual([]); + expect(result.malformed).toBe(true); + }); + + it('parses a valid config, skipping invalid entries with a warning (AC7)', () => { + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + scheduledPromptsPath(cwd), + JSON.stringify({ + entries: [ + refactorPrompt, + { id: 'broken', prompt: '', intervalDays: 0, lastTriggeredAt: null }, + { id: 'weekly', prompt: '/skill:refactor', intervalDays: 7, lastTriggeredAt: null }, + ], + }), + 'utf8', + ); + const result = loadScheduledPrompts(cwd, collectLog); + expect(result.absent).toBe(false); + expect(result.malformed).toBe(false); + expect(result.entries.map((e) => e.id)).toEqual(['refactor', 'weekly']); + expect(logs.join('\n')).toContain('invalid entry'); + }); + + it('loads a config written by saveScheduledPrompts', async () => { + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + await saveScheduledPrompts(cwd, { entries: [refactorPrompt] }); + const result = loadScheduledPrompts(cwd, collectLog); + expect(result.entries).toEqual([refactorPrompt]); + }); +}); + +// ── Atomic persist (AC4: tmp+rename; failures fail closed) ─────────── + +describe('saveScheduledPrompts', () => { + let cwd: string; + + beforeEach(() => { + cwd = makeTempDir(); + }); + + afterEach(() => { + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; + }); + + it('creates the .worklog directory and writes the config JSON', async () => { + await saveScheduledPrompts(cwd, { entries: [refactorPrompt] }); + const file = scheduledPromptsPath(cwd); + expect(existsSync(file)).toBe(true); + const parsed = JSON.parse(readFileSync(file, 'utf8')) as { entries: ScheduledPrompt[] }; + expect(parsed.entries).toEqual([refactorPrompt]); + }); + + it('leaves no tmp file behind after a successful write (atomic tmp+rename)', async () => { + await saveScheduledPrompts(cwd, { entries: [refactorPrompt] }); + const dirEntries = readdirSync(join(cwd, '.worklog')); + expect(dirEntries).toEqual([SCHEDULED_PROMPTS_FILE]); + }); + + it('overwrites an existing file atomically (create-if-absent is the CLI contract, not the save contract)', async () => { + await saveScheduledPrompts(cwd, { entries: [refactorPrompt] }); + await saveScheduledPrompts(cwd, { + entries: [{ ...refactorPrompt, id: 'weekly', intervalDays: 7 }], + }); + const parsed = JSON.parse(readFileSync(scheduledPromptsPath(cwd), 'utf8')) as { entries: ScheduledPrompt[] }; + expect(parsed.entries.map((e) => e.id)).toEqual(['weekly']); + }); +}); + +describe('updateScheduledPromptLastTriggered', () => { + let cwd: string; + const at = '2026-08-17T12:00:00.000Z'; + + beforeEach(() => { + cwd = makeTempDir(); + }); + + afterEach(() => { + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; + }); + + it('persists lastTriggeredAt and preserves the other entries', async () => { + const weekly = { ...refactorPrompt, id: 'weekly', intervalDays: 7 }; + await saveScheduledPrompts(cwd, { entries: [weekly, refactorPrompt] }); + const ok = await updateScheduledPromptLastTriggered(cwd, 'refactor', at); + expect(ok).toBe(true); + + const parsed = JSON.parse(readFileSync(scheduledPromptsPath(cwd), 'utf8')) as { entries: ScheduledPrompt[] }; + expect(parsed.entries.find((e) => e.id === 'refactor')?.lastTriggeredAt).toBe(at); + expect(parsed.entries.find((e) => e.id === 'weekly')?.lastTriggeredAt).toBeNull(); + }); + + it('preserves unknown top-level fields (e.g. version) on rewrite', async () => { + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + scheduledPromptsPath(cwd), + JSON.stringify({ version: 2, entries: [refactorPrompt] }), + 'utf8', + ); + await updateScheduledPromptLastTriggered(cwd, 'refactor', at); + const parsed = JSON.parse(readFileSync(scheduledPromptsPath(cwd), 'utf8')) as Record; + expect(parsed.version).toBe(2); + }); + + it('returns false when the entry id is unknown (fail-closed)', async () => { + await saveScheduledPrompts(cwd, { entries: [refactorPrompt] }); + expect(await updateScheduledPromptLastTriggered(cwd, 'nope', at)).toBe(false); + }); + + it('returns false when the config is absent or malformed (fail-closed, never throws)', async () => { + expect(await updateScheduledPromptLastTriggered(cwd, 'refactor', at)).toBe(false); + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync(scheduledPromptsPath(cwd), '{broken', 'utf8'); + expect(await updateScheduledPromptLastTriggered(cwd, 'refactor', at)).toBe(false); + }); + + it('returns false when the target entry is not an object (fail-closed)', async () => { + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync( + scheduledPromptsPath(cwd), + JSON.stringify({ entries: ['refactor'] }), + 'utf8', + ); + expect(await updateScheduledPromptLastTriggered(cwd, 'refactor', at)).toBe(false); + }); +}); + +// ── isValidScheduledPrompt is the parse predicate ───────────────────── + +describe('isValidScheduledPrompt', () => { + it('mirrors parseScheduledPrompt', () => { + expect(isValidScheduledPrompt(refactorPrompt)).toBe(true); + expect(isValidScheduledPrompt({ ...refactorPrompt, prompt: '' })).toBe(false); + expect(isValidScheduledPrompt({ ...refactorPrompt, intervalDays: -1 })).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/herdr/src/scheduled-prompts.ts b/packages/herdr/src/scheduled-prompts.ts new file mode 100644 index 000000000..d068c6828 --- /dev/null +++ b/packages/herdr/src/scheduled-prompts.ts @@ -0,0 +1,278 @@ +/** + * packages/herdr/src/scheduled-prompts.ts — Scheduled-prompts config for the + * downtime dispatcher (WL-0MSS1Q5ER007QDKX) + * + * Project-local config file consumed by the downtime worker's scheduled- + * prompts tier: `/.worklog/scheduled-prompts.json` (provisioned by + * `wl init` from `templates/scheduled-prompts.json`, create-if-absent). + * + * Each entry carries: + * - a stable `id` (used for the pane name `Downtime ` and the rolling + * log marker itemId), + * - the `prompt` text (any text the pi agent pane can run, e.g. + * `/skill:refactor`), + * - a best-effort `intervalDays` frequency (whole days; a delayed dispatch + * never fires more often than the frequency), + * - `lastTriggeredAt` (ISO-8601 UTC datetime; `null` = never run). + * + * Fail-closed philosophy (mirrors settings.ts + the downtime worker): an + * absent config is an EMPTY set (logged notice — `wl init` is the + * provisioning path); a malformed config is an EMPTY set (logged error); + * an invalid entry is skipped (logged warning). None of these ever throw + * or crash the worker. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** File name of the scheduled-prompts config inside `.worklog/`. */ +export const SCHEDULED_PROMPTS_FILE = 'scheduled-prompts.json'; + +/** One day in milliseconds (best-effort frequency granularity is whole days). */ +export const DAY_MS = 24 * 60 * 60 * 1000; + +// ── Types ───────────────────────────────────────────────────────────── + +/** One scheduled-prompt entry from the config file. */ +export interface ScheduledPrompt { + /** Stable entry id (pane name `Downtime `, rolling-log marker itemId). */ + id: string; + /** Prompt text run by the pi agent pane (any text, e.g. `/skill:refactor`). */ + prompt: string; + /** Best-effort frequency in whole days (> 0). */ + intervalDays: number; + /** ISO-8601 UTC datetime of the last dispatch; null = never run. */ + lastTriggeredAt: string | null; +} + +/** Root shape of `.worklog/scheduled-prompts.json`. */ +export interface ScheduledPromptsConfig { + entries: ScheduledPrompt[]; +} + +/** Result of loading the config (entries are ALWAYS the valid subset). */ +export interface ScheduledPromptsLoadResult { + entries: ScheduledPrompt[]; + /** true when the config file was absent (fail-closed empty set, notice logged). */ + absent: boolean; + /** true when the file existed but could not be parsed (fail-closed empty set, error logged). */ + malformed: boolean; +} + +/** Injectable logger (defaults to the plugin's stderr channel). */ +export type ScheduledPromptLog = (message: string) => void; + +/** + * Default logger: the plugin's established `[worklog-plugin]` stderr + * channel. Fail-closed logging must never crash the worker, so a throwing + * logger is a non-issue here (these writes are best-effort diagnostics). + */ +export function defaultScheduledPromptLog(message: string): void { + process.stderr.write(`[worklog-plugin] ${message}\n`); +} + +// ── Path / parse ────────────────────────────────────────────────────── + +/** Resolve the config file path for a worklog ROOT (`/.worklog/...`). */ +export function scheduledPromptsPath(cwd: string): string { + return join(cwd, '.worklog', SCHEDULED_PROMPTS_FILE); +} + +/** + * Parse one raw config entry into a typed `ScheduledPrompt`, or null when + * the entry is invalid (fail-closed, AC7): + * + * - id must be a non-empty string, + * - prompt text must be a non-empty (non-whitespace) string, + * - intervalDays must be a finite number > 0, + * - lastTriggeredAt must be null, absent (⇒ normalized to null = due), or + * an ISO-8601 string; a non-string or unparseable value rejects the + * whole entry (skipped with a warning — never a crash). + */ +export function parseScheduledPrompt(entry: unknown): ScheduledPrompt | null { + if (typeof entry !== 'object' || entry === null) return null; + const o = entry as Record; + + if (typeof o.id !== 'string' || o.id.length === 0) return null; + if (typeof o.prompt !== 'string' || o.prompt.trim().length === 0) return null; + if (typeof o.intervalDays !== 'number' || !Number.isFinite(o.intervalDays) || o.intervalDays <= 0) { + return null; + } + + let lastTriggeredAt: string | null = null; + if (o.lastTriggeredAt !== undefined && o.lastTriggeredAt !== null) { + if (typeof o.lastTriggeredAt !== 'string') return null; + if (Number.isNaN(new Date(o.lastTriggeredAt).getTime())) return null; + lastTriggeredAt = o.lastTriggeredAt; + } + + return { id: o.id, prompt: o.prompt, intervalDays: o.intervalDays, lastTriggeredAt }; +} + +/** Parse predicate: true when the entry is a valid scheduled prompt. */ +export function isValidScheduledPrompt(entry: unknown): entry is ScheduledPrompt { + return parseScheduledPrompt(entry) !== null; +} + +// ── Due check (AC3: due iff null or now - last >= intervalDays) ─────── + +/** + * Due-check for ONE entry (best-effort frequency): due iff + * `lastTriggeredAt` is null (never run) or the interval has fully elapsed + * (`now - last >= intervalDays * DAY_MS`). A `lastTriggeredAt` in the + * future (clock skew) is not due. + */ +export function isDueScheduledPrompt(entry: ScheduledPrompt, now: number = Date.now()): boolean { + if (entry.lastTriggeredAt === null) return true; + const last = new Date(entry.lastTriggeredAt).getTime(); + // Defensive: an unparseable timestamp (constructed directly, bypassing + // parseScheduledPrompt) is NaN → never due (fail-closed). + if (Number.isNaN(last)) return false; + return now - last >= entry.intervalDays * DAY_MS; +} + +/** + * Select the first due entry in CONFIG ORDER (AC6/AC7): entries dispatch + * one per idle slot in config order. Returns null when none are due. + */ +export function getDueScheduledPrompt( + entries: ScheduledPrompt[], + now: number = Date.now(), +): ScheduledPrompt | null { + return entries.find((entry) => isDueScheduledPrompt(entry, now)) ?? null; +} + +// ── Load (AC2: absent ⇒ empty + notice; malformed ⇒ empty + error) ──── + +/** + * Load the scheduled-prompts config for a worklog root, fail-closed: + * + * - absent file ⇒ `{ entries: [], absent: true }` with a logged notice + * (`wl init` is the provisioning path — no synthesis of defaults here), + * - unreadable/corrupt JSON/wrong shape ⇒ `{ entries: [], malformed: true }` + * with a logged error, + * - valid file ⇒ the VALID entries (invalid ones are skipped with a logged + * warning, AC7 — never a crash). + * + * Never throws. + */ +export function loadScheduledPrompts( + cwd: string, + log: ScheduledPromptLog = defaultScheduledPromptLog, +): ScheduledPromptsLoadResult { + const file = scheduledPromptsPath(cwd); + + if (!existsSync(file)) { + log( + `scheduled-prompts: config file absent at ${file} — treating the ` + + `scheduled-prompt set as empty (fail-closed; run \`wl init\` to provision).`, + ); + return { entries: [], absent: true, malformed: false }; + } + + let raw: string; + try { + raw = readFileSync(file, 'utf8'); + } catch (err) { + log( + `scheduled-prompts: unreadable config at ${file} (${(err as Error).message}) — ` + + `no scheduled dispatch (fail-closed).`, + ); + return { entries: [], absent: false, malformed: true }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + log( + `scheduled-prompts: malformed JSON in ${file} — no scheduled dispatch (fail-closed).`, + ); + return { entries: [], absent: false, malformed: true }; + } + + if (typeof parsed !== 'object' || parsed === null || !Array.isArray((parsed as { entries?: unknown }).entries)) { + log( + `scheduled-prompts: config at ${file} has no entries list — no scheduled ` + + `dispatch (fail-closed).`, + ); + return { entries: [], absent: false, malformed: true }; + } + + const entries: ScheduledPrompt[] = []; + const rawEntries = (parsed as { entries: unknown[] }).entries; + for (const [index, rawEntry] of rawEntries.entries()) { + const entry = parseScheduledPrompt(rawEntry); + if (entry === null) { + log( + `scheduled-prompts: skipping invalid entry #${index + 1} in ${file} ` + + `(missing/invalid id, prompt, intervalDays, or lastTriggeredAt) — fail-closed.`, + ); + continue; + } + entries.push(entry); + } + return { entries, absent: false, malformed: false }; +} + +// ── Atomic persist (AC4: tmp+rename; failures fail closed) ─────────── + +/** + * Atomically write the config file: write to a unique tmp file in the same + * directory, then rename over the target (a reader never sees a partial + * write). Creates `.worklog/` when missing. Throws on failure — callers + * (updateScheduledPromptLastTriggered) catch and fail closed. + */ +export async function saveScheduledPrompts(cwd: string, config: ScheduledPromptsConfig): Promise { + const dir = join(cwd, '.worklog'); + const file = scheduledPromptsPath(cwd); + await mkdir(dir, { recursive: true }); + const tmp = join( + dir, + `${SCHEDULED_PROMPTS_FILE}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + ); + await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, 'utf8'); + await rename(tmp, file); +} + +/** + * Persist a scheduled prompt's `lastTriggeredAt` (atomic tmp+rename) after a + * successful dispatch, so a delayed dispatch never fires more often than its + * frequency. Re-reads the RAW file immediately before writing + * (last-writer-wins vs a concurrent user edit is the accepted risk) and + * preserves ALL other fields verbatim — including entries the loader would + * skip as invalid, so a runtime write never silently deletes user config. + * + * Resolves FALSE on any failure (absent/malformed file, unknown id, I/O + * error) — the caller ABORTS the spawn (an unrecorded dispatch never runs) + * and the entry stays due for the next idle slot. Never throws. + */ +export async function updateScheduledPromptLastTriggered( + cwd: string, + id: string, + at: string, +): Promise { + try { + const file = scheduledPromptsPath(cwd); + if (!existsSync(file)) return false; // removed while we looked — fail closed + const raw = readFileSync(file, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== 'object' || parsed === null) return false; + const root = parsed as Record; + if (!Array.isArray(root.entries)) return false; + + const idx = (root.entries as unknown[]).findIndex( + (entry) => typeof entry === 'object' && entry !== null && (entry as Record).id === id, + ); + if (idx < 0) return false; // unknown id — fail closed + (root.entries[idx] as Record).lastTriggeredAt = at; + + await saveScheduledPrompts(cwd, root as unknown as ScheduledPromptsConfig); + return true; + } catch { + // Fail-closed: any I/O/parse error means the state could not be + // persisted — the dispatch must not proceed unrecorded. + return false; + } +} \ No newline at end of file diff --git a/packages/herdr/src/scheduler.test.ts b/packages/herdr/src/scheduler.test.ts index f0f436a83..4637ccbb8 100644 --- a/packages/herdr/src/scheduler.test.ts +++ b/packages/herdr/src/scheduler.test.ts @@ -416,3 +416,54 @@ describe('TaskScheduler', () => { }); }); }); + +describe('getIntervalMs hook (per-schedule jitter, WL-0MSSRED76008LGB6)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('recomputes the interval on every reschedule when the hook is set', async () => { + const run = vi.fn(); + const hook = vi.fn().mockReturnValueOnce(30_000).mockReturnValueOnce(45_000).mockReturnValue(15_000); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, getIntervalMs: hook, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(30_000); // first fire at 30s (static arm) + expect(run).toHaveBeenCalledTimes(1); + expect(hook).toHaveBeenCalledTimes(1); // asked for the NEXT interval (30s → 60s) + + await vi.advanceTimersByTimeAsync(30_000); // next fire at 60s (30 + 30 from hook) + expect(run).toHaveBeenCalledTimes(2); + expect(hook).toHaveBeenCalledTimes(2); // asked again (45s → next at 105s) + + await vi.advanceTimersByTimeAsync(45_000); // next fire at 105s (60 + 45 from hook) + expect(run).toHaveBeenCalledTimes(3); + expect(hook).toHaveBeenCalledTimes(3); + + scheduler.stop(); + }); + + it('first arm uses the static interval; the hook applies from the second fire', async () => { + const run = vi.fn(); + const hook = vi.fn().mockReturnValue(45_000); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, getIntervalMs: hook, run }); + scheduler.start(); + + // First fire at the static 30s (addTask arms from intervalMs, not the hook) + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); + expect(hook).toHaveBeenCalledTimes(1); + + // Second fire uses the hooked 45s + await vi.advanceTimersByTimeAsync(45_000); + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); +}); diff --git a/packages/herdr/src/scheduler.ts b/packages/herdr/src/scheduler.ts index 1e954c5d9..d3f448a74 100644 --- a/packages/herdr/src/scheduler.ts +++ b/packages/herdr/src/scheduler.ts @@ -48,6 +48,14 @@ export interface SchedulerTask { id: string; /** Period between runs in milliseconds (must be > 0). */ intervalMs: number; + /** + * Optional per-schedule interval override (WL-0MSSRED76008LGB6): when + * set, each reschedule computes a FRESH interval from this hook (e.g. + * jittered ±50% of the base) instead of reusing the static `intervalMs`. + * The first arm uses `intervalMs`; every subsequent reschedule asks the + * hook. Returns the effective interval for the NEXT run. + */ + getIntervalMs?: () => number; /** The work to run when the task is due. May be async. */ run: () => Promise | void; /** @@ -101,6 +109,10 @@ export class TaskScheduler { constructor(private readonly tickMs: number) {} + private static intervalFor(entry: TaskEntry): number { + return entry.task.getIntervalMs ? entry.task.getIntervalMs() : entry.task.intervalMs; + } + /** * Register a periodic task. Tasks with `intervalMs <= 0` are treated as * disabled and never fire. Adding a task while the scheduler is running @@ -128,7 +140,7 @@ export class TaskScheduler { entry.task.disabled = disabled; if (!disabled) { // (Re)arm from now — mirrors starting a fresh interval. - entry.dueAt = Date.now() + entry.task.intervalMs; + entry.dueAt = Date.now() + TaskScheduler.intervalFor(entry); } } @@ -176,7 +188,7 @@ export class TaskScheduler { if (now < entry.dueAt) continue; if (entry.task.singleFlight && entry.inFlight) { // Skipped tick is not coalesced — reschedule to the next interval. - entry.dueAt = now + entry.task.intervalMs; + entry.dueAt = now + TaskScheduler.intervalFor(entry); continue; } await this.runTask(entry, now); @@ -197,7 +209,7 @@ export class TaskScheduler { * permanently wedge the task, WL-0MSJIPHD0001L1J9). */ private async runTask(entry: TaskEntry, now: number): Promise { - entry.dueAt = now + entry.task.intervalMs; + entry.dueAt = now + TaskScheduler.intervalFor(entry); entry.inFlight = true; try { const timeoutMs = entry.task.runTimeoutMs; diff --git a/packages/herdr/src/settings.test.ts b/packages/herdr/src/settings.test.ts index d8e0c5071..8a442208a 100644 --- a/packages/herdr/src/settings.test.ts +++ b/packages/herdr/src/settings.test.ts @@ -115,3 +115,90 @@ describe('downtimeNoCandidateCooldownMs', () => { expect(loadSettings(path).downtimeNoCandidateCooldownMs).toBe(3_600_000); }); }); + +describe('modeSwitchEnabled', () => { + it('defaults to false (activity-gated mode switching is opt-in)', () => { + expect(defaultSettings.modeSwitchEnabled).toBe(false); + const path = tempSettingsPath(); + expect(loadSettings(path).modeSwitchEnabled).toBe(false); + }); + + it('accepts a persisted boolean true', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, modeSwitchEnabled: true }); + expect(loadSettings(path).modeSwitchEnabled).toBe(true); + + saveSettings(path, { ...defaultSettings, modeSwitchEnabled: false }); + expect(loadSettings(path).modeSwitchEnabled).toBe(false); + }); + + it('falls back to the default when the persisted value is not a boolean', () => { + const path = tempSettingsPath(); + writeFileSync(path, JSON.stringify({ ...defaultSettings, modeSwitchEnabled: 'yes' }), 'utf-8'); + expect(loadSettings(path).modeSwitchEnabled).toBe(false); + }); +}); + +describe('modeSwitchIdleThresholdMs', () => { + it('defaults to 900_000 ms (15 minutes)', () => { + expect(defaultSettings.modeSwitchIdleThresholdMs).toBe(900_000); + const path = tempSettingsPath(); + expect(loadSettings(path).modeSwitchIdleThresholdMs).toBe(900_000); + }); + + it('clamps a persisted value below the 60s floor up to the floor', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, modeSwitchIdleThresholdMs: 1_000 }); + expect(loadSettings(path).modeSwitchIdleThresholdMs).toBe(60_000); + + saveSettings(path, { ...defaultSettings, modeSwitchIdleThresholdMs: 600_000 }); + expect(loadSettings(path).modeSwitchIdleThresholdMs).toBe(600_000); + }); + + it('falls back to the default when the persisted value is not a number', () => { + const path = tempSettingsPath(); + writeFileSync(path, JSON.stringify({ ...defaultSettings, modeSwitchIdleThresholdMs: 'later' }), 'utf-8'); + expect(loadSettings(path).modeSwitchIdleThresholdMs).toBe(900_000); + }); +}); + +describe('modeSwitchPollIntervalMs', () => { + it('defaults to 10_000 ms', () => { + expect(defaultSettings.modeSwitchPollIntervalMs).toBe(10_000); + const path = tempSettingsPath(); + expect(loadSettings(path).modeSwitchPollIntervalMs).toBe(10_000); + }); + + it('clamps a persisted value below the 5s floor up to the floor', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, modeSwitchPollIntervalMs: 1_000 }); + expect(loadSettings(path).modeSwitchPollIntervalMs).toBe(5_000); + }); + + it('clamps a persisted value above the 60s cap down to the cap', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, modeSwitchPollIntervalMs: 300_000 }); + expect(loadSettings(path).modeSwitchPollIntervalMs).toBe(60_000); + }); + + it('falls back to the default when the persisted value is not a number', () => { + const path = tempSettingsPath(); + writeFileSync(path, JSON.stringify({ ...defaultSettings, modeSwitchPollIntervalMs: 'often' }), 'utf-8'); + expect(loadSettings(path).modeSwitchPollIntervalMs).toBe(10_000); + }); +}); + +describe('mode-switch proxy URL reuse', () => { + it('reuses downtimeProxyUrl (no separate mode-switch URL key)', () => { + // The mode-switch worker does not define its own URL setting; the proxy + // URL is shared with the downtime worker. + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, downtimeProxyUrl: 'http://example.invalid:8000' }); + expect(loadSettings(path).downtimeProxyUrl).toBe('http://example.invalid:8000'); + }); + + it('defaults downtimeProxyUrl when absent (shared default)', () => { + const path = tempSettingsPath(); + expect(loadSettings(path).downtimeProxyUrl).toBe('http://192.168.0.199:8000'); + }); +}); diff --git a/packages/herdr/src/settings.ts b/packages/herdr/src/settings.ts index 83cd69855..d344453d7 100644 --- a/packages/herdr/src/settings.ts +++ b/packages/herdr/src/settings.ts @@ -19,6 +19,12 @@ import { DEFAULT_DOWNTIME_PROXY_URL, DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, } from './downtime-worker.js'; +import { + clampModeSwitchIdleThresholdMs, + clampModeSwitchPollIntervalMs, + DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS, + DEFAULT_MODE_SWITCH_POLL_INTERVAL_MS, +} from './mode-switch-worker.js'; import { dirname } from 'node:path'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -44,7 +50,11 @@ export interface PluginSettings { downtimeEnabled: boolean; /** Minimum continuous idle duration before dispatch (ms). */ downtimeIdleThresholdMs: number; - /** Required free slots; 0 = all slots (default), or a positive integer N. */ + /** + * Required free slots; 0 = all slots, or a positive integer N (default 2 + * of 3 — spare-capacity dispatch, at least one slot reserved for the + * operator session). + */ downtimeRequiredFreeSlots: number; /** Poll interval for the proxy status endpoint (hard floor 10s). */ downtimePollIntervalMs: number; @@ -58,6 +68,16 @@ export interface PluginSettings { * Floor 60s; default 3_600_000 ms (60 min). */ downtimeNoCandidateCooldownMs: number; + /** Enable activity-gated mode-switching (fast on agent command, cheap on idle). */ + modeSwitchEnabled: boolean; + /** + * Idle window before switching to cheap mode (ms). Default 900_000 (15 min). + * A new operator agent-route command resets this timer. + */ + modeSwitchIdleThresholdMs: number; + /** Poll interval for the mode-switch worker (ms). Defaults to the same + * cadence as the downtime poller; clamped to a sensible range. */ + modeSwitchPollIntervalMs: number; } // ── Defaults ────────────────────────────────────────────────────────── @@ -77,6 +97,9 @@ export const defaultSettings: PluginSettings = { downtimeProxyUrl: DEFAULT_DOWNTIME_PROXY_URL, downtimeModel: DEFAULT_DOWNTIME_MODEL, downtimeNoCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, + modeSwitchEnabled: false, + modeSwitchIdleThresholdMs: DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS, + modeSwitchPollIntervalMs: DEFAULT_MODE_SWITCH_POLL_INTERVAL_MS, }; /** Minimum allowed browseItemCount. */ @@ -164,6 +187,14 @@ export function loadSettings(settingsPath?: string): PluginSettings { downtimeNoCandidateCooldownMs: typeof parsed.downtimeNoCandidateCooldownMs === 'number' ? clampDowntimeNoCandidateCooldownMs(parsed.downtimeNoCandidateCooldownMs) : defaultSettings.downtimeNoCandidateCooldownMs, + modeSwitchEnabled: typeof parsed.modeSwitchEnabled === 'boolean' + ? parsed.modeSwitchEnabled : defaultSettings.modeSwitchEnabled, + modeSwitchIdleThresholdMs: typeof parsed.modeSwitchIdleThresholdMs === 'number' + ? clampModeSwitchIdleThresholdMs(parsed.modeSwitchIdleThresholdMs) + : defaultSettings.modeSwitchIdleThresholdMs, + modeSwitchPollIntervalMs: typeof parsed.modeSwitchPollIntervalMs === 'number' + ? clampModeSwitchPollIntervalMs(parsed.modeSwitchPollIntervalMs) + : defaultSettings.modeSwitchPollIntervalMs, }; } catch { return { ...defaultSettings }; diff --git a/packages/herdr/src/shortcut-config.test.ts b/packages/herdr/src/shortcut-config.test.ts index 9d0789518..cedb395f2 100644 --- a/packages/herdr/src/shortcut-config.test.ts +++ b/packages/herdr/src/shortcut-config.test.ts @@ -131,14 +131,62 @@ describe('loadShortcutConfig — production shortcuts.json', () => { expect(registry.lookupChordEntry(['a', 'a'], 'list')?.workItemTypes).toBeUndefined(); }); - it('registers the f s sprint chord to return to the default view (WL-0MSGSE15000746F7)', () => { + it('registers the f s s sprint chord to return to the default view (WL-0MSGSE15000746F7, WL-0MSKC8T46006999S)', () => { const registry = loadShortcutConfig(); - const entry = registry.lookupChordEntry(['f', 's'], 'list', undefined, false); + // The sprint chord moved under the stage axis: `f s s` (previously the + // single-key `f s`). + const entry = registry.lookupChordEntry(['f', 's', 's'], 'list', undefined, false); expect(entry).toBeDefined(); expect(entry?.command).toBe('/wl'); expect(entry?.label).toBe('sprint'); }); + it('registers the stage-axis filter chords f s i|n|p|r (WL-0MSKC8T46006999S)', () => { + const registry = loadShortcutConfig(); + const expected: Array<[string[], string, string]> = [ + [['f', 's', 'i'], '/wl idea', 'filter stage idea'], + [['f', 's', 'n'], '/wl intake', 'filter stage intake'], + [['f', 's', 'p'], '/wl plan', 'filter stage plan'], + [['f', 's', 'r'], '/wl review', 'filter stage review'], + ]; + for (const [chord, command, label] of expected) { + const entry = registry.lookupChordEntry(chord, 'list', undefined, false); + expect(entry).toBeDefined(); + expect(entry?.command).toBe(command); + expect(entry?.label).toBe(label); + } + }); + + it('registers the priority-axis filter chords f p l|m|h|c and the f p s clear chord (WL-0MSKC8T46006999S)', () => { + const registry = loadShortcutConfig(); + const expected: Array<[string[], string, string]> = [ + [['f', 'p', 'l'], '/wl --priority low', 'filter priority low'], + [['f', 'p', 'm'], '/wl --priority medium', 'filter priority medium'], + [['f', 'p', 'h'], '/wl --priority high', 'filter priority high'], + [['f', 'p', 'c'], '/wl --priority critical', 'filter priority critical'], + [['f', 'p', 's'], '/wl', 'clear priority'], + ]; + for (const [chord, command, label] of expected) { + const entry = registry.lookupChordEntry(chord, 'list', undefined, false); + expect(entry).toBeDefined(); + expect(entry?.command).toBe(command); + expect(entry?.label).toBe(label); + } + }); + + it('no longer registers the old single-key stage filter chords (f i, f n, f p, f r) or the single f s sprint chord (WL-0MSKC8T46006999S)', () => { + const registry = loadShortcutConfig(); + // The old single-key stage chords must NOT resolve — `f i`/`f n`/`f p`/ + // `f r` previously filtered by stage, and `f s` was the sprint chord. + // `f s` and `f p` are now pure chord prefixes (axis leaders). + for (const chord of [['f', 'i'], ['f', 'n'], ['f', 'p'], ['f', 'r'], ['f', 's']]) { + expect(registry.lookupChordEntry(chord, 'list', undefined, false)).toBeUndefined(); + } + // The axis leaders are still valid prefixes for the new scheme. + expect(registry.getChordByPrefix(['f', 's'], 'list', undefined, false).length).toBe(5); + expect(registry.getChordByPrefix(['f', 'p'], 'list', undefined, false).length).toBe(5); + }); + it('registers the S ship-it chord to /skill:ship release (WL-0MSGG5N5Z0074TLY)', () => { const registry = loadShortcutConfig(); const entry = registry.lookupChordEntry(['S'], 'list', undefined, false); @@ -161,6 +209,141 @@ describe('loadShortcutConfig — production shortcuts.json', () => { expect(registry.lookupChordEntry(['S'], 'list', undefined, true)).toBeDefined(); expect(registry.getEntriesForStage(undefined, true).some(e => e.chord[0] === 'S')).toBe(true); }); + + it('marks the quiet state-change shortcuts with open_pane false (WL-0MSJLD1I70045ZUL)', () => { + const registry = loadShortcutConfig(); + // Chords deliberately configured to run in the background (no pane): + // audit approve/reject, priority updates, close/delete. Every other + // bundled shortcut omits the flag (default opens a pane). + const expectedNoPane = new Set([ + 'a,y', // audit approve + 'a,r', // audit reject + 'u,p,l', // update priority low + 'u,p,m', // update priority medium + 'u,p,h', // update priority high + 'u,p,c', // update priority critical + 'x,c', // close done + 'x,d', // close deleted + ]); + const noPane: string[] = []; + for (const entry of registry.getEntries()) { + const key = entry.chord.join(','); + if (entry.openPane === false) noPane.push(key); + } + expect(new Set(noPane)).toEqual(expectedNoPane); + }); + + it('verifies the a-y audit-approve command text (WL-0MSJLD1I70045ZUL)', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['a', 'y'], 'list'); + expect(entry?.openPane).toBe(false); + expect(entry?.command).toBe( + "!!wl reviewed false && wl audit-set --ready-to-close yes --summary 'Approved by manual review'", + ); + }); + + it('a-r (audit reject) resets status/stage/priority on reject (WL-0MSM72QXN008GUP2)', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['a', 'r'], 'list', undefined, false); + expect(entry).toBeDefined(); + expect(entry?.command).toContain('wl reviewed false'); + expect(entry?.command).toContain('wl update --status open --stage plan_complete --priority medium'); + expect(entry?.command).toContain("wl audit-set --ready-to-close no --summary 'Rejected by manual review. '"); + expect(entry?.label).toBe('audit reject'); + expect(entry?.stages).toEqual(['in_review']); + }); + + it('a-y (audit approve) and a-r (audit reject) differ only in the audit-set flag (WL-0MSM72QXN008GUP2)', () => { + const registry = loadShortcutConfig(); + const approve = registry.lookupChordEntry(['a', 'y'], 'list', undefined, false); + const reject = registry.lookupChordEntry(['a', 'r'], 'list', undefined, false); + // Both share the reviewed reset + audit-set chain. + expect(approve?.command).toContain('wl reviewed false'); + expect(reject?.command).toContain('wl reviewed false'); + // Approve sets ready-to-close yes; reject sets it no plus the reset. + expect(approve?.command).toContain('--ready-to-close yes'); + expect(reject?.command).toContain('--ready-to-close no'); + expect(reject?.command).toContain('--status open --stage plan_complete --priority medium'); + }); + + it('registers the d downtime-toggle chord to /downtime toggle (WL-0MSZ4NSOE007AQEF)', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['d'], 'list', undefined, false); + expect(entry).toBeDefined(); + expect(entry?.command).toBe('/downtime toggle'); + expect(entry?.label).toBe('downtime toggle'); + expect(entry?.view).toBe('both'); + // Visible in both list and detail views. + expect(registry.lookupChordEntry(['d'], 'detail', undefined, false)).toBeDefined(); + }); +}); + +// ── parseShortcutEntry ─────────────────────────────────────────────────── + +describe('parseShortcutEntry — open_pane field (WL-0MSJLD1I70045ZUL)', () => { + it('parses open_pane: false', () => { + const entry = parseShortcutEntry({ + chord: ['a', 'y'], + command: '!!wl reviewed false', + view: 'both', + open_pane: false, + }); + expect(entry?.openPane).toBe(false); + }); + + it('parses open_pane: true', () => { + const entry = parseShortcutEntry({ + chord: ['a', 'a'], + command: '/skill:audit ', + view: 'both', + open_pane: true, + }); + expect(entry?.openPane).toBe(true); + }); + + it('treats a missing open_pane as omit (backward compatible — pane opens)', () => { + const entry = parseShortcutEntry({ + chord: ['s'], + command: '!!wl search ', + view: 'both', + }); + expect(entry?.openPane).toBeUndefined(); + }); + + it('logs and treats an invalid open_pane as omit', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const cases: unknown[] = ['sometimes', 0, 1, null, [], {}]; + for (const bad of cases) { + const entry = parseShortcutEntry({ + chord: ['x'], + command: '!!wl close ', + view: 'both', + open_pane: bad, + }); + expect(entry?.openPane).toBeUndefined(); + } + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); + + it('keeps other fields intact when open_pane is present', () => { + const entry = parseShortcutEntry({ + chord: ['a', 'y'], + command: '!!wl reviewed false', + view: 'both', + model: 'plan', + code_freeze: 'allow', + open_pane: false, + }); + expect(entry).toMatchObject({ + chord: ['a', 'y'], + command: '!!wl reviewed false', + view: 'both', + model: 'plan', + codeFreeze: 'allow', + openPane: false, + }); + }); }); // ── parseShortcutEntry ─────────────────────────────────────────────────── diff --git a/packages/herdr/src/shortcut-config.ts b/packages/herdr/src/shortcut-config.ts index 4ae95a0a8..07eeb4519 100644 --- a/packages/herdr/src/shortcut-config.ts +++ b/packages/herdr/src/shortcut-config.ts @@ -51,6 +51,22 @@ export interface ShortcutEntry { * convention); invalid values are logged and treated as omitted. */ workItemTypes?: string[]; + /** + * Whether dispatching this shortcut should open a visible pane + * (WL-0MSJLD1I70045ZUL). Omitted (or `true`) opens a pane exactly as + * today: + * - `!!`/`!` shell commands open a "Command Output" herdr pane via + * run-in-pane.sh; + * - agent commands (`/skill:*`, `/intake`, `/plan`, `/prompt:`) open a + * pi agent pane via send-to-pi.sh. + * `false` runs the command in the background with stdout/stderr captured + * to a per-run log file — no pane is created, so the work-item ↔ pane + * association (WL-0MSBQUJQX005RAT9) is skipped for agent commands. + * Parsed from the `open_pane` key in shortcuts.json; invalid values are + * logged and treated as omitted (open a pane), mirroring the + * `code_freeze` pattern. + */ + openPane?: boolean; } // ── Registry ────────────────────────────────────────────────────────── @@ -264,6 +280,16 @@ export function parseShortcutEntry(raw: unknown): ShortcutEntry | undefined { console.error(`[shortcut-config] Invalid work_item_types value for shortcut "${command}"; expected a non-empty array of strings, treating as omitted`); } + const openPane = entry.open_pane; + if (openPane === true || openPane === false) { + shortcutEntry.openPane = openPane; + } else if (openPane !== undefined) { + // Invalid values are logged and treated as omit (open a pane — the + // default) — a bad value must never hide or break a shortcut + // (WL-0MSJLD1I70045ZUL). + console.error(`[shortcut-config] Invalid open_pane value "${String(openPane)}" for shortcut "${command}"; expected true or false, treating as omitted (open a pane)`); + } + // Agent-bound commands without an explicit model run on the default // `plan` model so every pi pane spawned from a shortcut opens with a // deterministic model (WL-0MSD48ZFC0043AO3). Non-agent commands (shell diff --git a/packages/herdr/src/shortcuts.json b/packages/herdr/src/shortcuts.json index 89ecbc2fb..47b019aec 100644 --- a/packages/herdr/src/shortcuts.json +++ b/packages/herdr/src/shortcuts.json @@ -1,4 +1,13 @@ [ + { + "chord": [ + "d" + ], + "command": "/downtime toggle", + "view": "both", + "label": "downtime toggle", + "description": "Toggle downtime dispatch for the current pane (d = disable; d again = follow settings; persists across restarts via marker)." + }, { "chord": [ "c" @@ -106,20 +115,22 @@ "in_review" ], "label": "audit approve", - "description": "Approve the in_review item by manual audit." + "description": "Approve the in_review item by manual audit.", + "open_pane": false }, { "chord": [ "a", "r" ], - "command": "!!wl reviewed false && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", + "command": "!!wl reviewed false && wl update --status open --stage plan_complete --priority medium && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", "view": "both", "stages": [ "in_review" ], "label": "audit reject", - "description": "Reject the in_review item by manual audit." + "description": "Reject the in_review item and reset it to open/plan_complete/medium for rework.", + "open_pane": false }, { "chord": [ @@ -130,7 +141,8 @@ "command": "!!wl update --priority low", "view": "both", "label": "update priority low", - "description": "Update the priority of the selected work item to low." + "description": "Update the priority of the selected work item to low.", + "open_pane": false }, { "chord": [ @@ -141,7 +153,8 @@ "command": "!!wl update --priority medium", "view": "both", "label": "update priority medium", - "description": "Update the priority of the selected work item to medium." + "description": "Update the priority of the selected work item to medium.", + "open_pane": false }, { "chord": [ @@ -152,7 +165,8 @@ "command": "!!wl update --priority high", "view": "both", "label": "update priority high", - "description": "Update the priority of the selected work item to high." + "description": "Update the priority of the selected work item to high.", + "open_pane": false }, { "chord": [ @@ -163,7 +177,8 @@ "command": "!!wl update --priority critical", "view": "both", "label": "update priority critical", - "description": "Update the priority of the selected work item to critical." + "description": "Update the priority of the selected work item to critical.", + "open_pane": false }, { "chord": [ @@ -193,7 +208,8 @@ "command": "!!wl close ", "view": "both", "label": "close done", - "description": "Close the work item as done." + "description": "Close the work item as done.", + "open_pane": false }, { "chord": [ @@ -203,7 +219,8 @@ "command": "!!wl delete ", "view": "both", "label": "close deleted", - "description": "Delete the work item." + "description": "Delete the work item.", + "open_pane": false }, { "chord": [ @@ -241,52 +258,112 @@ { "chord": [ "f", + "s", "i" ], "command": "/wl idea", "view": "both", - "label": "filter idea", - "description": "Filter browse list to items in the idea stage." + "label": "filter stage idea", + "description": "Filter browse list to items in the idea stage (WL-0MSKC8T46006999S: stage filters moved under the f s axis)." }, { "chord": [ "f", + "s", "n" ], "command": "/wl intake", "view": "both", - "label": "filter intake", - "description": "Filter browse list to items in the intake_complete stage." + "label": "filter stage intake", + "description": "Filter browse list to items in the intake_complete stage (WL-0MSKC8T46006999S)." }, { "chord": [ "f", + "s", "p" ], "command": "/wl plan", "view": "both", - "label": "filter plan", - "description": "Filter browse list to items in the plan_complete stage." + "label": "filter stage plan", + "description": "Filter browse list to items in the plan_complete stage (WL-0MSKC8T46006999S)." }, { "chord": [ "f", + "s", "r" ], "command": "/wl review", "view": "both", - "label": "filter in_review", - "description": "Filter browse list to items in the in_review stage." + "label": "filter stage review", + "description": "Filter browse list to items in the in_review stage (WL-0MSKC8T46006999S)." }, { "chord": [ "f", + "s", "s" ], "command": "/wl", "view": "both", "label": "sprint", - "description": "Return to the default unfiltered browse list (WL-0MSGSE15000746F7)." + "description": "Return to the default unfiltered browse list; clears both stage and priority filters (WL-0MSGSE15000746F7)." + }, + { + "chord": [ + "f", + "p", + "l" + ], + "command": "/wl --priority low", + "view": "both", + "label": "filter priority low", + "description": "Filter browse list to items with low priority (WL-0MSKC8T46006999S)." + }, + { + "chord": [ + "f", + "p", + "m" + ], + "command": "/wl --priority medium", + "view": "both", + "label": "filter priority medium", + "description": "Filter browse list to items with medium priority (WL-0MSKC8T46006999S)." + }, + { + "chord": [ + "f", + "p", + "h" + ], + "command": "/wl --priority high", + "view": "both", + "label": "filter priority high", + "description": "Filter browse list to items with high priority (WL-0MSKC8T46006999S)." + }, + { + "chord": [ + "f", + "p", + "c" + ], + "command": "/wl --priority critical", + "view": "both", + "label": "filter priority critical", + "description": "Filter browse list to items with critical priority (WL-0MSKC8T46006999S)." + }, + { + "chord": [ + "f", + "p", + "s" + ], + "command": "/wl", + "view": "both", + "label": "clear priority", + "description": "Clear the active priority filter and return to the unfiltered browse list (WL-0MSKC8T46006999S)." }, { "chord": ["n", "e"], diff --git a/packages/herdr/src/test-utils/mock-herdr-socket.ts b/packages/herdr/src/test-utils/mock-herdr-socket.ts new file mode 100644 index 000000000..fa95dcabc --- /dev/null +++ b/packages/herdr/src/test-utils/mock-herdr-socket.ts @@ -0,0 +1,286 @@ +/** + * packages/herdr/src/test-utils/mock-herdr-socket.ts — Mock herdr Unix-socket + * server for testing the event subscriber (F1 test harness). + * + * Provides a reusable in-memory herdr socket server (newline-delimited + * JSON-RPC) that accepts `events.subscribe` with the documented subscription + * shape and replies `{ "result": { "type": "subscription_started" } }`. + * Tests can then push scripted `{ "data": { ... }, "event": "..." }` frames + * to the connected client to simulate real herdr event streams. + * + * The mock uses a real Unix socket when HERDR_SOCKET_PORT is not set + * (matching production behaviour), or a TCP socket on 127.0.0.1 when + * HERDR_SOCKET_PORT is set (for environments where Unix sockets are not + * available — rare in tests but useful on some CI hosts). + */ + +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; + +// ── Types ──────────────────────────────────────────────────────────── + +/** A single pushed event frame from herdr (matches the live protocol). */ +export interface HerdrEventFrame { + /** Event type: "pane_focused", "pane_agent_status_changed", etc. */ + event: string; + /** Event payload — shape varies by event type. */ + data: Record; +} + +/** + * Request params for `events.subscribe` as the live herdr server expects them. + * Each subscription is either global (no pane_id) or per-pane. + */ +export interface SubscriptionRequest { + subscriptions: Array<{ type: string; pane_id?: string }>; +} + +/** + * A connected client socket, with helpers for pushing events. + */ +export interface MockClient { + /** Write a raw JSON-RPC frame (newline-delimited) to the client. */ + sendFrame(frame: string): void; + /** Push a single event frame in the herdr push format. */ + pushEvent(event: HerdrEventFrame): void; + /** Push a batch of event frames. */ + pushEvents(events: HerdrEventFrame[]): void; + /** Close the client connection. */ + close(): void; +} + +/** + * Mock herdr socket server. Creates a real socket, handles JSON-RPC framing, + * and exposes a method for tests to push events to connected clients. + */ +export class MockHerdrSocket { + private server: net.Server | null = null; + private clients = new Set(); + private port: number | null = null; + private socketPath: string | null = null; + + /** + * Create and start the mock server. + * + * When HERDR_SOCKET_PORT is set, uses a TCP socket on 127.0.0.1; + * otherwise creates a temp Unix socket in /tmp. + * + * @returns The resolved address (TCP host:port or Unix socket path). + */ + async start(): Promise { + const portEnv = process.env.HERDR_SOCKET_PORT; + + if (portEnv) { + // TCP mode: port specified by environment + this.port = parseInt(portEnv, 10); + this.server = net.createServer((socket) => { + this.handleConnection(socket); + }); + await new Promise((resolve, reject) => { + this.server!.listen({ port: this.port, host: '127.0.0.1' }, () => resolve()); + this.server!.on('error', reject); + }); + return `127.0.0.1:${this.port}`; + } + + // Unix socket mode: temp path in the system temp directory + this.socketPath = path.join(os.tmpdir(), `herdr-mock-${process.pid}-${Date.now()}.sock`); + + // Clean up stale socket files + try { + fs.unlinkSync(this.socketPath); + } catch { + // File doesn't exist — fine + } + + this.server = net.createServer((socket) => { + this.handleConnection(socket); + }); + + await new Promise((resolve, reject) => { + this.server!.listen(this.socketPath, () => resolve()); + this.server!.on('error', reject); + }); + + return this.socketPath; + } + + /** + * Handle a new client connection: parse JSON-RPC frames and respond. + */ + private handleConnection(socket: net.Socket): void { + let buffer = ''; + + socket.on('data', (chunk: Buffer) => { + buffer += chunk.toString(); + + // Process complete newline-delimited frames + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const frame = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + + if (!frame) continue; + + let request: unknown; + try { + request = JSON.parse(frame); + } catch { + // Malformed frame — ignore + continue; + } + + if (!this.isSubscribeRequest(request)) { + // Not an events.subscribe request — send an error + this.sendToClient(socket, { + jsonrpc: '2.0', + id: this.extractId(request), + error: { code: -32601, message: 'Method not found' }, + }); + continue; + } + + // Accept the subscription and send back subscription_started + this.sendToClient(socket, { + jsonrpc: '2.0', + id: this.extractId(request), + result: { type: 'subscription_started' }, + }); + + // Register this socket as a connected client + const client: MockClient = { + sendFrame: (f: string) => { + socket.write(f + '\n'); + }, + pushEvent: (e: HerdrEventFrame) => { + this.sendToClient(socket, { + jsonrpc: '2.0', + id: null, + notification: true, + data: e.data, + event: e.event, + }); + }, + pushEvents: (events: HerdrEventFrame[]) => { + for (const e of events) { + socket.write(JSON.stringify({ + jsonrpc: '2.0', + id: null, + notification: true, + data: e.data, + event: e.event, + }) + '\n'); + } + }, + close: () => { + socket.end(); + }, + }; + this.clients.add(client); + + socket.on('close', () => { + this.clients.delete(client); + }); + } + }); + + socket.on('error', () => { + // Socket error — will be cleaned up by close + }); + } + + /** + * Send a JSON-RPC frame (newline-delimited) to a specific socket. + */ + private sendToClient(socket: net.Socket, obj: Record): void { + const frame = JSON.stringify(obj); + socket.write(frame + '\n'); + } + + /** + * Check if a request is an events.subscribe call. + */ + private isSubscribeRequest(request: unknown): boolean { + if (!request || typeof request !== 'object') return false; + const r = request as Record; + return r.method === 'events.subscribe'; + } + + /** + * Extract the request id for response correlation. + */ + private extractId(request: unknown): string | null { + if (!request || typeof request !== 'object') return null; + const r = request as Record; + return typeof r.id === 'string' ? r.id : null; + } + + /** + * Get all currently connected clients. + */ + getConnectedClients(): MockClient[] { + return [...this.clients]; + } + + /** + * Get the first connected client (convenience for single-client tests). + */ + getFirstClient(): MockClient | undefined { + return this.clients.values().next().value; + } + + /** + * Stop the mock server and clean up. + */ + async stop(): Promise { + // Close all client connections + for (const client of this.clients) { + client.close(); + } + this.clients.clear(); + + // Close the server + if (this.server) { + await new Promise((resolve) => { + this.server!.close(() => resolve()); + }); + this.server = null; + } + + // Clean up the Unix socket file + if (this.socketPath) { + try { + fs.unlinkSync(this.socketPath); + } catch { + // Socket file may already be removed + } + this.socketPath = null; + } + } + + /** + * Get the socket path or host:port for this mock server. + */ + getAddress(): string { + if (this.socketPath) return this.socketPath; + if (this.port) return `127.0.0.1:${this.port}`; + throw new Error('Mock server not started'); + } +} + +/** + * Create a mock herdr socket server with convenience helpers. + * + * Usage: + * const mock = await createMockSocket(); + * const client = mock.getFirstClient(); + * client.pushEvent({ event: "pane_focused", data: { pane_id: "w1:p1" } }); + * await mock.stop(); + */ +export async function createMockSocket(): Promise { + const mock = new MockHerdrSocket(); + await mock.start(); + return mock; +} diff --git a/packages/herdr/src/visibility.test.ts b/packages/herdr/src/visibility.test.ts index 85d0806b9..93dea072c 100644 --- a/packages/herdr/src/visibility.test.ts +++ b/packages/herdr/src/visibility.test.ts @@ -229,4 +229,72 @@ describe('PollGate', () => { // refresh + sync ticks in one cycle share a single herdr tab get call. expect(mockFn).toHaveBeenCalledTimes(1); }); + + it('setVisibleFromEvent records an event-driven visibility value within the TTL (no exec)', async () => { + // WL-0MSHB7DHO004RHBJ (F3): a pane_focused event must update the + // visibility gate without a herdr exec, until the TTL expires. + process.env.HERDR_TAB_ID = 'w1:t11'; + const mockFn = vi.fn().mockResolvedValue({ + stdout: tabGetEnvelope(false), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const gate = new PollGate(); + // Initially the polling path reports hidden. + expect(await gate.visible()).toBe(false); + expect(mockFn).toHaveBeenCalledTimes(1); + + // A pane_focused event says the pane is now visible — this must take + // effect IMMEDIATELY with no further herdr exec. + gate.setVisibleFromEvent(true); + expect(await gate.visible()).toBe(true); + expect(await gate.visible()).toBe(true); + expect(mockFn).toHaveBeenCalledTimes(1); // still just the one poll + }); + + it('setVisibleFromEvent(false) marks the pane hidden for the event path', async () => { + process.env.HERDR_TAB_ID = 'w1:t11'; + const mockFn = vi.fn().mockResolvedValue({ + stdout: tabGetEnvelope(true), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const gate = new PollGate(); + expect(await gate.visible()).toBe(true); + expect(mockFn).toHaveBeenCalledTimes(1); + + // A pane_focused/unfocused event reports the pane hidden. + gate.setVisibleFromEvent(false); + expect(await gate.visible()).toBe(false); + expect(mockFn).toHaveBeenCalledTimes(1); + }); + + it('polling resumes after the TTL expires following a setVisibleFromEvent', async () => { + vi.useFakeTimers(); + try { + process.env.HERDR_TAB_ID = 'w1:t11'; + const mockFn = vi + .fn() + .mockResolvedValueOnce({ stdout: tabGetEnvelope(false), stderr: '' }) // first poll: hidden + .mockResolvedValueOnce({ stdout: tabGetEnvelope(true), stderr: '' }); // poll after TTL: visible + setExecFileAsync(mockFn as any); + + const gate = new PollGate(undefined, 2000); + expect(await gate.visible()).toBe(false); // poll says hidden + + // Event says visible — cache updated, no exec. + gate.setVisibleFromEvent(true); + expect(await gate.visible()).toBe(true); + expect(mockFn).toHaveBeenCalledTimes(1); + + // TTL expires → polling resumes and re-stats a fresh value. + vi.advanceTimersByTime(2001); + expect(await gate.visible()).toBe(true); + expect(mockFn).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/packages/herdr/src/visibility.ts b/packages/herdr/src/visibility.ts index 8e2c5b823..7cd2fe144 100644 --- a/packages/herdr/src/visibility.ts +++ b/packages/herdr/src/visibility.ts @@ -115,4 +115,17 @@ export class PollGate { this.cachedAt = Date.now(); return this.cachedVisible; } + + /** + * Apply an event-driven visibility update (e.g. a `pane_focused` event + * for the current pane, WL-0MSHB7DHO004RHBJ). Records the value in the + * memoizer cache so subsequent `visible()` calls within the TTL return + * the event value WITHOUT a `herdr tab get` exec — the event path + * replaces the poll for that window. Fails open: the underlying polling + * check resumes automatically once the TTL expires. + */ + setVisibleFromEvent(visible: boolean): void { + this.cachedVisible = visible; + this.cachedAt = Date.now(); + } } diff --git a/packages/herdr/src/worklist-agent-state-event.test.ts b/packages/herdr/src/worklist-agent-state-event.test.ts new file mode 100644 index 000000000..ed71297e5 --- /dev/null +++ b/packages/herdr/src/worklist-agent-state-event.test.ts @@ -0,0 +1,363 @@ +/** + * packages/herdr/src/worklist-agent-state-event.test.ts — Integration tests + * for the EVENT-DRIVEN agent-status + focus/visibility paths + * (WL-0MSHB7DHO004RHBJ F4/F5/F6). + * + * Verifies end-to-end that a mocked herdr event socket drives: + * - `pane_agent_status_changed` → immediate icon update (no `herdr agent + * list` exec), coalesced per render cycle. + * - `pane_agent_detected` → re-read of the shared `.worklog/agent-panes.json` + * (cross-instance / late-spawned agents) + per-pane subscription sync. + * - `pane_closed` / `pane_exited` → prune associations + icon clear. + * - `pane_focused` → immediate visibility update (no `herdr tab get`) and + * refresh on hidden → visible, clearing the paused indicator. + * + * Uses the real events.ts + mock-herdr-socket fixture + real fetcher exec + * seam. Run: npx vitest run packages/herdr/src/worklist-agent-state-event.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Module mocks (hoisted before worklist.js is imported). +vi.mock('./auto-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runSync: vi.fn().mockResolvedValue({ success: true }) }; +}); +vi.mock('./notify.js', () => ({ showToast: vi.fn() })); + +import { runWorklistTui } from './worklist.js'; +import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; +import { AgentTracker } from './agent-tracker.js'; +import { HerdrEventSubscriber } from './events.js'; +import { createMockSocket, type MockHerdrSocket } from './test-utils/mock-herdr-socket.js'; +import type { WorkItem } from './fetcher.js'; + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +/** Fresh temp state file for a given test. */ +function makeStateFile(): { path: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), 'herdr-wl-event-')); + return { + path: join(dir, 'agent-panes.json'), + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +/** Seed the shared agent-panes.json with an association. */ +function seedStateFile( + path: string, + items: Array<{ workItemId: string; paneId: string }>, +): void { + writeFileSync( + path, + JSON.stringify( + items.map((i) => ({ + workItemId: i.workItemId, + paneId: i.paneId, + recordedAt: new Date().toISOString(), + })), + null, + 2, + ), + 'utf8', + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { value: vi.fn(), configurable: true, writable: true }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); + + process.env.HERDR_TAB_ID = 'w1:t11'; + process.env.HERDR_PANE_ID = 'w1:pCM'; + delete process.env.HERDR_BIN_PATH; +}); + +afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_TAB_ID; + delete process.env.HERDR_PANE_ID; + delete process.env.HERDR_BIN_PATH; + vi.restoreAllMocks(); +}); + +/** Exec mock that reports a visible tab and empty wl results. */ +function makeVisibleExecMock(): Mock { + return vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'tab' && args[1] === 'get') { + return { stdout: JSON.stringify({ id: 'cli:tab:get', result: { tab: { focused: true } } }), stderr: '' }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }); +} + +async function quit(p: Promise): Promise { + dataHandler?.(Buffer.from('q')); + await p; +} + +/** Sleep real ms (used between event pushes so frames settle). */ +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +const GREEN_CIRCLE = '\u{1F7E2}'; // 🟢 working +const RED_CIRCLE = '\u{26D4}'; // ⛔ blocked +const WHITE_CIRCLE = '\u{26AA}'; // ⚪ idle + +describe('worklist — event-driven agent status (WL-0MSHB7DHO004RHBJ)', () => { + it('a pane_agent_status_changed event updates the icon with no agent-list exec', async () => { + const sf = makeStateFile(); + seedStateFile(sf.path, [{ workItemId: 'WL-1', paneId: 'w3:p99' }]); + + const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); + const mockServer: MockHerdrSocket = await createMockSocket(); + const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + const execMock = makeVisibleExecMock(); + setExecFileAsync(execMock as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + agentTracker: tracker, + mergeAgentStates: async () => {}, + }); + + // Let initial render + subscriber connect settle. + await sleep(100); + const client = mockServer.getFirstClient(); + expect(client).toBeDefined(); + // No icon before any state is seeded. + expect(writes.join('')).not.toContain(GREEN_CIRCLE); + + // Push an agent status change → the icon appears immediately. + client!.pushEvent({ + event: 'pane_agent_status_changed', + data: { pane_id: 'w3:p99', agent_status: 'working' }, + }); + await sleep(100); + + const output = writes.join(''); + expect(output).toContain(GREEN_CIRCLE); + // The event path must NOT spawn `herdr agent list` (only tab get + wl). + expect( + execMock.mock.calls.filter((c) => c[0] === 'herdr' && c[1]?.[0] === 'agent'), + ).toHaveLength(0); + + await subscriber.close(); + await mockServer.stop(); + await quit(p); + sf.cleanup(); + }); + + it('pane_agent_status_changed flips an existing icon to blocked', async () => { + const sf = makeStateFile(); + const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); + const mockServer = await createMockSocket(); + const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + setExecFileAsync(makeVisibleExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + agentTracker: tracker, + mergeAgentStates: async () => {}, + }); + await sleep(100); + const client = mockServer.getFirstClient(); + + // Seed the association at runtime (as if dispatch recorded it). + await tracker.recordAgentForWorkItem('WL-1', 'w3:p99'); + await sleep(20); + client!.pushEvent({ + event: 'pane_agent_status_changed', + data: { pane_id: 'w3:p99', agent_status: 'blocked' }, + }); + await sleep(100); + + expect(writes.join('')).toContain(RED_CIRCLE); + + await subscriber.close(); + await mockServer.stop(); + await quit(p); + sf.cleanup(); + }); + + it('a pane_closed event prunes the association and clears the icon', async () => { + const sf = makeStateFile(); + seedStateFile(sf.path, [{ workItemId: 'WL-1', paneId: 'w3:p99' }]); + + const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); + const mockServer = await createMockSocket(); + const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + setExecFileAsync(makeVisibleExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + agentTracker: tracker, + mergeAgentStates: async () => {}, + }); + await sleep(100); + const client = mockServer.getFirstClient(); + + // Seed a working icon VIA THE EVENT PATH so the renderer re-applies + // the cached state (a direct tracker call never triggers a render). + client!.pushEvent({ + event: 'pane_agent_status_changed', + data: { pane_id: 'w3:p99', agent_status: 'working' }, + }); + await sleep(100); + expect(writes.join('')).toContain(GREEN_CIRCLE); + + // A pane_closed event prunes → icon clears. + client!.pushEvent({ event: 'pane_closed', data: { pane_id: 'w3:p99' } }); + await sleep(100); + writes.length = 0; // only inspect output produced AFTER the event + expect(writes.join('')).not.toContain(GREEN_CIRCLE); + + await subscriber.close(); + await mockServer.stop(); + await quit(p); + sf.cleanup(); + }); + + it('pane_agent_detected re-reads the shared file for a late-spawned agent (cross-instance)', async () => { + const sf = makeStateFile(); + const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); + const mockServer = await createMockSocket(); + const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + setExecFileAsync(makeVisibleExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-LATE', title: 'Late task', status: 'open', stage: 'idea' }, + ]); + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + agentTracker: tracker, + mergeAgentStates: async () => {}, + }); + await sleep(100); + const client = mockServer.getFirstClient(); + + // Another instance records a late association to the shared file. + seedStateFile(sf.path, [{ workItemId: 'WL-LATE', paneId: 'w5:p55' }]); + await sleep(20); + + // A pane_agent_detected event triggers the re-read. + client!.pushEvent({ event: 'pane_agent_detected', data: { pane_id: 'w5:p55' } }); + await sleep(100); + + // The association is now visible to this instance. + expect(tracker.getPaneId('WL-LATE')).toBe('w5:p55'); + + // And a status event for that late pane updates the icon. + client!.pushEvent({ + event: 'pane_agent_status_changed', + data: { pane_id: 'w5:p55', agent_status: 'idle' }, + }); + await sleep(100); + expect(writes.join('')).toContain(WHITE_CIRCLE); + + await subscriber.close(); + await mockServer.stop(); + await quit(p); + sf.cleanup(); + }); + + it('a pane_exited event clears the icon (treated like closed)', async () => { + const sf = makeStateFile(); + seedStateFile(sf.path, [{ workItemId: 'WL-1', paneId: 'w3:p99' }]); + + const tracker = new AgentTracker({ stateFile: sf.path, ttlMs: 60_000 }); + const mockServer = await createMockSocket(); + const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + setExecFileAsync(makeVisibleExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + agentTracker: tracker, + mergeAgentStates: async () => {}, + }); + await sleep(100); + const client = mockServer.getFirstClient(); + + // Seed a working icon via the event path. + client!.pushEvent({ + event: 'pane_agent_status_changed', + data: { pane_id: 'w3:p99', agent_status: 'working' }, + }); + await sleep(100); + expect(writes.join('')).toContain(GREEN_CIRCLE); + + client!.pushEvent({ event: 'pane_exited', data: { pane_id: 'w3:p99' } }); + await sleep(100); + writes.length = 0; // only inspect output produced AFTER the event + expect(writes.join('')).not.toContain(GREEN_CIRCLE); + + await subscriber.close(); + await mockServer.stop(); + await quit(p); + sf.cleanup(); + }); +}); diff --git a/packages/herdr/src/worklist-agent-state.test.ts b/packages/herdr/src/worklist-agent-state.test.ts index 75d92c7b7..1bb90c2bb 100644 --- a/packages/herdr/src/worklist-agent-state.test.ts +++ b/packages/herdr/src/worklist-agent-state.test.ts @@ -28,7 +28,7 @@ vi.mock('./notify.js', () => ({ import { runWorklistTui } from './worklist.js'; import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; -import { stringDisplayWidth } from './icons.js'; +import { stringDisplayWidth } from '@worklog/shared/icons'; import type { WorkItem } from './fetcher.js'; let dataHandler: ((chunk: Buffer) => void) | undefined; diff --git a/packages/herdr/src/worklist-group-toggle.test.ts b/packages/herdr/src/worklist-group-toggle.test.ts new file mode 100644 index 000000000..6b4ba4a10 --- /dev/null +++ b/packages/herdr/src/worklist-group-toggle.test.ts @@ -0,0 +1,202 @@ +/** + * packages/herdr/src/worklist-group-toggle.test.ts — Tab toggle for group + * collapse (T3 for WL-0MSL5MPSZ003TG94). + * + * Tests for: + * - Tab on a heading row toggles that group's collapse state + * - Tab on an item row keeps the existing expand/collapse-children behaviour + * - Enter on a heading is a no-op + * - Heading toggle does not disturb expandedItems / navigation stack + * - Collapsed group's items hidden from render (display rows) and navigation + * + * Run: npx vitest run packages/herdr/src/worklist-group-toggle.test.ts + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + WorkItemListState, + handleKeypress, +} from './worklist.js'; +import type { WorkItem } from './fetcher.js'; + +// ── Helpers ────────────────────────────────────────────────────────────── + +const TERM_80x24 = { rows: 24, cols: 80 }; + +function makeItem( + id: string, + group?: number, + groupLabel?: string, + stage?: string, + children?: WorkItem[], + childCount?: number, +): WorkItem { + return { id, title: `Item ${id}`, status: 'open', stage, group, groupLabel, children, childCount }; +} + +function makeChild(id: string): WorkItem { + return { id, title: `Child ${id}`, status: 'open' }; +} + +function makeGroupedList(): WorkItem[] { + return [ + makeItem('A', 1, 'Group 1'), + makeItem('B', 1, 'Group 1'), + makeItem('C', 2, 'Group 2'), + makeItem('D', 2, 'Group 2'), + makeItem('E', 3, 'Idea'), + ]; +} + +// ── Tab on heading toggles group collapse ──────────────────────────────── + +describe('Tab on a heading toggles group collapse', () => { + it('collapses the selected group', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // selectedIndex 0 = heading(1) + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 1 }); + + const action = handleKeypress(state, '\t', TERM_80x24); + expect(action).toBeNull(); // handled inline, no on-demand fetch action + expect(state.collapsedGroups.has(1)).toBe(true); + }); + + it('re-expands on the second Tab', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + handleKeypress(state, '\t', TERM_80x24); + expect(state.collapsedGroups.has(1)).toBe(true); + handleKeypress(state, '\t', TERM_80x24); + expect(state.collapsedGroups.has(1)).toBe(false); + }); + + it('toggles the group under any heading, not just the first', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // Navigate to heading(2): rows = [h1, A, B, h2, C, D, h3, E] → index 3 + state.selectedIndex = 3; + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 2 }); + + handleKeypress(state, '\t', TERM_80x24); + expect(state.collapsedGroups.has(2)).toBe(true); + expect(state.collapsedGroups.has(1)).toBe(false); + }); + + it('collapsing hides the group items from display rows and navigation', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = 3; // heading(2) + handleKeypress(state, '\t', TERM_80x24); + + const rows = state.getDisplayRows(); + const items = rows.filter((r): r is WorkItem => + typeof r === 'object' && r !== null && 'id' in r && !('kind' in r) + ); + expect(items.map(i => i.id)).toEqual(['A', 'B', 'E']); + // Navigation space shrinks: [h1, A, B, h2, h3, E] = 6 rows (was 8) + expect(state.flatCount).toBe(6); + }); + + it('the heading row stays selected and visible after collapse', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = 3; // heading(2) + handleKeypress(state, '\t', TERM_80x24); + // Selection stays on heading(2) — it remains a row + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 2, collapsed: true }); + }); +}); + +// ── Tab on item keeps existing behaviour ───────────────────────────────── + +describe('Tab on an item keeps existing expand/collapse-children behaviour', () => { + it('returns toggle-expand for an item with children', () => { + const parent = makeItem('P', 1, 'Group 1', 'plan_complete', [makeChild('C1')], 1); + const state = new WorkItemListState([parent], TERM_80x24); + state.selectedIndex = 1; // C1? — P is index 0, then heading? No: P group 1 at 0 + // display rows: [h1, P, C1] + state.selectedIndex = 1; // P + const action = handleKeypress(state, '\t', TERM_80x24); + expect(action).toBe('toggle-expand'); + expect(state.collapsedGroups.size).toBe(0); // no group toggled + }); + + it('expands an item with children and does not touch collapsedGroups', () => { + const parent = makeItem('P', 1, 'Group 1', 'plan_complete', [makeChild('C1'), makeChild('C2')], 2); + const state = new WorkItemListState([parent], TERM_80x24); + state.selectedIndex = 1; // P + const action = handleKeypress(state, '\t', TERM_80x24); + expect(action).toBe('toggle-expand'); + expect(state.isExpanded('P')).toBe(true); + expect(state.collapsedGroups.size).toBe(0); + }); + + it('returns null for an item without children (no toggle, no crash)', () => { + const item = makeItem('X', 1, 'Group 1'); + const state = new WorkItemListState([item], TERM_80x24); + state.selectedIndex = 1; // X + expect(handleKeypress(state, '\t', TERM_80x24)).toBeNull(); + expect(state.collapsedGroups.size).toBe(0); + }); + + it('item expand/collapse still works alongside collapsed groups', () => { + const parent = makeItem('P', 1, 'Group 1', 'plan_complete', [makeChild('C1')], 1); + const state = new WorkItemListState([parent], TERM_80x24); + state.selectedIndex = 1; // P + handleKeypress(state, '\t', TERM_80x24); // expand P + expect(state.isExpanded('P')).toBe(true); + handleKeypress(state, '\t', TERM_80x24); // collapse P + expect(state.isExpanded('P')).toBe(false); + expect(state.collapsedGroups.size).toBe(0); + }); +}); + +// ── Enter on heading is a no-op ────────────────────────────────────────── + +describe('Enter on a heading is a no-op', () => { + it('does not open the detail view', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + const action = handleKeypress(state, '\r', TERM_80x24); + expect(action).toBe('select'); + expect(state.mode).toBe('list'); + expect(state.detailItem).toBeNull(); + }); + + it('does not toggle the group (only Tab toggles)', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + handleKeypress(state, '\r', TERM_80x24); + expect(state.collapsedGroups.size).toBe(0); + }); + + it('Enter on a heading with children data does not expand it', () => { + // Heading rows never carry children — but guard: even if the row were an + // item with children, Enter on a HEADING must be a no-op. Use a heading + // selection and confirm no expand happens. + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + handleKeypress(state, '\r', TERM_80x24); + expect(state.mode).toBe('list'); + }); +}); + +// ── Navigation stack / expandedItems untouched by heading toggle ───────── + +describe('heading toggle does not disturb item state', () => { + it('expandedItems is untouched by a heading toggle', () => { + const parent = makeItem('P', 1, 'Group 1', 'plan_complete', [makeChild('C1')], 1); + const items = [parent, makeItem('A', 1, 'Group 1'), makeItem('B', 2, 'Group 2')]; + const state = new WorkItemListState(items, TERM_80x24); + state.toggleExpand('P'); + expect(state.isExpanded('P')).toBe(true); + + // Navigate to heading(2): rows = [h1, P, C1, A, h2, B] → index 4 + state.selectedIndex = 4; + handleKeypress(state, '\t', TERM_80x24); // collapse group 2 + expect(state.collapsedGroups.has(2)).toBe(true); + expect(state.isExpanded('P')).toBe(true); // untouched + }); + + it('navigation stack is untouched by a heading toggle', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.pushNavigationState('SOME-PARENT'); + expect(state.navigationStack.depth).toBe(1); + + handleKeypress(state, '\t', TERM_80x24); // collapse group 1 (heading selected) + expect(state.navigationStack.depth).toBe(1); + }); +}); diff --git a/packages/herdr/src/worklist-heading-metadata.test.ts b/packages/herdr/src/worklist-heading-metadata.test.ts new file mode 100644 index 000000000..8dbe92cb7 --- /dev/null +++ b/packages/herdr/src/worklist-heading-metadata.test.ts @@ -0,0 +1,146 @@ +/** + * packages/herdr/src/worklist-heading-metadata.test.ts — Metadata panel group + * info on heading selection (T5 for WL-0MSL5MPSZ003TG94). + * + * Tests for: + * - Selecting a heading row renders group info (label + count) in the + * metadata panel instead of stale item metadata + * - getSelectedItem() returns null for heading selection (panel caller + * renders group info) + * - Enter on a heading is a no-op (does not open a detail view) + * - Selecting an item row renders its normal metadata panel + * + * Run: npx vitest run packages/herdr/src/worklist-heading-metadata.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + createListRenderer, + WorkItemListState, + handleKeypress, + formatGroupInfoPanel, + type DisplayHeadingRow, +} from './worklist.js'; +import type { WorkItem } from './fetcher.js'; + +// ── Helpers ────────────────────────────────────────────────────────────── + +const TERM_80x24 = { rows: 24, cols: 80 }; + +function makeHeading(group: number, groupLabel: string, count: number, collapsed = false): DisplayHeadingRow { + return { kind: 'heading', group, groupLabel, count, collapsed }; +} + +function makeItem(id: string, group?: number, groupLabel?: string, stage?: string): WorkItem { + return { id, title: `Item ${id}`, status: 'open', stage, group, groupLabel, description: `Description of ${id}` }; +} + +function makeGroupedList(): WorkItem[] { + return [ + makeItem('A', 1, 'Group 1', 'in_progress'), + makeItem('B', 1, 'Group 1'), + makeItem('C', 2, 'Idea', 'idea'), + ]; +} + +// ── formatGroupInfoPanel ───────────────────────────────────────────────── + +describe('formatGroupInfoPanel', () => { + it('renders the group label, count and collapse state', () => { + const lines = formatGroupInfoPanel(makeHeading(1, 'Group 1', 3, false), 80, 8); + const text = lines.join('\n'); + expect(text).toContain('Group 1'); + expect(text).toContain('3'); + expect(text).toContain('expanded'); + }); + + it('renders the collapsed state when collapsed', () => { + const lines = formatGroupInfoPanel(makeHeading(2, 'In Review', 5, true), 80, 8); + const text = lines.join('\n'); + expect(text).toContain('In Review'); + expect(text).toContain('5'); + expect(text).toContain('collapsed'); + }); + + it('never exceeds the panel row budget', () => { + const lines = formatGroupInfoPanel(makeHeading(1, 'Group 1', 3), 80, 4); + expect(lines.length).toBeLessThanOrEqual(4); + }); +}); + +// ── Renderer metadata panel on heading selection ───────────────────────── + +describe('renderer metadata panel on heading selection', () => { + const renderer = createListRenderer(); + + it('shows group info (label + count) when a heading is selected', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // selectedIndex 0 = heading(1) + const output = renderer(state.getDisplayRows(), 0, 0, TERM_80x24, null, 'list', null); + expect(output).toContain('Group 1'); + expect(output).toContain('3'); + // No stale item metadata for item A + expect(output).not.toContain('Description of A'); + }); + + it('shows group info for any selected heading', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // Navigate to heading(2): rows = [h1, A, B, h2, C] → index 3 + state.selectedIndex = 3; + const output = renderer(state.getDisplayRows(), 3, 0, TERM_80x24, null, 'list', null); + expect(output).toContain('Idea'); + expect(output).toContain('1'); + }); + + it('shows the normal item metadata panel when an item is selected', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = 1; // A + const output = renderer(state.getDisplayRows(), 1, 0, TERM_80x24, null, 'list', null); + expect(output).toContain('Description of A'); + expect(output).toContain('── A ──'); + }); + + it('does not show group info when an item is selected', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = 1; // A + const output = renderer(state.getDisplayRows(), 1, 0, TERM_80x24, null, 'list', null); + // The panel is the item metadata — no group-count lines in the panel + // (the list-area heading row still shows the group label, which is fine). + expect(output).not.toContain('Items:'); + expect(output).not.toContain("Tab toggles this group's collapse state"); + }); +}); + +// ── getSelectedItem returns null for heading (panel caller contract) ───── + +describe('getSelectedItem() null for heading (AC2)', () => { + it('returns null for a heading selection', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + expect(state.getSelectedItem()).toBeNull(); + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading' }); + }); + + it('returns the item for an item selection', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.moveDown(); // A + expect(state.getSelectedItem()?.id).toBe('A'); + }); +}); + +// ── Enter on heading is a no-op (AC3) ──────────────────────────────────── + +describe('Enter on a heading is a no-op (AC3)', () => { + it('does not open the detail view', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + const action = handleKeypress(state, '\r', TERM_80x24); + expect(action).toBe('select'); + expect(state.mode).toBe('list'); + expect(state.detailItem).toBeNull(); + }); + + it('keeps the heading selected after Enter', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + handleKeypress(state, '\r', TERM_80x24); + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 1 }); + }); +}); diff --git a/packages/herdr/src/worklist-heading-rows.test.ts b/packages/herdr/src/worklist-heading-rows.test.ts new file mode 100644 index 000000000..018d6d6a7 --- /dev/null +++ b/packages/herdr/src/worklist-heading-rows.test.ts @@ -0,0 +1,347 @@ +/** + * packages/herdr/src/worklist-heading-rows.test.ts — Display-rows model with + * heading type (T1 for WL-0MSL5MPSZ003TG94). + * + * Tests for: + * - DisplayRow union type (heading / item) + * - WorkItemListState.collapsedGroups state + * - getDisplayRows() producing interleaved heading + item rows + * - Heading count = top-level items in group (unaffected by collapse) + * - collapsedGroups survives list refresh + * + * Run: npx vitest run packages/herdr/src/worklist-heading-rows.test.ts + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + WorkItemListState, + type DisplayRow, +} from './worklist.js'; +import type { WorkItem } from './fetcher.js'; + +// ── Helpers ────────────────────────────────────────────────────────────── + +const TERM_80x24 = { rows: 24, cols: 80 }; + +function makeItem( + id: string, + group?: number, + groupLabel?: string, + stage?: string, + children?: WorkItem[], +): WorkItem { + return { id, title: `Item ${id}`, status: 'open', stage, group, groupLabel, children }; +} + +function makeChild(id: string): WorkItem { + return { id, title: `Child ${id}`, status: 'open' }; +} + +/** + * Build a grouped list of items matching the regroupWorkItems output. + */ +function makeGroupedList(): WorkItem[] { + return [ + makeItem('A', 1, 'Group 1'), + makeItem('B', 1, 'Group 1'), + makeItem('C', 1, 'Group 1'), + makeItem('D', 2, 'Group 2'), + makeItem('E', 2, 'Group 2'), + makeItem('F', 3, 'Idea'), + ]; +} + +// ── DisplayRow type ────────────────────────────────────────────────────── + +describe('DisplayRow type', () => { + it('defines a heading row with correct shape', () => { + const heading: DisplayRow = { + kind: 'heading', + group: 1, + groupLabel: 'Group 1', + count: 3, + collapsed: false, + }; + expect(heading.kind).toBe('heading'); + expect(heading.group).toBe(1); + expect(heading.groupLabel).toBe('Group 1'); + expect(heading.count).toBe(3); + expect(heading.collapsed).toBe(false); + }); + + it('defines an item row as a WorkItem', () => { + const item: WorkItem = makeItem('A'); + // DisplayRow union: item rows are just WorkItem objects + expect(item.id).toBe('A'); + expect(item.title).toBe('Item A'); + }); +}); + +// ── collapsedGroups state ──────────────────────────────────────────────── + +describe('collapsedGroups state', () => { + it('starts empty', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + expect(state.collapsedGroups.size).toBe(0); + }); + + it('can toggle a group into collapsed state', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + state.toggleGroupCollapse(1); + expect(state.collapsedGroups.has(1)).toBe(true); + }); + + it('can toggle a collapsed group back to expanded', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + state.toggleGroupCollapse(1); + state.toggleGroupCollapse(1); + expect(state.collapsedGroups.has(1)).toBe(false); + }); +}); + +// ── getDisplayRows() ───────────────────────────────────────────────────── + +describe('getDisplayRows()', () => { + it('interleaves heading rows before each new group', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + const rows = state.getDisplayRows(); + + // Should have: heading(1), A, B, C, heading(2), D, E, heading(3), F + expect(rows.length).toBe(9); + + // First row is a heading + expect(rows[0]).toMatchObject({ kind: 'heading', group: 1, groupLabel: 'Group 1', count: 3, collapsed: false }); + // Second row is an item + expect((rows[1] as WorkItem).id).toBe('A'); + expect((rows[2] as WorkItem).id).toBe('B'); + expect((rows[3] as WorkItem).id).toBe('C'); + // Fourth row is heading for Group 2 + expect(rows[4]).toMatchObject({ kind: 'heading', group: 2, groupLabel: 'Group 2', count: 2, collapsed: false }); + // Fifth and sixth are items from Group 2 + expect((rows[5] as WorkItem).id).toBe('D'); + expect((rows[6] as WorkItem).id).toBe('E'); + // Seventh is heading for Idea + expect(rows[7]).toMatchObject({ kind: 'heading', group: 3, groupLabel: 'Idea', count: 1, collapsed: false }); + // Eighth is the Idea item + expect((rows[8] as WorkItem).id).toBe('F'); + }); + + it('shows correct count per group', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + const rows = state.getDisplayRows(); + + const headings = rows.filter((r): r is NonNullable & { kind: 'heading' } => + typeof r === 'object' && r !== null && 'kind' in r && (r as any).kind === 'heading' + ); + + expect(headings.length).toBe(3); + expect(headings[0].count).toBe(3); // Group 1 + expect(headings[1].count).toBe(2); // Group 2 + expect(headings[2].count).toBe(1); // Idea + }); + + it('excludes items of collapsed groups from display rows', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + + // Collapse group 1 + state.toggleGroupCollapse(1); + const rows = state.getDisplayRows(); + + // Should have: heading(1), heading(2), D, E, heading(3), F + expect(rows.length).toBe(6); + + // First row is still the heading for Group 1 + expect(rows[0]).toMatchObject({ kind: 'heading', group: 1, collapsed: true }); + // Next should be heading for Group 2 (items of group 1 are hidden) + expect(rows[1]).toMatchObject({ kind: 'heading', group: 2 }); + expect((rows[2] as WorkItem).id).toBe('D'); + }); + + it('keeps heading visible even when group is collapsed', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + + state.toggleGroupCollapse(1); + const rows = state.getDisplayRows(); + + const headings = rows.filter((r): r is NonNullable & { kind: 'heading' } => + typeof r === 'object' && r !== null && 'kind' in r && (r as any).kind === 'heading' + ); + + // All 3 headings should still be visible + expect(headings.length).toBe(3); + // First heading should show collapsed: true + expect(headings[0].collapsed).toBe(true); + // Count should still reflect the full group count (AC4) + expect(headings[0].count).toBe(3); + }); + + it('does not include items without a group field', () => { + const items = [ + makeItem('X', 1, 'Group 1'), + makeItem('Y'), // no group + makeItem('Z', 1, 'Group 1'), + ]; + const state = new WorkItemListState(items, TERM_80x24); + const rows = state.getDisplayRows(); + + // Should have: heading(1), X, Z, Y + // Items without a group are included but no heading is inserted for them + const itemsOnly = rows.filter((r): r is WorkItem => + typeof r === 'object' && r !== null && 'id' in r && !('kind' in r) + ); + expect(itemsOnly.length).toBe(3); + }); +}); + +// ── collapsedGroups survives refresh ───────────────────────────────────── + +describe('collapsedGroups survives list refresh', () => { + it('preserves collapsed groups across refreshItems', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + + // Collapse group 1 + state.toggleGroupCollapse(1); + expect(state.collapsedGroups.has(1)).toBe(true); + + // Simulate a refresh with updated items (same group assignments) + const refreshedItems = [ + makeItem('A', 1, 'Group 1'), + makeItem('B-updated', 1, 'Group 1'), // title changed + makeItem('C', 1, 'Group 1'), + makeItem('D', 2, 'Group 2'), + makeItem('E', 2, 'Group 2'), + makeItem('F', 3, 'Idea'), + ]; + state.refreshItems(refreshedItems); + + // collapsedGroups should survive + expect(state.collapsedGroups.has(1)).toBe(true); + + // Display rows should reflect the collapsed state + const rows = state.getDisplayRows(); + expect(rows.length).toBe(6); // heading(1), heading(2), D, E, heading(3), F + }); + + it('preserves collapsed groups when group assignments change after refresh', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + + // Collapse group 1 + state.toggleGroupCollapse(1); + + // Refresh with regrouped items — groups renumbered + const refreshedItems = [ + makeItem('D', 1, 'Group 1'), // was group 2 + makeItem('E', 1, 'Group 1'), + makeItem('A', 2, 'Group 2'), // was group 1, now group 2 + makeItem('B', 2, 'Group 2'), + makeItem('C', 2, 'Group 2'), + ]; + state.refreshItems(refreshedItems); + + // The collapsed set uses group numbers, so group 1 (now D/E) is collapsed. + // This is the documented in-session behaviour (same as expandedItems). + expect(state.collapsedGroups.has(1)).toBe(true); + const rows = state.getDisplayRows(); + // Group 1 (D/E) is collapsed → its items are hidden; group 2 (A/B/C) + // items remain visible. + const itemsVisible = rows.filter((r): r is WorkItem => + typeof r === 'object' && r !== null && 'id' in r && !('kind' in r) + ); + expect(itemsVisible.map(i => i.id).sort()).toEqual(['A', 'B', 'C']); + }); + + it('all headings visible with correct counts after refresh', () => { + const items = makeGroupedList(); + const state = new WorkItemListState(items, TERM_80x24); + + state.toggleGroupCollapse(2); + + const refreshedItems = [ + makeItem('A-new', 1, 'Group 1'), + makeItem('B-new', 1, 'Group 1'), + makeItem('C-new', 1, 'Group 1'), + makeItem('D-new', 2, 'Group 2'), + makeItem('F-new', 3, 'Idea'), + ]; + state.refreshItems(refreshedItems); + + const rows = state.getDisplayRows(); + const headings = rows.filter((r): r is NonNullable & { kind: 'heading' } => + typeof r === 'object' && r !== null && 'kind' in r && (r as any).kind === 'heading' + ); + + expect(headings.length).toBe(3); + expect(headings[0].count).toBe(3); + expect(headings[1].count).toBe(1); // Group 2 only has D now + expect(headings[2].count).toBe(1); + expect(headings[1].collapsed).toBe(true); + }); +}); + +// ── Interaction with expanded items ────────────────────────────────────── + +describe('getDisplayRows() with expanded items', () => { + it('includes children of expanded parents in display rows', () => { + const parent = makeItem('P', 1, 'Group 1', 'plan_complete', [makeChild('C1'), makeChild('C2')]); + parent.childCount = 2; + const items = [parent, makeItem('A', 1, 'Group 1')]; + const state = new WorkItemListState(items, TERM_80x24); + + // Expand the parent + state.toggleExpand('P'); + const rows = state.getDisplayRows(); + + // heading(1), P, C1, C2, A + expect(rows.length).toBe(5); + expect(rows[0]).toMatchObject({ kind: 'heading', group: 1 }); + expect((rows[1] as WorkItem).id).toBe('P'); + expect((rows[2] as WorkItem).id).toBe('C1'); + expect((rows[3] as WorkItem).id).toBe('C2'); + expect((rows[4] as WorkItem).id).toBe('A'); + }); + + it('count does NOT include children of expanded parents', () => { + const parent = makeItem('P', 1, 'Group 1', 'plan_complete', [makeChild('C1'), makeChild('C2')]); + parent.childCount = 2; + const items = [parent, makeItem('A', 1, 'Group 1')]; + const state = new WorkItemListState(items, TERM_80x24); + + state.toggleExpand('P'); + const rows = state.getDisplayRows(); + + const heading = rows[0] as NonNullable & { kind: 'heading' }; + // Count = top-level items only (P + A = 2), NOT including children + expect(heading.count).toBe(2); + }); + + it('items of collapsed groups are excluded even if parent is expanded', () => { + const parent = makeItem('P', 1, 'Group 1', 'plan_complete', [makeChild('C1'), makeChild('C2')]); + parent.childCount = 2; + const items = [parent, makeItem('A', 1, 'Group 1')]; + const state = new WorkItemListState(items, TERM_80x24); + + // Expand parent and collapse group + state.toggleExpand('P'); + state.toggleGroupCollapse(1); + const rows = state.getDisplayRows(); + + // Only heading visible (collapsed group hides P, C1, C2, A) + const itemsVisible = rows.filter((r): r is WorkItem => + typeof r === 'object' && r !== null && 'id' in r && !('kind' in r) + ); + expect(itemsVisible.length).toBe(0); + // Heading still visible with correct count (top-level items, regardless of collapse) + const heading = rows[0] as NonNullable & { kind: 'heading' }; + expect(heading.count).toBe(2); + expect(heading.collapsed).toBe(true); + }); +}); diff --git a/packages/herdr/src/worklist-icons.test.ts b/packages/herdr/src/worklist-icons.test.ts index 81ab6f63c..aa766b560 100644 --- a/packages/herdr/src/worklist-icons.test.ts +++ b/packages/herdr/src/worklist-icons.test.ts @@ -155,7 +155,7 @@ describe('worklist — showIcons gating through runWorklistTui', () => { expect(output).toContain('[OPEN]'); // status text fallback expect(output).toContain('WL-1'); expect(output).not.toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon - expect(output).toContain('[?]'); // audit text fallback + expect(output).toContain('unknown'); // audit text label (metadata falls back to plain text, WL-0MSGIXHHI009KFW9) await quit(p); }); diff --git a/packages/herdr/src/worklist-integration.test.ts b/packages/herdr/src/worklist-integration.test.ts new file mode 100644 index 000000000..14e4b076e --- /dev/null +++ b/packages/herdr/src/worklist-integration.test.ts @@ -0,0 +1,319 @@ +/** + * packages/herdr/src/worklist-integration.test.ts — End-to-end integration + * tests for collapsible group headings with item counts (T6 of + * WL-0MSL5MPSZ003TG94). + * + * Drives the real TUI (runWorklistTui) with a fake stdin/stdout harness: + * - AC1: navigate to a heading → Tab collapses the group → rows shrink in + * render AND navigation → Tab again expands. + * - AC2: heading counts reflect the top-level items in the group (post + * stage-filter) before AND after collapse. + * - AC3: the `rows - 1` line-count invariant holds under collapse + * configurations with the metadata panel. + * + * Run: npx vitest run packages/herdr/src/worklist-integration.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +vi.mock('./fetcher.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchActionableCount: vi.fn().mockResolvedValue(0), + fetchChildrenForItem: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock('./auto-sync.js', () => ({ + runSync: vi.fn().mockResolvedValue({ success: true }), + createSyncTimer: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + clampSyncInterval: vi.fn((v: number) => v), +})); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui } from './worklist.js'; +import { setWorklogDir, resetWorklogDir, type WorkItem } from './fetcher.js'; +import { setLogPath, resetLogPath } from './command-log.js'; +import { loadShortcutConfig } from './shortcut-config.js'; + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness (same pattern as code-freeze-dialog.test.ts) +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; +let tmpDir: string; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + setLogPath(join(tmpdir(), `herdr-int-cmdlog-${process.pid}-${Date.now()}.json`)); + tmpDir = mkdtempSync(join(tmpdir(), 'herdr-int-')); + setWorklogDir(tmpDir); + + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { + value: vi.fn(), + configurable: true, + writable: true, + }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); +}); + +afterEach(() => { + resetWorklogDir(); + resetLogPath(); + rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** Small async tick helper so awaited promises settle. */ +function tick(): Promise { + return new Promise((r) => setTimeout(r, 5)); +} + +function makeItem(id: string, group?: number, groupLabel?: string, stage?: string): WorkItem { + return { id, title: `Item ${id}`, status: 'open', stage, group, groupLabel }; +} + +function startTui(items: WorkItem[]): Promise { + return runWorklistTui(async () => items, items, loadShortcutConfig(), { + autoRefresh: false, + autoSync: false, + showHelpText: false, + }); +} + +/** Raw accumulated stdout (ANSI codes included). */ +function rawOutput(): string { + return writes.join(''); +} + +/** The last full render written to stdout (a complete redraw). */ +function lastRender(): string { + return writes[writes.length - 1] ?? ''; +} + +/** Strip ANSI codes for line counting. */ +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +/** Visible lines of the last render. */ +function visibleLines(): string[] { + return stripAnsi(lastRender()).split('\n'); +} + +// --------------------------------------------------------------------------- +// AC1: End-to-end collapse/expand scenario +// --------------------------------------------------------------------------- + +describe('AC1: TUI end-to-end collapse/expand scenario', () => { + const groupedItems = (): WorkItem[] => [ + makeItem('A', 1, 'Group 1'), + makeItem('B', 1, 'Group 1'), + makeItem('C', 2, 'Idea'), + makeItem('D', 2, 'Idea'), + ]; + + it('navigate to heading → Tab collapses → rows shrink in render and navigation → Tab expands', async () => { + const p = startTui(groupedItems()); + await tick(); + + // Display rows: [h1, A, B, h2, C, D]; initial selection is the h1 heading. + let lines = visibleLines().join('\n'); + expect(lines).toContain('── Group 1 (2) ▼ ──'); + expect(lines).toContain('── Idea (2) ▼ ──'); + expect(lines).toContain('Item A'); + + // Tab on the selected heading (h1) collapses Group 1. + dataHandler?.(Buffer.from('\t')); + await tick(); + await tick(); + + lines = visibleLines().join('\n'); + // Collapsed group renders only its heading — items A/B are hidden. + expect(lines).toContain('── Group 1 (2) ▶ ──'); + expect(lines).not.toContain('Item A'); + expect(lines).not.toContain('Item B'); + // Other group unaffected. + expect(lines).toContain('Item C'); + expect(lines).toContain('Item D'); + + // Navigation skips the collapsed items: j from h1 lands on h2, then C. + dataHandler?.(Buffer.from('j')); + await tick(); + await tick(); + // Selected row h2 — its heading line is reverse-video in the render. + let sel = selectedRowText(); + expect(sel).toContain('Idea'); + + dataHandler?.(Buffer.from('j')); + await tick(); + await tick(); + sel = selectedRowText(); + expect(sel).toContain('Item C'); + + // j again should wrap or stop — with the collapsed group, nav stays in + // the visible rows (no jump back into hidden A/B). + dataHandler?.(Buffer.from('j')); + await tick(); + await tick(); + sel = selectedRowText(); + expect(sel).toContain('Item D'); + + // Tab on the selected heading h2 collapses Idea too. + // Move back up to h2 (display rows: [h1, h2, C, D]). + dataHandler?.(Buffer.from('k')); + await tick(); + await tick(); + sel = selectedRowText(); + expect(sel).toContain('Item C'); + dataHandler?.(Buffer.from('k')); + await tick(); + await tick(); + sel = selectedRowText(); + expect(sel).toContain('Idea'); + dataHandler?.(Buffer.from('\t')); + await tick(); + await tick(); + lines = visibleLines().join('\n'); + expect(lines).toContain('── Idea (2) ▶ ──'); + expect(lines).not.toContain('Item C'); + + // Tab again on h2 expands it back. + dataHandler?.(Buffer.from('\t')); + await tick(); + await tick(); + lines = visibleLines().join('\n'); + expect(lines).toContain('── Idea (2) ▼ ──'); + expect(lines).toContain('Item C'); + expect(lines).toContain('Item D'); + + dataHandler?.(Buffer.from('q')); + await p; + }); +}); + +// --------------------------------------------------------------------------- +// AC2: counts match top-level items post stage-filter, before/after collapse +// --------------------------------------------------------------------------- + +describe('AC2: heading counts are stable across collapse', () => { + it('counts reflect post-filter top-level items, unchanged by collapse', async () => { + const items = [ + makeItem('A', 1, 'Group 1'), + makeItem('B', 1, 'Group 1'), + makeItem('C', 2, 'Idea', 'idea'), + makeItem('D', 2, 'Idea'), + ]; + const p = startTui(items); + await tick(); + + // Count = top-level items in the group (2 each), regardless of collapse. + let lines = visibleLines().join('\n'); + expect(lines).toContain('── Group 1 (2) ▼ ──'); + expect(lines).toContain('── Idea (2) ▼ ──'); + + // Collapse Group 1 — the count stays 2 (it is NOT the visible count). + dataHandler?.(Buffer.from('\t')); + await tick(); + await tick(); + lines = visibleLines().join('\n'); + expect(lines).toContain('── Group 1 (2) ▶ ──'); + expect(lines).not.toContain('── Group 1 (0)'); + + // Collapse Idea too — both counts remain. + dataHandler?.(Buffer.from('j')); + await tick(); + dataHandler?.(Buffer.from('\t')); + await tick(); + await tick(); + lines = visibleLines().join('\n'); + expect(lines).toContain('── Group 1 (2) ▶ ──'); + expect(lines).toContain('── Idea (2) ▶ ──'); + + dataHandler?.(Buffer.from('q')); + await p; + }); +}); + +// --------------------------------------------------------------------------- +// AC3: rows - 1 line-count invariant with collapse + metadata panel +// --------------------------------------------------------------------------- + +describe('AC3: rows - 1 invariant under collapse configurations', () => { + it('render never exceeds rows - 1 lines with collapsed groups', async () => { + const items = Array.from({ length: 12 }, (_, i) => + makeItem(`I${i}`, i % 2, i % 2 === 0 ? 'Even' : 'Odd'), + ); + const p = startTui(items); + await tick(); + + // Baseline (all expanded): render fits rows - 1. + let lineCount = visibleLines().length; + expect(lineCount).toBeLessThanOrEqual(23); // 24 rows - 1 + + // Collapse the first group, then the second. + dataHandler?.(Buffer.from('\t')); // collapse Even (h1 selected) + await tick(); + await tick(); + lineCount = visibleLines().length; + expect(lineCount).toBeLessThanOrEqual(23); + + dataHandler?.(Buffer.from('j')); // to h2 (Odd) + await tick(); + await tick(); + dataHandler?.(Buffer.from('\t')); // collapse Odd + await tick(); + await tick(); + lineCount = visibleLines().length; + expect(lineCount).toBeLessThanOrEqual(23); + // Only the two headings remain in the list area. + const lines = visibleLines().join('\n'); + expect(lines).toContain('── Even (6) ▶ ──'); + expect(lines).toContain('── Odd (6) ▶ ──'); + + dataHandler?.(Buffer.from('q')); + await p; + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Extract the reverse-video selected row from the last render. */ +function selectedRowText(): string { + const raw = lastRender(); + // Selected rows are wrapped in ANSI.reverse (7m). The wrapped content may + // itself contain fg color codes, so match non-greedily up to the reset. + const m = raw.match(/\x1b\[7m(.*?)\x1b\[0m/); + return m ? stripAnsi(m[1]) : ''; +} diff --git a/packages/herdr/src/worklist-mouse.test.ts b/packages/herdr/src/worklist-mouse.test.ts new file mode 100644 index 000000000..22f0478e2 --- /dev/null +++ b/packages/herdr/src/worklist-mouse.test.ts @@ -0,0 +1,767 @@ +/** + * Unit tests for mouse/touch input handling in the Herdr worklist selection + * list (parent WL-0MSGHM5BQ0096BNJ, child WL-0MSI720DX002E9WC). + * + * TDD RED phase (C1): the mouse path is NOT implemented yet, so this module + * fails to load with "does not provide an export named 'parseMouseEvent'" / + * "'mapMouseToAction'" (etc.) from src/worklist.ts. This is the expected, + * documented red state — the tests go green when the implementation children + * land: + * - C2 (WL-0MSI7278X0008XGM): ANSI mouse constants, parseMouseEvent + + * split-chunk buffering, lifecycle emission in runWorklistTui. + * - C3 (WL-0MSI72EYW00050N2): row mapping, mapMouseToAction, dispatch + * (double-click window, wheel/touch scroll, filter-tap, drag-motion + * guard). + * + * ── Contract under test (implemented by C2/C3) ────────────────────────── + * + * export interface ParsedMouseEvent { + * button: number; // raw SGR button code (0=left, 1=middle, 2=right, + * // 64=wheel up, 65=wheel down; bit 32 = motion) + * x: number; // 1-based column + * y: number; // 1-based row + * release: boolean; // true for '\x1b[<...m' (release), false for 'M' (press) + * } + * + * export function parseMouseEvent(key: string): ParsedMouseEvent | null; + * // PURE: parses a COMPLETE SGR mouse sequence ('\x1b[ makeItem(String(i))), + TERM_80x24, + ); +} + +/** Left-press event helper. */ +function press(button: number, x: number, y: number): ParsedMouseEvent { + return { button, x, y, release: false }; +} + +// ── AC1: ANSI mouse lifecycle sequences ──────────────────────────────── + +describe('ANSI mouse sequences (WL-0MSGHM5BQ0096BNJ AC1)', () => { + it('defines the exact mouse-tracking enable sequence (1000 + 1002 + 1006)', () => { + expect(ANSI.mouseEnable).toBe('\x1b[?1000h\x1b[?1002h\x1b[?1006h'); + }); + + it('defines the exact mouse-tracking disable sequence (h → l)', () => { + expect(ANSI.mouseDisable).toBe('\x1b[?1000l\x1b[?1002l\x1b[?1006l'); + }); +}); + +// ── AC1/AC6: SGR parser ──────────────────────────────────────────────── + +describe('parseMouseEvent (WL-0MSGHM5BQ0096BNJ AC1/AC6)', () => { + it('parses a left press: \\x1b[<0;10;5M', () => { + expect(parseMouseEvent('\x1b[<0;10;5M')).toEqual({ button: 0, x: 10, y: 5, release: false }); + }); + + it('parses a left release: \\x1b[<0;10;5m', () => { + expect(parseMouseEvent('\x1b[<0;10;5m')).toEqual({ button: 0, x: 10, y: 5, release: true }); + }); + + it('preserves 1-based coordinates and multi-digit values', () => { + expect(parseMouseEvent('\x1b[<0;123;45M')).toEqual({ button: 0, x: 123, y: 45, release: false }); + }); + + it('decodes wheel-up (64) and wheel-down (65) buttons', () => { + expect(parseMouseEvent('\x1b[<64;1;1M')).toEqual({ button: 64, x: 1, y: 1, release: false }); + expect(parseMouseEvent('\x1b[<65;1;1M')).toEqual({ button: 65, x: 1, y: 1, release: false }); + }); + + it('decodes the motion bit (32) as part of the button code', () => { + // 35 = 3 | 32 — a motion event carrying the release-button marker. + expect(parseMouseEvent('\x1b[<35;2;3M')).toEqual({ button: 35, x: 2, y: 3, release: false }); + }); + + it('returns null for plain keyboard input', () => { + expect(parseMouseEvent('j')).toBeNull(); + expect(parseMouseEvent('q')).toBeNull(); + expect(parseMouseEvent('')).toBeNull(); + }); + + it('returns null for escape sequences that are NOT SGR mouse events', () => { + // Arrow keys must fall through to the keyboard path untouched (AC6). + expect(parseMouseEvent('\x1b[A')).toBeNull(); + expect(parseMouseEvent('\x1b[B')).toBeNull(); + expect(parseMouseEvent('\x1b[5~')).toBeNull(); + }); + + it('returns null for malformed SGR sequences', () => { + expect(parseMouseEvent('\x1b[<')).toBeNull(); + expect(parseMouseEvent('\x1b[ { + it('parses a complete sequence in one chunk', () => { + const ev = consumeMouseChunk('\x1b[<0;10;5M'); + expect(ev).toEqual({ button: 0, x: 10, y: 5, release: false }); + }); + + it('buffers a partial SGR prefix until the terminating M arrives', () => { + expect(consumeMouseChunk('\x1b[<0;10;')).toBeNull(); // partial — buffered + expect(consumeMouseChunk('5M')).toEqual({ button: 0, x: 10, y: 5, release: false }); + }); + + it('buffers a release (m) split across chunks', () => { + expect(consumeMouseChunk('\x1b[<64;3;')).toBeNull(); + expect(consumeMouseChunk('2m')).toEqual({ button: 64, x: 3, y: 2, release: true }); + }); + + it('buffers a multi-chunk split (three chunks)', () => { + expect(consumeMouseChunk('\x1b[<')).toBeNull(); + expect(consumeMouseChunk('0;10;')).toBeNull(); + expect(consumeMouseChunk('5M')).toEqual({ button: 0, x: 10, y: 5, release: false }); + }); + + it('clears the buffer on a non-mouse chunk (a stale tail must not complete later)', () => { + expect(consumeMouseChunk('\x1b[<0;10;')).toBeNull(); // partial — buffered + expect(consumeMouseChunk('j')).toBeNull(); // keyboard input clears the buffer + expect(consumeMouseChunk('5M')).toBeNull(); // stale tail does NOT complete + }); + + it('screen-clearing/refresh chunks do not disturb a pending partial', () => { + expect(consumeMouseChunk('\x1b[<0;10;')).toBeNull(); + expect(consumeMouseChunk('\x1b[2J')).toBeNull(); + expect(consumeMouseChunk('5M')).toEqual({ button: 0, x: 10, y: 5, release: false }); + }); +}); + +// ── AC2: row mapping — list mode ─────────────────────────────────────── + +describe('mapMouseToAction — list mode row mapping (AC2)', () => { + it('maps a click on a visible item row to select-row with the flat index (scroll 0)', () => { + const state = makeListState(30); + expect(mapMouseToAction(state, press(0, 5, 2), TERM_80x24)).toEqual({ type: 'select-row', index: 0 }); + expect(mapMouseToAction(state, press(0, 5, 5), TERM_80x24)).toEqual({ type: 'select-row', index: 3 }); + expect(mapMouseToAction(state, press(0, 5, 14), TERM_80x24)).toEqual({ type: 'select-row', index: 12 }); + }); + + it('is inert for the header row (y=1) and panel rows', () => { + const state = makeListState(30); + expect(mapMouseToAction(state, press(0, 5, 1), TERM_80x24)).toBeNull(); + // Dynamic layout (WL-0MSQ44MDX008U69J): with 30 items the list fills the + // available rows (header + 17 items + '▼ more' + footer = rows 1-20) and + // the metadata panel occupies the bottom rows (21-23). + expect(mapMouseToAction(state, press(0, 5, 21), TERM_80x24)).toBeNull(); // metadata panel + expect(mapMouseToAction(state, press(0, 5, 23), TERM_80x24)).toBeNull(); // metadata panel + expect(mapMouseToAction(state, press(0, 5, 24), TERM_80x24)).toBeNull(); // notification row + }); + + it('is inert for the bottom fold indicator and footer rows', () => { + const state = makeListState(30); + // Dynamic layout: rows 2-18 are items, row 19 is '▼ more', row 20 footer. + expect(mapMouseToAction(state, press(0, 5, 19), TERM_80x24)).toBeNull(); // '▼ more' + expect(mapMouseToAction(state, press(0, 5, 20), TERM_80x24)).toBeNull(); // footer + }); + + it('is inert for blank rows beyond the visible tail', () => { + const state = makeListState(3); + expect(mapMouseToAction(state, press(0, 5, 4), TERM_80x24)).toEqual({ type: 'select-row', index: 2 }); + expect(mapMouseToAction(state, press(0, 5, 5), TERM_80x24)).toBeNull(); // blank fill + }); + + it('accounts for scrollOffset and the top fold indicator', () => { + const state = makeListState(30); + state.scrollOffset = 20; + expect(mapMouseToAction(state, press(0, 5, 2), TERM_80x24)).toBeNull(); // '▲ more' + expect(mapMouseToAction(state, press(0, 5, 3), TERM_80x24)).toEqual({ type: 'select-row', index: 20 }); + expect(mapMouseToAction(state, press(0, 5, 12), TERM_80x24)).toEqual({ type: 'select-row', index: 29 }); + expect(mapMouseToAction(state, press(0, 5, 13), TERM_80x24)).toBeNull(); // past the tail + }); + + it('maps clicks on heading rows to their display row index (headings are selectable)', () => { + const items = [makeItem('a', 1), makeItem('b', 1), makeItem('c', 2), makeItem('d', 2)]; + const state = new WorkItemListState(items, TERM_80x24); + // Display rows: [h1, a, b, h2, c, d] — heading rows are selectable per + // WL-0MSL5MPSZ003TG94 AC2, so they map to their display row index. + expect(mapMouseToAction(state, press(0, 5, 2), TERM_80x24)).toEqual({ type: 'select-row', index: 0 }); // h1 + expect(mapMouseToAction(state, press(0, 5, 3), TERM_80x24)).toEqual({ type: 'select-row', index: 1 }); // a + expect(mapMouseToAction(state, press(0, 5, 4), TERM_80x24)).toEqual({ type: 'select-row', index: 2 }); // b + expect(mapMouseToAction(state, press(0, 5, 5), TERM_80x24)).toEqual({ type: 'select-row', index: 3 }); // h2 + expect(mapMouseToAction(state, press(0, 5, 6), TERM_80x24)).toEqual({ type: 'select-row', index: 4 }); // c + expect(mapMouseToAction(state, press(0, 5, 7), TERM_80x24)).toEqual({ type: 'select-row', index: 5 }); // d + }); +}); + +// ── AC2/AC3: row mapping — detail mode ───────────────────────────────── + +describe('mapMouseToAction — detail mode (AC2/AC3)', () => { + it('ignores single clicks in detail mode (no rows to select)', () => { + const state = makeListState(3); + state.mode = 'detail'; + state.detailItem = makeItem('a'); + expect(mapMouseToAction(state, press(0, 5, 5), TERM_80x24)).toBeNull(); + }); + + it('returns back for a double-click in detail mode (AC3: to the list)', () => { + const state = makeListState(3); + state.mode = 'detail'; + state.detailItem = makeItem('a'); + const click: MouseClickState = { + lastClick: { x: 5, y: 3, at: 1000 }, + now: 1300, + }; + expect(mapMouseToAction(state, press(0, 5, 3), TERM_80x24, click)).toEqual({ type: 'back' }); + }); +}); + +// ── AC5: row mapping — filter prompt ─────────────────────────────────── + +describe('mapMouseToAction — filter prompt taps (AC5)', () => { + it('maps a tap on the stage-options row (y=3) to the stage index under the pointer', () => { + const state = makeListState(3); + state.mode = 'filter'; + expect(mapMouseToAction(state, press(0, 3, 3), TERM_80x24)).toEqual({ type: 'filter-stage', index: 0 }); // inside [0] + expect(mapMouseToAction(state, press(0, 13, 3), TERM_80x24)).toEqual({ type: 'filter-stage', index: 1 }); // inside [1] + expect(mapMouseToAction(state, press(0, 34, 3), TERM_80x24)).toEqual({ type: 'filter-stage', index: 2 }); // inside [2] + expect(mapMouseToAction(state, press(0, 53, 3), TERM_80x24)).toEqual({ type: 'filter-stage', index: 3 }); // inside [3] + expect(mapMouseToAction(state, press(0, 70, 3), TERM_80x24)).toEqual({ type: 'filter-stage', index: 4 }); // inside [4] + }); + + it('is inert for taps on any other filter row (AC5)', () => { + const state = makeListState(3); + state.mode = 'filter'; + expect(mapMouseToAction(state, press(0, 5, 1), TERM_80x24)).toBeNull(); + expect(mapMouseToAction(state, press(0, 5, 2), TERM_80x24)).toBeNull(); // 'Filter by stage:' header + expect(mapMouseToAction(state, press(0, 5, 4), TERM_80x24)).toBeNull(); + expect(mapMouseToAction(state, press(0, 5, 5), TERM_80x24)).toBeNull(); // '[0-5] select stage' hint + }); +}); + +// ── AC3: double-click window ─────────────────────────────────────────── + +describe('mapMouseToAction — double-click window (AC3)', () => { + it('opens the detail view for a second click on the same row within 400 ms', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: { x: 5, y: 6, at: 1000 }, now: 1300 }; + expect(mapMouseToAction(state, press(0, 5, 6), TERM_80x24, click)).toEqual({ type: 'open-detail' }); + }); + + it('treats the 400 ms boundary itself as a double-click (inclusive <=)', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: { x: 5, y: 6, at: 1000 }, now: 1400 }; + expect(mapMouseToAction(state, press(0, 5, 6), TERM_80x24, click)).toEqual({ type: 'open-detail' }); + }); + + it('treats a second click past 400 ms as a plain select-row (window elapsed)', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: { x: 5, y: 6, at: 1000 }, now: 1401 }; + expect(mapMouseToAction(state, press(0, 5, 6), TERM_80x24, click)).toEqual({ type: 'select-row', index: 4 }); + }); + + it('never leaks double-click state to a DIFFERENT row (AC3)', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: { x: 5, y: 6, at: 1000 }, now: 1300 }; + expect(mapMouseToAction(state, press(0, 5, 8), TERM_80x24, click)).toEqual({ type: 'select-row', index: 6 }); + }); + + it('treats a first click (no prior click state) as a plain select-row', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: null, now: 1000 }; + expect(mapMouseToAction(state, press(0, 5, 6), TERM_80x24, click)).toEqual({ type: 'select-row', index: 4 }); + }); + + it('does not open the detail view from a repeated click on an inert row', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: { x: 5, y: 1, at: 1000 }, now: 1300 }; + expect(mapMouseToAction(state, press(0, 5, 1), TERM_80x24, click)).toBeNull(); // header rows stay inert + }); +}); + +// ── WL-0MSZBWT500034E74: onData wiring — handleMouseInput ───────────── +// Regression: dispatchMouse updated clickState to the CURRENT event before +// mapMouseToAction ran, so every click self-matched as a double-click and +// opened the detail view instead of selecting. handleMouseInput is the +// exported wiring that onData calls; these tests pin the select-vs-open +// semantics (parent AC2/AC3). + +describe('handleMouseInput — single click selects, double click opens (AC2/AC3)', () => { + it('a single left click on a visible row selects it WITHOUT opening the detail view', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: null, now: 0 }; + const consumed = handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click); + expect(consumed).toBe(true); // mouse sequence fully consumed + expect(state.selectedIndex).toBe(1); // row 3 → index 1 (row 2 → index 0) + expect(state.mode).toBe('list'); // NOT detail — selection only + expect(click.lastClick).toEqual({ x: 5, y: 3, at: expect.any(Number) }); + }); + + it('a second click on the SAME row within the window opens the detail view', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: null, now: 0 }; + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click); // first click — selects + expect(state.mode).toBe('list'); + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click); // second click (~0ms later) + expect(state.mode).toBe('detail'); + expect(state.detailItem?.id).toBe('1'); + }); + + it('a second click on a DIFFERENT row within the window selects that row, never opens detail', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: null, now: 0 }; + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click); // row 3 + handleMouseInput(state, '\x1b[<0;5;8M', TERM_80x24, click); // row 8, same instant + expect(state.mode).toBe('list'); + expect(state.selectedIndex).toBe(6); // row 8 → index 6 + }); + + it('release events do not advance the double-click window', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: null, now: 0 }; + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click); // press 1 + handleMouseInput(state, '\x1b[<0;5;3m', TERM_80x24, click); // release 1 — inert, no state change + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click); // press 2 → still a double-click + expect(state.mode).toBe('detail'); + }); + + it('wheel events navigate but never count as clicks for the window', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: null, now: 0 }; + handleMouseInput(state, '\x1b[<64;5;3M', TERM_80x24, click); // wheel up — wraps to last + expect(state.selectedIndex).toBe(29); // moveUp wraps to last (30 items) + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click); // click row 3 → selects only + expect(state.mode).toBe('list'); // wheel did not prime a double-click + handleMouseInput(state, '\x1b[<0;5;3M', TERM_80x24, click); // same row again → double-click + expect(state.mode).toBe('detail'); // double-click still works after a wheel event + }); + + it('consumes a partial SGR prefix so it never reaches the keyboard path', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: null, now: 0 }; + expect(handleMouseInput(state, '\x1b[<0;5;', TERM_80x24, click)).toBe(true); // buffered partial + expect(handleMouseInput(state, '3M', TERM_80x24, click)).toBe(true); // completes + expect(state.selectedIndex).toBe(1); + expect(state.mode).toBe('list'); + }); + + it('returns false for plain keys and foreign escapes (keyboard path untouched)', () => { + const state = makeListState(30); + const click: MouseClickState = { lastClick: null, now: 0 }; + expect(handleMouseInput(state, 'j', TERM_80x24, click)).toBe(false); + expect(handleMouseInput(state, 'q', TERM_80x24, click)).toBe(false); + expect(handleMouseInput(state, '\x1b[A', TERM_80x24, click)).toBe(false); // arrow key stays keyboard + expect(state.selectedIndex).toBe(0); // untouched + }); +}); + +// ── AC4: wheel / touch-scroll dispatch ───────────────────────────────── + +describe('mapMouseToAction — wheel and touch scroll (AC4)', () => { + it('maps wheel-up (64) and wheel-down (65) to navigation in list mode', () => { + const state = makeListState(30); + expect(mapMouseToAction(state, press(64, 5, 5), TERM_80x24)).toEqual({ type: 'wheel-up' }); + expect(mapMouseToAction(state, press(65, 5, 5), TERM_80x24)).toEqual({ type: 'wheel-down' }); + }); + + it('maps wheel events to detail scrolling in detail mode (j/k-equivalent)', () => { + const state = makeListState(3); + state.mode = 'detail'; + state.detailItem = makeItem('a'); + expect(mapMouseToAction(state, press(64, 5, 5), TERM_80x24)).toEqual({ type: 'scroll-detail-up' }); + expect(mapMouseToAction(state, press(65, 5, 5), TERM_80x24)).toEqual({ type: 'scroll-detail-down' }); + }); + + it('ignores wheel events in filter mode (plan A2)', () => { + const state = makeListState(3); + state.mode = 'filter'; + expect(mapMouseToAction(state, press(64, 5, 3), TERM_80x24)).toBeNull(); + expect(mapMouseToAction(state, press(65, 5, 3), TERM_80x24)).toBeNull(); + }); +}); + +// ── AC6: drag-motion guard + button semantics ────────────────────────── + +describe('mapMouseToAction — drag-motion guard and button codes (AC6)', () => { + it('never jumps the selection on motion events (button & 32)', () => { + const state = makeListState(30); + // 35 = 3 | 32 (motion carrying release marker), 33 = 1 | 32, 32 alone. + expect(mapMouseToAction(state, press(32, 5, 5), TERM_80x24)).toBeNull(); + expect(mapMouseToAction(state, press(33, 5, 5), TERM_80x24)).toBeNull(); + expect(mapMouseToAction(state, press(35, 5, 5), TERM_80x24)).toBeNull(); + }); + + it('guards motion in detail and filter modes too', () => { + const d = makeListState(3); + d.mode = 'detail'; + d.detailItem = makeItem('a'); + expect(mapMouseToAction(d, press(32, 5, 5), TERM_80x24)).toBeNull(); + const f = makeListState(3); + f.mode = 'filter'; + expect(mapMouseToAction(f, press(34, 5, 3), TERM_80x24)).toBeNull(); + }); + + it('ignores release events for click actions (press drives selection)', () => { + const state = makeListState(30); + expect(mapMouseToAction(state, { button: 0, x: 5, y: 5, release: true }, TERM_80x24)).toBeNull(); + }); + + it('ignores middle (1) and right (2) buttons — only left selects', () => { + const state = makeListState(30); + expect(mapMouseToAction(state, press(1, 5, 5), TERM_80x24)).toBeNull(); + expect(mapMouseToAction(state, press(2, 5, 5), TERM_80x24)).toBeNull(); + }); +}); + +// ── AC6: unknown-sequence regression ─────────────────────────────────── + +describe('unknown-sequence regression (AC6)', () => { + it('leaves the keyboard path untouched: handleKeypress treats mouse chunks as inert', () => { + const state = makeListState(30); + const before = { selectedIndex: state.selectedIndex, scrollOffset: state.scrollOffset }; + const action = handleKeypress(state, '\x1b[<0;10;5M', TERM_80x24); + expect(action).toBeNull(); + expect(state.selectedIndex).toBe(before.selectedIndex); + expect(state.scrollOffset).toBe(before.scrollOffset); + }); + + it('does not confuse mouse chunks with arrow-key escape sequences', () => { + expect(parseMouseEvent('\x1b[A')).toBeNull(); // arrows stay keyboard + expect(keyToAction('\x1b[<0;10;5M')).toBeNull(); // mouse chunk is not an arrow action + }); +}); + +// ── AC1: lifecycle emission ──────────────────────────────────────────── + +describe('runWorklistTui — mouse lifecycle emission (AC1)', () => { + it('emits enable sequences on raw-mode entry and disable sequences on cleanup', async () => { + // Fake TTY stdin + captured stdout. + type StdinListener = (chunk: Buffer) => void; + const dataListeners: StdinListener[] = []; + const fakeStdin = { + isTTY: true, + setRawMode: vi.fn(), + resume: vi.fn(), + pause: vi.fn(), + on: (ev: string, cb: StdinListener) => { + if (ev === 'data') dataListeners.push(cb); + }, + removeListener: (ev: string, cb: StdinListener) => { + if (ev === 'data') { + const i = dataListeners.indexOf(cb); + if (i >= 0) dataListeners.splice(i, 1); + } + }, + }; + const writes: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any) => { + writes.push(String(chunk)); + return true; + }) as any); + const origStdin = (process as any).stdin; + // Node 22+ defines process.stdin as a getter-only property — use + // Object.defineProperty to swap in the fake TTY stdin. + Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true, writable: true }); + + try { + // Stub wl subprocesses (fetchActionableCount etc.) — the mouse test + // suite never spawns a real wl process. + setExecFileAsync((async () => ({ stdout: '{"count": 0}' })) as any); + + const tui = runWorklistTui( + async () => [], + [], + undefined, + { autoRefresh: false, autoSync: false }, + ); + + // Let the initial render complete, then assert the enable sequence + // was emitted at raw-mode entry (before any input). + await vi.waitFor(() => { + expect(writes.join('')).toContain(ANSI.mouseEnable); + }); + + // Quit via 'q' → cleanup() must emit the disable sequences. + dataListeners.forEach((cb) => cb(Buffer.from('q'))); + await tui; + + expect(writes.join('')).toContain(ANSI.mouseDisable); + // Enable must precede disable in the output stream. + expect(writes.join('').indexOf(ANSI.mouseEnable)).toBeLessThan( + writes.join('').indexOf(ANSI.mouseDisable), + ); + } finally { + Object.defineProperty(process, 'stdin', { value: origStdin, configurable: true, writable: true }); + writeSpy.mockRestore(); + resetExecFileAsync(); + } + }); +}); + +// ── WL-0MT0AP2LR000JFWN: Alt+m toggle for drag-select ───────────────── +// Regression: mouse tracking (enabled on raw-mode entry) captures ALL mouse +// events, so the terminal's native text selection (drag-select to copy) +// stops working. Alt+m toggles tracking off (native selection works again) +// and back on (mouse interaction resumes). + +describe('runWorklistTui — Alt+m mouse-tracking toggle (WL-0MT0AP2LR000JFWN)', () => { + // Shared fake-TTY harness: swaps process.stdin for a fake TTY that + // records data listeners, and captures process.stdout writes. + function setupFakeTty(): { + dataListeners: Array<(chunk: Buffer) => void>; + writes: string[]; + restore: () => void; + } { + type StdinListener = (chunk: Buffer) => void; + const dataListeners: StdinListener[] = []; + const fakeStdin = { + isTTY: true, + setRawMode: vi.fn(), + resume: vi.fn(), + pause: vi.fn(), + on: (ev: string, cb: StdinListener) => { + if (ev === 'data') dataListeners.push(cb); + }, + removeListener: (ev: string, cb: StdinListener) => { + if (ev === 'data') { + const i = dataListeners.indexOf(cb); + if (i >= 0) dataListeners.splice(i, 1); + } + }, + }; + const writes: string[] = []; + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any) => { + writes.push(String(chunk)); + return true; + }) as any); + const origStdin = (process as any).stdin; + Object.defineProperty(process, 'stdin', { value: fakeStdin, configurable: true, writable: true }); + setExecFileAsync((async () => ({ stdout: '{"count": 0}' })) as any); + return { + dataListeners, + writes, + restore: () => { + Object.defineProperty(process, 'stdin', { value: origStdin, configurable: true, writable: true }); + writeSpy.mockRestore(); + resetExecFileAsync(); + }, + }; + } + + it('Alt+m toggles tracking OFF (disable sequences emitted) then ON again', async () => { + const { dataListeners, writes, restore } = setupFakeTty(); + try { + const tui = runWorklistTui( + async () => [], + [], + undefined, + { autoRefresh: false, autoSync: false }, + ); + + // Raw-mode entry → mouse tracking enabled by default. + await vi.waitFor(() => { + expect(writes.join('')).toContain(ANSI.mouseEnable); + }); + const firstEnable = writes.join('').indexOf(ANSI.mouseEnable); + + // Alt+m (\x1bm) → toggle OFF: disable sequences emitted. + dataListeners.forEach((cb) => cb(Buffer.from('\x1bm'))); + await vi.waitFor(() => { + expect(writes.join('').indexOf(ANSI.mouseDisable)).toBeGreaterThan(firstEnable); + }); + const disableAt = writes.join('').indexOf(ANSI.mouseDisable); + + // Alt+m again → toggle back ON: a SECOND enable sequence is emitted. + dataListeners.forEach((cb) => cb(Buffer.from('\x1bm'))); + await vi.waitFor(() => { + const enableCount = writes.join('').split(ANSI.mouseEnable).length - 1; + expect(enableCount).toBe(2); + expect(writes.join('').lastIndexOf(ANSI.mouseEnable)).toBeGreaterThan(disableAt); + }); + + // Quit via 'q' → cleanup still emits the disable sequences. + dataListeners.forEach((cb) => cb(Buffer.from('q'))); + await tui; + expect(writes.join('')).toContain(ANSI.mouseDisable); + } finally { + restore(); + } + }); + + it('Alt+m does not reach keyToAction (meta-down m is unaffected)', async () => { + const { dataListeners, writes, restore } = setupFakeTty(); + try { + const tui = runWorklistTui( + async () => [], + [], + undefined, + { autoRefresh: false, autoSync: false }, + ); + await vi.waitFor(() => { + expect(writes.join('')).toContain(ANSI.mouseEnable); + }); + + // Plain 'm' (meta-down) must NOT trigger the toggle — only \x1bm does. + const before = writes.join(''); + dataListeners.forEach((cb) => cb(Buffer.from('m'))); + // Allow the render to settle, then confirm no disable sequence was + // emitted (plain m is a navigation key, not a toggle). + await new Promise((r) => setTimeout(r, 50)); + expect(writes.join('').split(ANSI.mouseDisable).length - 1).toBe(0); + void before; + + // Alt+m toggles OFF (disable emitted), proving the \x1bm prefix is + // what triggers the toggle — distinct from the bare 'm' key. + dataListeners.forEach((cb) => cb(Buffer.from('\x1bm'))); + await vi.waitFor(() => { + expect(writes.join('').split(ANSI.mouseDisable).length - 1).toBeGreaterThan(0); + }); + + dataListeners.forEach((cb) => cb(Buffer.from('q'))); + await tui; + } finally { + restore(); + } + }); + + it('footer shows the current mouse-tracking state (on/off)', async () => { + const { dataListeners, writes, restore } = setupFakeTty(); + try { + const tui = runWorklistTui( + async () => [], + [], + undefined, + { autoRefresh: false, autoSync: false }, + ); + + // Initial render: hint says mouse on. + await vi.waitFor(() => { + expect(writes.join('')).toContain('alt+m mouse on'); + }); + + // Alt+m → hint flips to mouse off. + dataListeners.forEach((cb) => cb(Buffer.from('\x1bm'))); + await vi.waitFor(() => { + expect(writes.join('')).toContain('alt+m mouse off'); + }); + + // Alt+m again → hint flips back to mouse on. + dataListeners.forEach((cb) => cb(Buffer.from('\x1bm'))); + await vi.waitFor(() => { + expect(writes.join('')).toContain('alt+m mouse on'); + }); + + dataListeners.forEach((cb) => cb(Buffer.from('q'))); + await tui; + } finally { + restore(); + } + }); +}); \ No newline at end of file diff --git a/packages/herdr/src/worklist-navigation-rows.test.ts b/packages/herdr/src/worklist-navigation-rows.test.ts new file mode 100644 index 000000000..8affd3a41 --- /dev/null +++ b/packages/herdr/src/worklist-navigation-rows.test.ts @@ -0,0 +1,302 @@ +/** + * packages/herdr/src/worklist-navigation-rows.test.ts — Navigation over + * display rows (T2 for WL-0MSL5MPSZ003TG94). + * + * Tests for: + * - moveUp/moveDown land on heading rows + * - pageUp/pageDown, goToFirst/goToLast work over display rows + * - Wrap-around works correctly with headings + * - flatCount = display row count + * - _clampSelection and _adjustScroll use display row count + * - getSelectedItem() returns null for heading selection + * + * Run: npx vitest run packages/herdr/src/worklist-navigation-rows.test.ts + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + WorkItemListState, + type DisplayRow, +} from './worklist.js'; +import type { WorkItem } from './fetcher.js'; + +// ── Helpers ────────────────────────────────────────────────────────────── + +const TERM_80x24 = { rows: 24, cols: 80 }; + +function makeItem( + id: string, + group?: number, + groupLabel?: string, + stage?: string, + children?: WorkItem[], +): WorkItem { + return { id, title: `Item ${id}`, status: 'open', stage, group, groupLabel, children }; +} + +function makeChild(id: string): WorkItem { + return { id, title: `Child ${id}`, status: 'open' }; +} + +/** Build a grouped list matching regroupWorkItems output. */ +function makeGroupedList(): WorkItem[] { + return [ + makeItem('A', 1, 'Group 1'), + makeItem('B', 1, 'Group 1'), + makeItem('C', 1, 'Group 1'), + makeItem('D', 2, 'Group 2'), + makeItem('E', 2, 'Group 2'), + makeItem('F', 3, 'Idea'), + ]; +} + +// ── flatCount ──────────────────────────────────────────────────────────── + +describe('flatCount returns display row count', () => { + it('counts headings + items', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // 3 headings + 6 items = 9 + expect(state.flatCount).toBe(9); + expect(state.flatCount).toBe(state.getDisplayRows().length); + }); + + it('counts fewer rows when groups are collapsed', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.toggleGroupCollapse(1); + // heading(1) + heading(2) + D, E + heading(3) + F = 6 + expect(state.flatCount).toBe(6); + }); + + it('counts headings + expanded children', () => { + const parent = makeItem('P', 1, 'Group 1', 'plan_complete', [makeChild('C1'), makeChild('C2')]); + parent.childCount = 2; + const items = [parent, makeItem('A', 1, 'Group 1'), makeItem('B', 2, 'Group 2')]; + const state = new WorkItemListState(items, TERM_80x24); + state.toggleExpand('P'); + // heading(1) + P + C1 + C2 + A + heading(2) + B = 7 + expect(state.flatCount).toBe(7); + }); +}); + +// ── moveUp / moveDown ─────────────────────────────────────────────────── + +describe('moveUp / moveDown over display rows', () => { + it('moveDown advances from heading to first item in group', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + expect(state.selectedIndex).toBe(0); // heading(1) + state.moveDown(); + expect(state.selectedIndex).toBe(1); // A + expect(state.getSelectedItem()?.id).toBe('A'); + }); + + it('moveDown lands on heading rows', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // Start at heading(1) → A → B → C → heading(2) + state.moveDown(); // A (1) + state.moveDown(); // B (2) + state.moveDown(); // C (3) + state.moveDown(); // heading(2) (4) + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 2 }); + }); + + it('moveUp goes from item back to heading', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.moveDown(); // A + state.moveDown(); // B + state.moveDown(); // C + state.moveDown(); // heading(2) + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 2 }); + state.moveUp(); // C + expect((state.getSelectedItem() as WorkItem)?.id).toBe('C'); + }); + + it('moveDown wrap-around goes from last row to first (heading)', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // Last display row is F (index 8) + for (let i = 0; i < 8; i++) state.moveDown(); + expect(state.selectedIndex).toBe(8); + expect(state.getSelectedItem()?.id).toBe('F'); + state.moveDown(); // should wrap to 0 (heading) + expect(state.selectedIndex).toBe(0); + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 1 }); + }); + + it('moveUp wrap-around goes from first (heading) to last row', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.moveUp(); // should wrap to last (F) + expect(state.selectedIndex).toBe(8); + expect(state.getSelectedItem()?.id).toBe('F'); + }); +}); + +// ── pageUp / pageDown ──────────────────────────────────────────────────── + +describe('pageUp / pageDown over display rows', () => { + it('pageDown advances by list height over display rows', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + const pageSize = state._listHeight(); + state.pageDown(); + // flatCount (9) < pageSize (17), so pageDown clamps to the last row. + expect(state.selectedIndex).toBe(state.flatCount - 1); + expect(state.getSelectedDisplayRow()).toBeDefined(); + }); + + it('pageDown clamps to display row count', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.pageDown(); + expect(state.selectedIndex).toBeLessThan(state.flatCount); + expect(state.selectedIndex).toBeGreaterThanOrEqual(0); + }); + + it('pageUp goes to top when already near top', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.moveDown(); // index 1 + state.pageUp(); // should go to 0 + expect(state.selectedIndex).toBe(0); + expect((state.getSelectedDisplayRow() as NonNullable & { kind: 'heading' }).kind).toBe('heading'); + }); +}); + +// ── goToFirst / goToLast ───────────────────────────────────────────────── + +describe('goToFirst / goToLast over display rows', () => { + it('goToFirst selects heading(1)', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = 8; + state.scrollOffset = 5; + state.goToFirst(); + expect(state.selectedIndex).toBe(0); + expect(state.scrollOffset).toBe(0); + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 1 }); + }); + + it('goToLast selects last display row', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.goToLast(); + expect(state.selectedIndex).toBe(8); // last row + expect(state.getSelectedItem()?.id).toBe('F'); + }); +}); + +// ── getSelectedItem returns null for heading ────────────────────────────── + +describe('getSelectedItem() heading handling', () => { + it('returns null when a heading row is selected', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // selectedIndex = 0, which is heading(1) + expect(state.getSelectedItem()).toBeNull(); + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading' }); + }); + + it('returns the item when an item row is selected', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.moveDown(); // A (index 1) + expect(state.getSelectedItem()?.id).toBe('A'); + }); + + it('returns null for any heading row, not just the first', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // Move to heading(2) (index 4) + for (let i = 0; i < 4; i++) state.moveDown(); + expect(state.getSelectedItem()).toBeNull(); + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 2 }); + }); + + it('returns null for heading in collapsed group too', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.toggleGroupCollapse(1); + // After collapse, heading(1) is index 0 + expect(state.getSelectedItem()).toBeNull(); + expect(state.getSelectedDisplayRow()).toMatchObject({ kind: 'heading', group: 1, collapsed: true }); + }); +}); + +// ── getSelectedDisplayRow ──────────────────────────────────────────────── + +describe('getSelectedDisplayRow()', () => { + it('returns the row at selectedIndex', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // Headings are derived per call, so compare deeply; items are the same + // object references from the flattened tree. + expect(state.getSelectedDisplayRow()).toEqual(state.getDisplayRows()[0]); + state.moveDown(); + expect(state.getSelectedDisplayRow()).toBe(state.getDisplayRows()[1]); + expect(state.getSelectedDisplayRow()?.id).toBe('A'); + }); + + it('returns null when selection is out of bounds', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = 999; + expect(state.getSelectedDisplayRow()).toBeNull(); + }); +}); + +// ── clamping with display rows ────────────────────────────────────────── + +describe('_clampSelection with display rows', () => { + it('clamps to display row count', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = state.flatCount + 10; + state._clampSelection(); + expect(state.selectedIndex).toBe(state.flatCount - 1); + }); + + it('clamps negative to 0', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = -5; + state._clampSelection(); + expect(state.selectedIndex).toBe(0); + }); + + it('clamps to 0 when list is empty', () => { + const state = new WorkItemListState([], TERM_80x24); + state.selectedIndex = 5; + state._clampSelection(); + expect(state.selectedIndex).toBe(0); + }); +}); + +// ── _adjustScroll with display rows ────────────────────────────────────── + +describe('_adjustScroll with display rows', () => { + it('clamps scroll offset to display row count', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.selectedIndex = state.flatCount - 1; + state.scrollOffset = 999; + state._adjustScroll(); + expect(state.scrollOffset).toBeLessThanOrEqual(Math.max(0, state.flatCount - state._listHeight())); + }); + + it('keeps selected index visible within scroll window', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + // Move selection far down, scroll should follow + state.selectedIndex = state.flatCount - 1; + state._adjustScroll(); + expect(state.selectedIndex).toBeGreaterThanOrEqual(state.scrollOffset); + expect(state.selectedIndex).toBeLessThan(state.scrollOffset + state._listHeight()); + }); +}); + +// ── collapsed groups and navigation ────────────────────────────────────── + +describe('collapsed groups in navigation', () => { + it('collapsing a group shrinks the navigation space', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + const before = state.flatCount; + state.toggleGroupCollapse(1); + expect(state.flatCount).toBeLessThan(before); + // Navigation now covers the reduced display rows + state.goToLast(); + expect(state.selectedIndex).toBe(state.flatCount - 1); + expect(state.getSelectedItem()?.id).toBe('F'); + }); + + it('a selection pointing at a hidden item clamps to valid display row', () => { + const state = new WorkItemListState(makeGroupedList(), TERM_80x24); + state.moveDown(); // A (index 1) + state.moveDown(); // B (index 2) + // Collapse group 1 → rows shrink from 9 to 6; selection 2 is still valid + state.toggleGroupCollapse(1); + expect(state.selectedIndex).toBeLessThan(state.flatCount); + }); +}); diff --git a/packages/herdr/src/worklist-nested-expand.test.ts b/packages/herdr/src/worklist-nested-expand.test.ts index a3d536c5b..c2f61dcd4 100644 --- a/packages/herdr/src/worklist-nested-expand.test.ts +++ b/packages/herdr/src/worklist-nested-expand.test.ts @@ -190,10 +190,15 @@ describe('nested expansion (WL-0MSQ3FH1K000MMJW)', () => { expect(writes.join('')).toContain('FEATURE'); expect(parentFetches).toEqual([{ parentId: 'P' }]); - // Select FEATURE (flattened index 1) with ↓. + // Select FEATURE with ↓: the first ↓ lands on the group heading row + // (FEATURE carries an "Other" group stamp from regroupWorkItems while P + // has none, so a heading row is interleaved — selectable per + // WL-0MSL5MPSZ003TG94 AC2), the second ↓ lands on FEATURE. writes.length = 0; dataHandler?.(Buffer.from('j')); await vi.advanceTimersByTimeAsync(0); + dataHandler?.(Buffer.from('j')); + await vi.advanceTimersByTimeAsync(0); // Tab on FEATURE: fetch grandchildren (TASK-1, TASK-2) on demand. dataHandler?.(Buffer.from('\t')); @@ -221,11 +226,15 @@ describe('nested expansion (WL-0MSQ3FH1K000MMJW)', () => { }); await vi.advanceTimersByTimeAsync(0); - // Expand P, move to FEATURE, expand FEATURE. + // Expand P, move to FEATURE (↓ lands on the group heading first — + // headings are selectable rows per WL-0MSL5MPSZ003TG94 AC2), expand + // FEATURE. dataHandler?.(Buffer.from('\t')); await vi.advanceTimersByTimeAsync(0); dataHandler?.(Buffer.from('j')); await vi.advanceTimersByTimeAsync(0); + dataHandler?.(Buffer.from('j')); + await vi.advanceTimersByTimeAsync(0); dataHandler?.(Buffer.from('\t')); await vi.advanceTimersByTimeAsync(0); expect(writes.join('')).toContain('TASK-1'); @@ -263,11 +272,15 @@ describe('nested expansion (WL-0MSQ3FH1K000MMJW)', () => { await vi.advanceTimersByTimeAsync(0); fetcher.mockClear(); - // Expand P → FEATURE, then FEATURE → TASK-1/TASK-2. + // Expand P → FEATURE, then FEATURE → TASK-1/TASK-2. The ↓ keys pass + // through the interleaved heading row (selectable per + // WL-0MSL5MPSZ003TG94 AC2). dataHandler?.(Buffer.from('\t')); await vi.advanceTimersByTimeAsync(0); dataHandler?.(Buffer.from('j')); await vi.advanceTimersByTimeAsync(0); + dataHandler?.(Buffer.from('j')); + await vi.advanceTimersByTimeAsync(0); dataHandler?.(Buffer.from('\t')); await vi.advanceTimersByTimeAsync(0); expect(writes.join('')).toContain('TASK-1'); @@ -300,12 +313,15 @@ describe('nested expansion (WL-0MSQ3FH1K000MMJW)', () => { }); await vi.advanceTimersByTimeAsync(0); - // Expand P with Tab (fetches FEATURE), select FEATURE, then Tab-expand - // FEATURE so its grandchildren are loaded. + // Expand P with Tab (fetches FEATURE), select FEATURE (↓ lands on the + // interleaved heading row first — selectable per WL-0MSL5MPSZ003TG94 + // AC2), then Tab-expand FEATURE so its grandchildren are loaded. dataHandler?.(Buffer.from('\t')); await vi.advanceTimersByTimeAsync(0); dataHandler?.(Buffer.from('j')); await vi.advanceTimersByTimeAsync(0); + dataHandler?.(Buffer.from('j')); + await vi.advanceTimersByTimeAsync(0); dataHandler?.(Buffer.from('\t')); await vi.advanceTimersByTimeAsync(0); expect(writes.join('')).toContain('TASK-1'); diff --git a/packages/herdr/src/worklist-renderer-headings.test.ts b/packages/herdr/src/worklist-renderer-headings.test.ts new file mode 100644 index 000000000..2a0c3be0a --- /dev/null +++ b/packages/herdr/src/worklist-renderer-headings.test.ts @@ -0,0 +1,176 @@ +/** + * packages/herdr/src/worklist-renderer-headings.test.ts — Renderer heading + * rows with counts (T4 for WL-0MSL5MPSZ003TG94). + * + * Tests for: + * - Heading rows render label + item count (e.g. `── Group 1 (4) ──`) + * - Collapse arrow/indicator on heading rows (expanded vs collapsed) + * - createListRenderer renders headings from the display model (no group + * transition derivation) + * - Collapsed groups' items do not render; headings still render with count + * - The `rows - 1` line-count invariant holds under heading configurations + * - Click-row mapping accounts for heading rows + * + * Run: npx vitest run packages/herdr/src/worklist-renderer-headings.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + createListRenderer, + mapMouseToAction, + WorkItemListState, + type DisplayHeadingRow, + type ParsedMouseEvent, +} from './worklist.js'; +import type { WorkItem } from './fetcher.js'; + +// ── Helpers ────────────────────────────────────────────────────────────── + +const TERM_80x24 = { rows: 24, cols: 80 }; +const ANSI_REVERSE = '\x1b[7m'; + +function makeHeading(group: number, groupLabel: string, count: number, collapsed = false): DisplayHeadingRow { + return { kind: 'heading', group, groupLabel, count, collapsed }; +} + +function makeItem(id: string, group?: number): WorkItem { + return { id, title: `Item ${id}`, status: 'open', group }; +} + +function press(x: number, y: number): ParsedMouseEvent { + // Left-button press at (x, y) + return { button: 0, x, y, release: false }; +} + +// ── Heading rendering with counts ──────────────────────────────────────── + +describe('heading rows render label + count', () => { + const renderer = createListRenderer(); + + it('renders each heading with its item count', () => { + const rows = [ + makeHeading(1, 'Group 1', 3), + makeItem('A', 1), + makeItem('B', 1), + makeItem('C', 1), + makeHeading(2, 'Idea', 1), + makeItem('D', 2), + ]; + const output = renderer(rows, 0, 0, TERM_80x24, null, 'list', null); + expect(output).toContain('Group 1 (3)'); + expect(output).toContain('Idea (1)'); + }); + + it('shows the count whether expanded or collapsed', () => { + const rows = [ + makeHeading(1, 'Group 1', 3, false), + makeItem('A', 1), + makeHeading(2, 'Group 2', 2, true), + ]; + const output = renderer(rows, 0, 0, TERM_80x24, null, 'list', null); + expect(output).toContain('Group 1 (3)'); + expect(output).toContain('Group 2 (2)'); + }); + + it('renders a collapse arrow distinct from item rows', () => { + const rows = [ + makeHeading(1, 'Group 1', 3, false), // expanded + makeItem('A', 1), + makeHeading(2, 'Group 2', 2, true), // collapsed + ]; + const output = renderer(rows, 0, 0, TERM_80x24, null, 'list', null); + // Expanded heading shows ▼; collapsed shows ▶ + expect(output).toContain('▼'); + expect(output).toContain('▶'); + }); + + it('does not render items of collapsed groups (they are excluded from rows)', () => { + // The caller passes display rows where collapsed-group items are already + // excluded — the renderer renders exactly the rows it is given. + const rows = [ + makeHeading(1, 'Group 1', 2, true), + makeHeading(2, 'Idea', 1, false), + makeItem('D', 2), + ]; + const output = renderer(rows, 0, 0, TERM_80x24, null, 'list', null); + expect(output).toContain('Group 1 (2)'); + expect(output).not.toContain('Item A'); + expect(output).toContain('Item D'); + }); + + it('highlights a selected heading row', () => { + const rows = [ + makeHeading(1, 'Group 1', 3), + makeItem('A', 1), + ]; + const output = renderer(rows, 0, 0, TERM_80x24, null, 'list', null); + // Selected heading (index 0) rendered with reverse video + expect(output).toContain(ANSI_REVERSE); + }); +}); + +// ── Line-count invariant with headings ─────────────────────────────────── + +describe('line-count invariant with heading rows', () => { + const renderer = createListRenderer(); + + it('never exceeds rows - 1 with many groups', () => { + // 6 groups × (heading + 4 items) = 30 rows + const rows: (DisplayHeadingRow | WorkItem)[] = []; + for (let g = 1; g <= 6; g++) { + rows.push(makeHeading(g, `Group ${g}`, 4)); + for (let i = 0; i < 4; i++) { + rows.push(makeItem(`G${g}-I${i}`, g)); + } + } + const output = renderer(rows, 0, 0, TERM_80x24, null, 'list', null); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + expect(output).toContain('Work Items'); + }); + + it('never exceeds rows - 1 with many collapsed headings', () => { + const rows: (DisplayHeadingRow | WorkItem)[] = []; + for (let g = 1; g <= 12; g++) { + rows.push(makeHeading(g, `Group ${g}`, 3, true)); + } + const output = renderer(rows, 0, 0, TERM_80x24, null, 'list', null); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + }); + + it('never exceeds rows - 1 with code-freeze banner and headings', () => { + const rows: (DisplayHeadingRow | WorkItem)[] = []; + for (let g = 1; g <= 8; g++) { + rows.push(makeHeading(g, `Group ${g}`, 5)); + for (let i = 0; i < 5; i++) { + rows.push(makeItem(`G${g}-I${i}`, g)); + } + } + const output = renderer(rows, 0, 0, TERM_80x24, null, 'list', null, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + expect(output).toContain('CODE FREEZE'); + }); +}); + +// ── Click-row mapping with headings ────────────────────────────────────── + +describe('click-row mapping accounts for heading rows', () => { + it('maps clicks on heading rows to their display row index', () => { + const items = [makeItem('a', 1), makeItem('b', 1), makeItem('c', 2), makeItem('d', 2)]; + const state = new WorkItemListState(items, TERM_80x24); + // display rows: [h1, a, b, h2, c, d] + // rows on screen: y2=h1(0), y3=a(1), y4=b(2), y5=h2(3), y6=c(4), y7=d(5) + expect(mapMouseToAction(state, press(5, 2), TERM_80x24)).toEqual({ type: 'select-row', index: 0 }); + expect(mapMouseToAction(state, press(5, 3), TERM_80x24)).toEqual({ type: 'select-row', index: 1 }); + expect(mapMouseToAction(state, press(5, 5), TERM_80x24)).toEqual({ type: 'select-row', index: 3 }); + expect(mapMouseToAction(state, press(5, 7), TERM_80x24)).toEqual({ type: 'select-row', index: 5 }); + }); + + it('maps clicks on items of collapsed groups correctly', () => { + const items = [makeItem('a', 1), makeItem('b', 1), makeItem('c', 2), makeItem('d', 2)]; + const state = new WorkItemListState(items, TERM_80x24); + state.toggleGroupCollapse(1); + // display rows: [h1, h2, c, d] → y2=h1(0), y3=h2(1), y4=c(2), y5=d(3) + expect(mapMouseToAction(state, press(5, 3), TERM_80x24)).toEqual({ type: 'select-row', index: 1 }); + expect(mapMouseToAction(state, press(5, 4), TERM_80x24)).toEqual({ type: 'select-row', index: 2 }); + }); +}); diff --git a/packages/herdr/src/worklist-visibility.test.ts b/packages/herdr/src/worklist-visibility.test.ts index 76208e4c2..84d917096 100644 --- a/packages/herdr/src/worklist-visibility.test.ts +++ b/packages/herdr/src/worklist-visibility.test.ts @@ -560,3 +560,110 @@ describe('worklist TUI visibility gating — hidden → visible transition (WL-0 // Silence unused-var lint for showToast in the manual-sync assertion import. void showToast; + +// ── Event-driven focus/visibility path (WL-0MSHB7DHO004RHBJ F3) ─────── +// A pane_focused event for the CURRENT pane updates the visibility gate +// directly (no `herdr tab get` exec) and triggers an immediate refresh on +// the hidden → visible transition — replacing the 2s resume-poll. + +describe('worklist — event-driven focus/visibility (WL-0MSHB7DHO004RHBJ)', () => { + it('a pane_focused event for the current pane triggers an immediate refresh with no tab-get exec', async () => { + const { createMockSocket } = await import('./test-utils/mock-herdr-socket.js'); + const { HerdrEventSubscriber } = await import('./events.js'); + const mockServer = await createMockSocket(); + const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock(true) as any); + + const fetcher = vi.fn().mockResolvedValue([]); + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + }); + await new Promise((r) => setTimeout(r, 100)); + const client = mockServer.getFirstClient(); + expect(client).toBeDefined(); + fetcher.mockClear(); + + // A pane_focused event for the current pane triggers doRefresh(true). + client!.pushEvent({ event: 'pane_focused', data: { pane_id: 'w1:pCM', focused: true } }); + await new Promise((r) => setTimeout(r, 100)); + expect(fetcher).toHaveBeenCalled(); + + await subscriber.close(); + await mockServer.stop(); + await quit(p); + }); + + it('a pane_focused(unfocused) event for the current pane sets the paused indicator without a tab-get exec', async () => { + const { createMockSocket } = await import('./test-utils/mock-herdr-socket.js'); + const { HerdrEventSubscriber } = await import('./events.js'); + const mockServer = await createMockSocket(); + const subscriber = new HerdrEventSubscriber({ socketPath: mockServer.getAddress(), callbacks: {} }); + process.env.HERDR_PANE_ID = 'w1:pCM'; + const tabGetCalls = { count: 0 }; + setExecFileAsync(makeExecMock(true, tabGetCalls) as any); + + const fetcher = vi.fn().mockResolvedValue([]); + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + }); + await new Promise((r) => setTimeout(r, 100)); + const client = mockServer.getFirstClient(); + + // Unfocus event → pane marked hidden immediately without a tab-get exec. + const before = tabGetCalls.count; + client!.pushEvent({ event: 'pane_focused', data: { pane_id: 'w1:pCM', focused: false } }); + await new Promise((r) => setTimeout(r, 100)); + writes.length = 0; + dataHandler?.(Buffer.from('j')); // navigation re-renders the header + await new Promise((r) => setTimeout(r, 20)); + expect(writes.join('')).toContain('[paused — hidden]'); + // No tab-get exec happened for this transition. + expect(tabGetCalls.count).toBe(before); + + await subscriber.close(); + await mockServer.stop(); + await quit(p); + }); + + it('fail-open: unreachable socket keeps today\'s polling cadence (no crash, fetcher ticks as before)', async () => { + const { HerdrEventSubscriber } = await import('./events.js'); + const subscriber = new HerdrEventSubscriber({ + socketPath: '/tmp/herdr-unreachable-test-socket-never-exists.sock', + callbacks: {}, + maxReconnectAttempts: 0, + }); + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + process.env.HERDR_TAB_ID = 'w1:t11'; + setExecFileAsync(makeExecMock(true) as any); + + const fetcher = vi.fn().mockResolvedValue([]); + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + subscriber, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + // The events path is unavailable → the polling cadence is preserved. + await vi.advanceTimersByTimeAsync(30_000); + expect(fetcher).toHaveBeenCalledTimes(1); + + // The resume-poll fallback is active while events are unavailable: a + // hidden → visible transition is still caught by the 2s resume-poll. + await subscriber.close(); + await quit(p); + }); +}); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 0ed34fb98..ef3ed8e0a 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'; import { WorkItemListState, getTermSize, @@ -24,6 +24,7 @@ import { getChordHelpHints, handleKeypress, computeMetadataPanelHeight, + computeDynamicLayout, formatMetadataPanel, formatTimestamp, buildMetaRows, @@ -36,6 +37,7 @@ import { } from './worklist.js'; import type { ChordState } from './worklist.js'; import type { DowntimeWorker } from './downtime-worker.js'; +import { createDowntimeWorker, createDowntimePoller } from './downtime-worker.js'; import { setLogPath, resetLogPath, recordCommand, getLastCommand } from './command-log.js'; import { loadShortcutConfig, ShortcutRegistry, type ShortcutEntry } from './shortcut-config.js'; import { regroupWorkItems, extractFilePaths } from './grouping.js'; @@ -133,12 +135,113 @@ describe('createListRenderer — line-count invariant', () => { { ...makeItem('LIST-REV'), stage: 'in_review', priority: 'medium', status: 'completed' }, ]; const regrouped = regroupWorkItems(merged, 3); - const output = renderer(regrouped, 0, 0, TERM_80x24, null, 'list', null); - const inReviewSeparators = (output.match(/── In Review ──/g) ?? []).length; + // The renderer renders heading rows from the display model — build a + // state and pass getDisplayRows() (WL-0MSL5MPSZ003TG94 AC3). + const state = new WorkItemListState(regrouped, TERM_80x24); + const output = renderer(state.getDisplayRows(), 0, 0, TERM_80x24, null, 'list', null); + const inReviewSeparators = (output.match(/── In Review \(\d+\)/g) ?? []).length; expect(inReviewSeparators).toBe(1); - // In Review separator appears after the Other separator in the rendered output. - expect(output.indexOf('── Other ──')).toBeGreaterThan(-1); - expect(output.indexOf('── In Review ──')).toBeGreaterThan(output.indexOf('── Other ──')); + // In Review heading appears after the Other heading in the rendered output. + expect(output.indexOf('Other (')).toBeGreaterThan(-1); + expect(output.indexOf('── In Review (')).toBeGreaterThan(output.indexOf('Other (')); + }); +}); + +// ── Fold indicators (WL-0MSG8YXYJ008PWJJ) ───────────────────────────── +// When the flattened item list overflows the visible list area, the renderer +// emits dim `▲ more` / `▼ more` markers so users know items are hidden above +// or below the fold. Indicators are display-only rows: they consume no +// navigation state or selection index, and they are included in the render +// budget so the `rows - 1` invariant still holds. + +describe('createListRenderer — fold indicators', () => { + const renderer = createListRenderer(); + + it('shows ▼ more when items exist below the fold at scroll offset 0', () => { + const items: WorkItem[] = Array.from({ length: 30 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 0, 0, TERM_80x24, null, 'list', null); + expect(output).toContain('▼ more'); + expect(output).not.toContain('▲ more'); + }); + + it('shows ▲ more when scrolled down (items above the viewport)', () => { + const items: WorkItem[] = Array.from({ length: 30 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 5, 5, TERM_80x24, null, 'list', null); + expect(output).toContain('▲ more'); + }); + + it('shows both ▲ more and ▼ more when scrolled with items remaining below', () => { + const items: WorkItem[] = Array.from({ length: 30 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 5, 5, TERM_80x24, null, 'list', null); + expect(output).toContain('▲ more'); + expect(output).toContain('▼ more'); + }); + + it('renders no indicators when all items fit in the visible area', () => { + const items: WorkItem[] = [makeItem('A'), makeItem('B'), makeItem('C')]; + const output = renderer(items, 0, 0, TERM_80x24, null, 'list', null); + expect(output).not.toContain('▼ more'); + expect(output).not.toContain('▲ more'); + }); + + it('renders no ▼ more at the last scroll position (nothing below the fold)', () => { + const items: WorkItem[] = Array.from({ length: 20 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 19, 19, TERM_80x24, null, 'list', null); + expect(output).not.toContain('▼ more'); + }); + + it('preserves the rows - 1 line-count invariant with both indicators active', () => { + const items: WorkItem[] = Array.from({ length: 30 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 5, 5, TERM_80x24, null, 'list', null); + expect(output).toContain('▲ more'); + expect(output).toContain('▼ more'); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + // Simulate render()'s notification append: still fits within the pane. + const withNotification = + output.split('\n').slice(0, TERM_80x24.rows - 1).join('\n') + '\n' + ' [Synced]'; + expect(withNotification.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows); + }); + + it('preserves the line-count invariant with indicators and group separators', () => { + const grouped: WorkItem[] = Array.from({ length: 30 }, (_, i) => ({ + ...makeItem(`G${i}`), + group: i % 3, + groupLabel: `Group ${i % 3}`, + })); + const output = renderer(grouped, 5, 5, TERM_80x24, null, 'list', null); + expect(output).toContain('▲ more'); + expect(output).toContain('▼ more'); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + }); + + it('renders indicators as dim, non-selectable display-only rows', () => { + const items: WorkItem[] = Array.from({ length: 30 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 3, 3, TERM_80x24, null, 'list', null); + expect(output).toContain('▲ more'); + expect(output).toContain('▼ more'); + const lines = output.split('\n'); + // Indicators are dim-styled and carry no selection marker. + const topLine = lines.find((l) => l.includes('▲ more'))!; + expect(topLine).toContain(ANSI.dim); + expect(topLine).not.toContain('▸'); + const bottomLine = lines.find((l) => l.includes('▼ more'))!; + expect(bottomLine).toContain(ANSI.dim); + expect(bottomLine).not.toContain('▸'); + // The selected item (index 3) is still rendered with the selection marker. + expect(output).toContain('▸'); + }); + + it('does not change getVisibleItems semantics (indicators are renderer-only)', () => { + const items: WorkItem[] = Array.from({ length: 30 }, (_, i) => makeItem(`I${i}`)); + const state = new WorkItemListState(items, TERM_80x24); + // Unchanged: the visible window is the first listHeight items, no + // indicator rows are consumed from navigation state. With the dynamic + // layout (WL-0MSQ44MDX008U69J) the list can use up to the full available + // space, so the window is the max list height (rows - 4 - minMeta = 17 + // on 80x24). + expect(state.getVisibleItems()).toHaveLength(17); + expect(state.getVisibleItems()[0].id).toBe('I0'); + expect(state.getVisibleItems()[16].id).toBe('I16'); }); }); @@ -204,6 +307,19 @@ describe('createListRenderer — no blank line, no filter bar', () => { const unfiltered = renderer([makeItem('A')], 0, 0, TERM_80x24, null, 'list', null); expect(unfiltered.split('\n')[0]).not.toContain('filtered:'); }); + + it('shows the axis-qualified label for a priority filter in the header (WL-0MSKC8T46006999S)', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, 'priority critical', 'list', null); + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('Work Items'); + expect(firstLine).toContain('(filtered: priority critical)'); + }); + + it('shows the axis-qualified label for a stage filter (WL-0MSKC8T46006999S)', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, 'stage in_review', 'list', null); + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('(filtered: stage in_review)'); + }); }); describe('WorkItemListState.refreshItems — preserve selection by ID', () => { @@ -491,6 +607,73 @@ describe('WorkItemListState.refreshItems — preserve selection by ID', () => { }); }); +describe('WorkItemListState — priority filter slot (WL-0MSKC8T46006999S)', () => { + const TERM = { rows: 24, cols: 80 }; + + it('filters items by priority client-side via applyPriorityFilter', () => { + const items = [ + { ...makeItem('A'), priority: 'critical' }, + { ...makeItem('B'), priority: 'high' }, + { ...makeItem('C'), priority: 'medium' }, + ]; + const state = new WorkItemListState(items, TERM); + state.applyPriorityFilter('critical'); + expect(state.items.map((i) => i.id)).toEqual(['A']); + }); + + it('applyPriorityFilter clears the active stage filter (replace semantics)', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM); + state.applyFilter('idea'); + expect(state.activeFilter).toBe('idea'); + state.applyPriorityFilter('high'); + expect(state.activePriorityFilter).toBe('high'); + expect(state.activeFilter).toBeNull(); + }); + + it('applyFilter clears the active priority filter (replace semantics)', () => { + const state = new WorkItemListState([makeItem('A')], TERM); + state.applyPriorityFilter('critical'); + expect(state.activePriorityFilter).toBe('critical'); + state.applyFilter('idea'); + expect(state.activeFilter).toBe('idea'); + expect(state.activePriorityFilter).toBeNull(); + }); + + it('clearFilter clears both stage and priority filters (sprint)', () => { + const state = new WorkItemListState([makeItem('A')], TERM); + state.applyPriorityFilter('critical'); + state.applyFilter('idea'); + state.clearFilter(); + expect(state.activeFilter).toBeNull(); + expect(state.activePriorityFilter).toBeNull(); + expect(state.items.length).toBe(1); + }); + + it('activeFilterLabel is axis-qualified and null when unfiltered', () => { + const state = new WorkItemListState([makeItem('A')], TERM); + expect(state.activeFilterLabel).toBeNull(); + state.applyFilter('in_review'); + expect(state.activeFilterLabel).toBe('stage in_review'); + state.applyPriorityFilter('critical'); + expect(state.activeFilterLabel).toBe('priority critical'); + state.clearFilter(); + expect(state.activeFilterLabel).toBeNull(); + }); + + it('refreshItems preserves the priority filter across refreshes', () => { + const items = [ + { ...makeItem('A'), priority: 'critical' }, + { ...makeItem('B'), priority: 'high' }, + ]; + const state = new WorkItemListState(items, TERM); + state.applyPriorityFilter('critical'); + expect(state.items.map((i) => i.id)).toEqual(['A']); + state.refreshItems(items); + expect(state.activePriorityFilter).toBe('critical'); + expect(state.items.map((i) => i.id)).toEqual(['A']); + }); +}); + describe('nested expansion — 3+ level hierarchies (WL-0MSQ3FH1K000MMJW)', () => { /** * Build a 3-level hierarchy: EPIC → FEATURE → TASK. Children carry the @@ -694,6 +877,35 @@ describe('executeResolvedCommand', () => { expect(onCommand).not.toHaveBeenCalled(); }); + it('returns dispatched for /wl --priority

priority filters (WL-0MSKC8T46006999S)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + const onCommand = vi.fn(); + const result = executeResolvedCommand('/wl --priority critical', state, onCommand); + expect(result).toBe('dispatched'); + expect(state.activePriorityFilter).toBe('critical'); + expect(state.activeFilter).toBeNull(); + expect(onCommand).not.toHaveBeenCalled(); + }); + + it('routes unknown /wl --priority values to the callback (no crash, no filter change)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + const onCommand = vi.fn(); + const result = executeResolvedCommand('/wl --priority bogus', state, onCommand); + expect(result).toBe('callback'); + expect(onCommand).toHaveBeenCalledWith('/wl --priority bogus', undefined); + expect(state.activePriorityFilter).toBeNull(); + }); + + it('sprint clears an active priority filter (clear-all, WL-0MSKC8T46006999S)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + state.applyPriorityFilter('high'); + const onCommand = vi.fn(); + const result = executeResolvedCommand('/wl', state, onCommand); + expect(result).toBe('dispatched'); + expect(state.activePriorityFilter).toBeNull(); + expect(state.activeFilter).toBeNull(); + }); + it('returns dispatched for /skill:implement with resolved ', () => { const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); state.selectedIndex = 0; @@ -755,10 +967,91 @@ describe('dispatchChordCommand', () => { expect(state.activeFilter).toBeNull(); }); - it('shows the sprint chord in the f-chord help line (WL-0MSGSE15000746F7)', () => { + it('handles /wl --priority

priority filter commands internally (WL-0MSKC8T46006999S)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + expect(dispatchChordCommand('/wl --priority critical', state)).toBe(true); + expect(state.activePriorityFilter).toBe('critical'); + expect(dispatchChordCommand('/wl --priority high', state)).toBe(true); + expect(state.activePriorityFilter).toBe('high'); + expect(dispatchChordCommand('/wl --priority medium', state)).toBe(true); + expect(state.activePriorityFilter).toBe('medium'); + expect(dispatchChordCommand('/wl --priority low', state)).toBe(true); + expect(state.activePriorityFilter).toBe('low'); + }); + + it('leaves unknown /wl --priority values unhandled (no crash, no filter change)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + const result = dispatchChordCommand('/wl --priority bogus', state); + expect(result).toBe(false); + expect(state.activePriorityFilter).toBeNull(); + expect(state.activeFilter).toBeNull(); + }); + + it('does not treat a bare /wl --priority (no value) as a valid command', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + expect(dispatchChordCommand('/wl --priority', state)).toBe(false); + expect(state.activePriorityFilter).toBeNull(); + }); + + it('applying a priority filter replaces an active stage filter (replace semantics)', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM_80x24); + state.applyFilter('idea'); + expect(state.activeFilter).toBe('idea'); + const result = dispatchChordCommand('/wl --priority critical', state); + expect(result).toBe(true); + expect(state.activePriorityFilter).toBe('critical'); + expect(state.activeFilter).toBeNull(); + }); + + it('applying a stage filter replaces an active priority filter (replace semantics)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + state.applyPriorityFilter('critical'); + expect(state.activePriorityFilter).toBe('critical'); + const result = dispatchChordCommand('/wl idea', state); + expect(result).toBe(true); + expect(state.activeFilter).toBe('idea'); + expect(state.activePriorityFilter).toBeNull(); + }); + + it('sprint /wl clears BOTH the stage and priority filters (WL-0MSKC8T46006999S)', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + state.applyPriorityFilter('critical'); + expect(state.activePriorityFilter).toBe('critical'); + const result = dispatchChordCommand('/wl', state); + expect(result).toBe(true); + expect(state.activePriorityFilter).toBeNull(); + expect(state.activeFilter).toBeNull(); + }); + + it('handles /downtime toggle internally via the callback (no pane dispatch, no stdout)', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM_80x24); + const onCommand = vi.fn(); + const onDowntimeToggle = vi.fn(); + const result = dispatchChordCommand('/downtime toggle', state, onCommand, undefined, onDowntimeToggle); + expect(result).toBe(true); + expect(onDowntimeToggle).toHaveBeenCalledTimes(1); + expect(onCommand).not.toHaveBeenCalled(); // never routed to stdout/pane + }); + + it('handles /downtime toggle without a toggle callback (graceful no-op)', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM_80x24); + const onCommand = vi.fn(); + const result = dispatchChordCommand('/downtime toggle', state, onCommand); + expect(result).toBe(true); + expect(onCommand).not.toHaveBeenCalled(); + }); + + it('does not treat other /downtime commands as handled (unknown family falls through)', () => { + const state = new WorkItemListState([makeItem('A', 'idea')], TERM_80x24); + const onCommand = vi.fn(); + expect(dispatchChordCommand('/downtime other', state, onCommand)).toBe(false); + expect(onCommand).not.toHaveBeenCalled(); + }); + + it('shows the sprint chord in the f-s chord help line (WL-0MSGSE15000746F7)', () => { const registry = loadShortcutConfig(); - const chords = registry.getChordByPrefix(['f'], 'list', undefined, false); - const hints = formatChordHintsForHelp(chords, ['f']); + const chords = registry.getChordByPrefix(['f', 's'], 'list', undefined, false); + const hints = formatChordHintsForHelp(chords, ['f', 's']); expect(hints).toContain('s:sprint'); }); @@ -808,13 +1101,13 @@ describe('dispatchChordCommand', () => { state.selectedIndex = 0; const onCommand = vi.fn(); const result = dispatchChordCommand( - "!!wl reviewed false && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", + "!!wl reviewed false && wl update --status open --stage plan_complete --priority medium && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", state, onCommand, ); expect(result).toBe(true); expect(onCommand).toHaveBeenCalledWith( - "!!wl reviewed TEST-123 false && wl audit-set TEST-123 --ready-to-close no --summary 'Rejected by manual review. '", + "!!wl reviewed TEST-123 false && wl update TEST-123 --status open --stage plan_complete --priority medium && wl audit-set TEST-123 --ready-to-close no --summary 'Rejected by manual review. '", undefined, ); }); @@ -865,6 +1158,140 @@ describe('dispatchChordCommand', () => { }); }); +describe('openPane plumbing (WL-0MSJLD1I70045ZUL)', () => { + it('processChordInput stores resolvedOpenPane=false for a chord with open_pane: false', () => { + const registry = loadShortcutConfig(); + const chordState = createChordState(); + const step1 = processChordInput(chordState, 'a', registry, 'list', 'in_review'); + const step2 = processChordInput(chordState, 'y', registry, 'list', 'in_review'); + expect(step1).toBeNull(); // still collecting + expect(step2).toBe('chord-complete'); + expect(chordState.resolvedOpenPane).toBe(false); + expect(chordState.resolvedCommand).toContain('wl reviewed false'); + }); + + it('processChordInput leaves resolvedOpenPane undefined for chords without open_pane', () => { + const registry = loadShortcutConfig(); + const chordState = createChordState(); + processChordInput(chordState, 'a', registry, 'list', 'in_review'); + const step2 = processChordInput(chordState, 'a', registry, 'list', 'in_review'); + expect(step2).toBe('chord-complete'); + expect(chordState.resolvedOpenPane).toBeUndefined(); + }); + + it('dispatchChordCommand passes openPane=false through to onCommand', () => { + const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + const result = dispatchChordCommand( + "!!wl reviewed false && wl audit-set --ready-to-close yes --summary 'Approved by manual review'", + state, + onCommand, + undefined, + undefined, + false, + ); + expect(result).toBe(true); + expect(onCommand).toHaveBeenCalledWith( + "!!wl reviewed TEST-123 false && wl audit-set TEST-123 --ready-to-close yes --summary 'Approved by manual review'", + undefined, + false, + ); + }); + + it('dispatchChordCommand without openPane keeps the 2-arg onCommand call (default open)', () => { + const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + dispatchChordCommand('/skill:audit ', state, onCommand); + expect(onCommand).toHaveBeenCalledWith('/skill:audit TEST-123', undefined); + }); + + it('executeResolvedCommand passes openPane=false through to onCommand', () => { + const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + const result = executeResolvedCommand( + 'wl update --priority high', + state, + onCommand, + false, + undefined, + undefined, + false, + ); + expect(result).toBe('callback'); + expect(onCommand).toHaveBeenCalledWith('wl update TEST-123 --priority high', undefined, false); + }); + + it('executeResolvedCommand passes openPane through for agent commands via dispatchChordCommand', () => { + const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + const result = executeResolvedCommand( + '/skill:audit ', + state, + onCommand, + false, + 'plan', + undefined, + false, + ); + expect(result).toBe('dispatched'); + expect(onCommand).toHaveBeenCalledWith('/skill:audit TEST-123', 'plan', false); + }); + + it('executeResolvedCommand without openPane keeps the 2-arg onCommand call', () => { + const state = new WorkItemListState([makeItem('A')], TERM_80x24); + const onCommand = vi.fn(); + executeResolvedCommand('echo hello', state, onCommand); + expect(onCommand).toHaveBeenCalledWith('echo hello', undefined); + }); + + it('passes onRefresh through to onCommand for background (openPane false) dispatch', () => { + const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + const onRefresh = vi.fn(); + const result = executeResolvedCommand( + 'wl update --priority high', + state, + onCommand, + false, + undefined, + undefined, + false, // openPane: false → background dispatch + onRefresh, + ); + expect(result).toBe('callback'); + expect(onCommand).toHaveBeenCalledWith( + 'wl update TEST-123 --priority high', + undefined, + false, + onRefresh, + ); + }); + + it('passes onRefresh through for background agent dispatch (dispatchChordCommand path)', () => { + const state = new WorkItemListState([makeItem('TEST-123')], TERM_80x24); + state.selectedIndex = 0; + const onCommand = vi.fn(); + const onRefresh = vi.fn(); + const result = executeResolvedCommand( + '/skill:audit ', + state, + onCommand, + false, + 'plan', + undefined, + false, // openPane: false → background dispatch + onRefresh, + ); + expect(result).toBe('dispatched'); + expect(onCommand).toHaveBeenCalledWith('/skill:audit TEST-123', 'plan', false, onRefresh); + }); +}); + describe('fetchItemsForView — stage-filtered fetch', () => { beforeEach(() => { resetExecFileAsync(); @@ -879,7 +1306,7 @@ describe('fetchItemsForView — stage-filtered fetch', () => { setExecFileAsync(mockFn as any); const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); - const items = await fetchItemsForView('idea', defaultFetcher); + const items = await fetchItemsForView('idea', null, defaultFetcher); expect(items.map((i) => i.id)).toEqual(['A', 'B']); expect(defaultFetcher).not.toHaveBeenCalled(); @@ -906,7 +1333,7 @@ describe('fetchItemsForView — stage-filtered fetch', () => { setExecFileAsync(mockFn as any); const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); - const items = await fetchItemsForView('in_review', defaultFetcher); + const items = await fetchItemsForView('in_review', null, defaultFetcher); expect(items.map((i) => i.id)).toEqual(['A', 'B']); expect(defaultFetcher).not.toHaveBeenCalled(); @@ -921,7 +1348,7 @@ describe('fetchItemsForView — stage-filtered fetch', () => { it('uses the default fetcher when no filter is active', async () => { const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); - const items = await fetchItemsForView(null, defaultFetcher); + const items = await fetchItemsForView(null, null, defaultFetcher); expect(items.map((i) => i.id)).toEqual(['C']); expect(defaultFetcher).toHaveBeenCalledTimes(1); }); @@ -931,10 +1358,58 @@ describe('fetchItemsForView — stage-filtered fetch', () => { setExecFileAsync(mockFn as any); const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); - const items = await fetchItemsForView('idea', defaultFetcher); + const items = await fetchItemsForView('idea', null, defaultFetcher); + expect(items.map((i) => i.id)).toEqual(['C']); + }); + + it('fetches all open root items at the priority when a priority filter is active', async () => { + const criticalItems = [ + { ...makeItem('A'), priority: 'critical' }, + { ...makeItem('B'), priority: 'critical' }, + ]; + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: criticalItems }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); + + const items = await fetchItemsForView(null, 'critical', defaultFetcher); + + expect(items.map((i) => i.id)).toEqual(['A', 'B']); + expect(defaultFetcher).not.toHaveBeenCalled(); + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toContain('list'); + expect(callArgs[callArgs.indexOf('--status') + 1]).toBe('open'); + expect(callArgs[callArgs.indexOf('--priority') + 1]).toBe('critical'); + expect(callArgs).toContain('--root-only'); + }); + + it('falls back to the default fetcher when the priority fetch fails', async () => { + const mockFn = vi.fn().mockRejectedValue(new Error('wl failed')); + setExecFileAsync(mockFn as any); + const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); + + const items = await fetchItemsForView(null, 'high', defaultFetcher); expect(items.map((i) => i.id)).toEqual(['C']); }); + it('prefers the stage filter when both are somehow set (defensive)', async () => { + const stageItems = [makeItem('A', 'idea')]; + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: stageItems }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + const defaultFetcher = vi.fn().mockResolvedValue([makeItem('C')]); + + const items = await fetchItemsForView('idea', 'critical', defaultFetcher); + expect(items.map((i) => i.id)).toEqual(['A']); + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toContain('--stage'); + expect(callArgs).not.toContain('--priority'); + }); + it('applies the in_review stage filter client-side regardless of status', () => { // Client-side filter (_applyFilters) matches on stage only — items are // already status-filtered at fetch time, so completed/in-progress @@ -1885,13 +2360,16 @@ describe('createListRenderer — metadata panel in list mode', () => { group: i, groupLabel: `Group ${i}`, })); + // Pass display rows (headings + items) so the renderer renders heading + // rows from the model (WL-0MSL5MPSZ003TG94). + const state = new WorkItemListState(grouped, TERM_80x24); const output = renderer( - grouped, 0, 0, TERM_80x24, null, 'list', null, + state.getDisplayRows(), 0, 0, TERM_80x24, null, 'list', null, undefined, null, 0, false, undefined, undefined, 0, false, true, 0, '/skill:audit WL-G0', ); expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); expect(output).toContain('CODE FREEZE'); - expect(output).toContain('── Group 0 ──'); + expect(output).toContain('Group 0 (1)'); }); it('keeps render plus notification line within rows lines', () => { @@ -1928,10 +2406,17 @@ describe('WorkItemListState — metadata scroll state', () => { it('clamps metaScrollDown to the panel content height', () => { const state = new WorkItemListState([makeRichItem()], TERM_80x24); - const panelHeight = computeMetadataPanelHeight(TERM_80x24.rows); + // Use the dynamic layout (WL-0MSQ44MDX008U69J): for a single-item list the + // metadata panel expands to fill the pane, so the clamp uses that height. + const { panelHeight } = computeDynamicLayout( + state.items, + state.scrollOffset, + /* bannerCount = */ 0, + TERM_80x24, + ); const content = formatMetadataPanel(state.getSelectedItem()!, TERM_80x24.cols, panelHeight, 0); const maxScroll = Math.max(0, content.length - panelHeight); - state.metaScrollDown(100); + state.metaScrollDown(100, panelHeight); expect(state.metaScrollOffset).toBe(maxScroll); }); @@ -1970,11 +2455,12 @@ describe('handleKeypress — metadata scroll keys', () => { state.pageUp(); expect(state.selectedIndex).toBe(0); - // pageDown advances by the list page size (13 rows on 80x24 — the - // freed blank + filter-bar chrome rows are given back to the list) + // pageDown advances by the list page size (17 rows on 80x24 — the list + // may now use up to the full available space minus the metadata minimum, + // WL-0MSQ44MDX008U69J) state.selectedIndex = 0; state.pageDown(); - expect(state.selectedIndex).toBe(13); + expect(state.selectedIndex).toBe(17); // pageDown clamps at the last item state.selectedIndex = 29; @@ -1984,7 +2470,7 @@ describe('handleKeypress — metadata scroll keys', () => { // pageUp moves back exactly one page state.selectedIndex = 23; state.pageUp(); - expect(state.selectedIndex).toBe(10); + expect(state.selectedIndex).toBe(6); }); it('goToFirst/goToLast jump to the ends via state', () => { @@ -2012,12 +2498,12 @@ describe('handleKeypress — metadata scroll keys', () => { // PgUp (\x1b[5~) → pageup expect(handleKeypress(state, '\x1b[5~', TERM_80x24)).toBe('pageup'); - expect(state.selectedIndex).toBe(16); // 29 - 13 + expect(state.selectedIndex).toBe(12); // 29 - 17 // PgDn (\x1b[6~) → pagedown state.selectedIndex = 0; expect(handleKeypress(state, '\x1b[6~', TERM_80x24)).toBe('pagedown'); - expect(state.selectedIndex).toBe(13); + expect(state.selectedIndex).toBe(17); }); }); @@ -2226,6 +2712,126 @@ describe('buildMetaRows — timestamps rendered via formatTimestamp', () => { }); }); +// ── Icon + text metadata values (WL-0MSGIXHHI009KFW9) ────────────────── +// The metadata section (list-mode panel and detail view) renders the same +// icons as the list rows, paired with their text label (icon + text, e.g. +// `🔄 in_progress`, `⭐ high`, `📥 intake_complete`). The Stage row mirrors +// the list's audit-aware in_review icon via the shared stageDisplayIcon +// helper; the Type row shows the epic icon for epic items only; Audit and +// Reviewed rows pair their icons with text labels. With icons disabled the +// values fall back to plain text — no emoji, no bracketed fallbacks. + +describe('buildMetaRows — icon + text metadata values (WL-0MSGIXHHI009KFW9)', () => { + it('prefixes Status, Stage, Priority, Risk and Effort with the list icons', () => { + const rows = new Map(buildMetaRows(makeRichItem())); + // makeRichItem: status in_progress, stage in_progress, priority high, + // risk medium, effort 3 (free-form — no icon key). + expect(rows.get('Status')).toBe('\u{1F504} in_progress'); // 🔄 + expect(rows.get('Stage')).toBe('\u{1F6E0}\u{FE0F} in_progress'); // 🛠️ + expect(rows.get('Priority')).toBe('\u{2B50} high'); // ⭐ + expect(rows.get('Risk')).toBe('\u{1F7E1} medium'); // 🟡 + }); + + it('keeps unknown free-form Effort values text-only (consistent with the list)', () => { + const rows = new Map(buildMetaRows(makeRichItem())); // effort: '3' + expect(rows.get('Effort')).toBe('3'); + }); + + it('shows the epic icon on the Type row for epic items only', () => { + const epic = { ...makeRichItem(), issueType: 'epic' }; + expect(new Map(buildMetaRows(epic)).get('Type')).toBe('\u{2299} epic'); // ⊙ + expect(new Map(buildMetaRows(makeRichItem())).get('Type')).toBe('feature'); + }); + + it('pairs Audit and Reviewed icons with text labels', () => { + // makeRichItem: auditResult true → `✅ ready to close`, + // needsProducerReview true → `❌ needs review`. + const rows = new Map(buildMetaRows(makeRichItem())); + expect(rows.get('Audit')).toBe('\u{2705} ready to close'); // ✅ + expect(rows.get('Reviewed')).toBe('\u{274C} needs review'); // ❌ + const reviewed = new Map(buildMetaRows({ ...makeRichItem(), needsProducerReview: false })); + expect(reviewed.get('Reviewed')).toBe('\u{2705} reviewed'); // ✅ + }); + + it('renders plain text values when icons are disabled (no emoji, no [BRACKET] fallbacks)', () => { + const rows = new Map(buildMetaRows(makeRichItem(), true)); + expect(rows.get('Status')).toBe('in_progress'); + expect(rows.get('Stage')).toBe('in_progress'); + expect(rows.get('Priority')).toBe('high'); + expect(rows.get('Type')).toBe('feature'); + expect(rows.get('Risk')).toBe('medium'); + expect(rows.get('Effort')).toBe('3'); + expect(rows.get('Audit')).toBe('ready to close'); + expect(rows.get('Reviewed')).toBe('needs review'); + // No icon glyphs leak into the noIcons values. + for (const [label, value] of rows) { + if (['Status', 'Stage', 'Priority', 'Type', 'Risk', 'Effort', 'Audit', 'Reviewed'].includes(label)) { + expect(value).not.toMatch(/[\u{1F300}-\u{1FAFF}\u{2700}-\u{27BF}\u{2600}-\u{26FF}\u{2299}]/u); + expect(value).not.toMatch(/^\[/); + } + } + }); +}); + +describe('buildMetaRows — audit-aware in_review Stage row (WL-0MSGIXHHI009KFW9 AC2)', () => { + const base = (over: Partial = {}): WorkItem => ({ + ...makeRichItem(), + stage: 'in_review', + auditResult: null, + auditedAt: null, + updatedAt: '2026-08-02T10:00:00.000Z', + ...over, + }); + + it('shows the fresh audit result icon (✅/❌/❓) when the audit is fresh', () => { + const updatedAt = '2026-08-02T10:00:00.000Z'; + const freshPass = base({ auditResult: true, auditedAt: '2026-08-02T10:00:30.000Z', updatedAt }); + expect(new Map(buildMetaRows(freshPass)).get('Stage')).toBe('\u{2705} in_review'); // ✅ + const freshFail = base({ auditResult: false, auditedAt: '2026-08-02T10:00:30.000Z', updatedAt }); + expect(new Map(buildMetaRows(freshFail)).get('Stage')).toBe('\u{274C} in_review'); // ❌ + const freshUnknown = base({ auditResult: null, auditedAt: '2026-08-02T10:00:30.000Z', updatedAt }); + expect(new Map(buildMetaRows(freshUnknown)).get('Stage')).toBe('\u{2753} in_review'); // ❓ + }); + + it('shows the stale-passed hourglass when the audit is stale but passed', () => { + const stalePass = base({ auditResult: true, auditedAt: '2026-08-01T10:00:00.000Z' }); + expect(new Map(buildMetaRows(stalePass)).get('Stage')).toBe('\u{23F3} in_review'); // ⏳ + }); + + it('falls back to the plain in_review stage icon otherwise (no audit / stale fail)', () => { + const noAudit = base(); + expect(new Map(buildMetaRows(noAudit)).get('Stage')).toBe('\u{1F50D} in_review'); // 🔍 + }); +}); + +describe('metadata panel and detail view — icon + text values (WL-0MSGIXHHI009KFW9 AC6)', () => { + it('renders icon+text in the list-mode metadata panel', () => { + const joined = formatMetadataPanel(makeRichItem(), 80, 20, 0).join('\n'); + expect(joined).toContain('\u{1F504} in_progress'); // 🔄 status + expect(joined).toContain('\u{2B50} high'); // ⭐ priority + expect(joined).toContain('\u{2705} ready to close'); // ✅ audit + }); + + it('renders icon+text in the detail-view metadata table', () => { + const joined = formatDetailContent(makeRichItem(), 80).join('\n'); + expect(joined).toContain('\u{1F504} in_progress'); + expect(joined).toContain('\u{2B50} high'); + expect(joined).toContain('\u{2705} ready to close'); + }); + + it('renders plain text in both views when icons are disabled', () => { + const panel = formatMetadataPanel(makeRichItem(), 80, 20, 0, undefined, true).join('\n'); + expect(panel).toContain(' in_progress'); + expect(panel).toContain(' ready to close'); + expect(panel).not.toContain('\u{1F504}'); + expect(panel).not.toContain('[INPR]'); + const detail = formatDetailContent(makeRichItem(), 80, undefined, true).join('\n'); + expect(detail).toContain('in_progress'); + expect(detail).toContain('ready to close'); + expect(detail).not.toContain('\u{1F504}'); + }); +}); + // ── Downtime status indicator (WL-0MSF49FMW009M06K, F4) ─────────────── describe('renderDowntimeStatus', () => { @@ -2253,13 +2859,67 @@ describe('renderDowntimeStatus', () => { expect(renderDowntimeStatus(worker)).toContain('downtime dispatching'); }); - it('renders the disabled state', () => { + it('renders the Downtime Off state', () => { const worker = { idleSince: null, dispatching: false, enabled: false, } as unknown as DowntimeWorker; - expect(renderDowntimeStatus(worker)).toContain('downtime disabled'); + expect(renderDowntimeStatus(worker)).toContain('Downtime Off'); + }); + + it('renders idle status when enabled and idle (per-instance enabled from override)', () => { + const worker = { + idleSince: Date.now() - 65_000, // 1:05 + dispatching: false, + enabled: true, + } as unknown as DowntimeWorker; + const status = renderDowntimeStatus(worker); + expect(status).toContain('downtime idle 1:05'); + expect(status).toContain('⏳'); + }); + + it('renders per-instance isolation: two real workers toggle independently', () => { + const makeWorker = (enabled: boolean) => + createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: {} as never, + config: () => ({ + enabled, + thresholdMs: 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }), + }); + const w1 = makeWorker(true); + const w2 = makeWorker(true); + w1.toggle(); // disable only pane 1 + expect(w1.enabled).toBe(false); + expect(w2.enabled).toBe(true); + expect(renderDowntimeStatus(w1)).toContain('Downtime Off'); + expect(renderDowntimeStatus(w2)).toContain('downtime busy'); + }); + + it('renders idle status when toggle forces dispatch on despite global setting off', () => { + // Global setting disabled, but the per-instance override forces the + // worker on — the header must show the live idle status, not Off. + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: {} as never, + config: () => ({ + enabled: false, // global setting off + thresholdMs: 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + }), + override: true, + }); + expect(worker.enabled).toBe(true); + expect(renderDowntimeStatus(worker)).toContain('downtime busy'); }); it('renders the paused state during the no-candidate cooldown (no stale idle duration)', () => { @@ -2296,6 +2956,152 @@ describe('renderDowntimeStatus', () => { expect(output).toContain('[⏳ downtime idle 0:05]'); expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); }); + + // Header honesty (WL-0MT5SFX1S002YUYZ / WL-0MT5SG0VU005ARUR): the rendered + // state must ALWAYS agree with the worker's effective dispatch gate, and a + // worker restored from the persisted disable marker must be visible. + it.each([ + [ + 'disabled (override=false, global enabled)', + { idleSince: Date.now() - 60_000, dispatching: false, enabled: false, restoredFromMarker: false }, + 'Downtime Off', + ], + [ + 'disabled (restored from marker)', + { idleSince: Date.now() - 60_000, dispatching: false, enabled: false, restoredFromMarker: true }, + 'Downtime Off (restored)', + ], + [ + 'enabled + idle', + { idleSince: Date.now() - 60_000, dispatching: false, enabled: true, restoredFromMarker: false }, + 'downtime idle', + ], + [ + 'enabled + paused', + { idleSince: null, dispatching: false, enabled: true, paused: true, restoredFromMarker: false }, + 'downtime paused', + ], + [ + 'dispatching', + { idleSince: Date.now() - 60_000, dispatching: true, enabled: true, restoredFromMarker: false }, + 'downtime dispatching', + ], + [ + 'enabled + busy', + { idleSince: null, dispatching: false, enabled: true, restoredFromMarker: false }, + 'downtime busy', + ], + ])('renders %s agreeing with the effective gate (never lies, AC1)', (_label, worker, expected) => { + const status = renderDowntimeStatus(worker as unknown as DowntimeWorker); + expect(status).toContain(expected); + // A pane whose dispatch gate is disabled must NEVER render the + // enabled-idle string (AC1): + if ((worker as { enabled: boolean }).enabled === false) { + expect(status).not.toContain('downtime idle'); + } + }); + + it('disabled override renders [Downtime Off] regardless of global settings (AC2)', () => { + const worker = { + idleSince: Date.now() - 5_000, + dispatching: false, + enabled: false, // override=false ⇒ effective off even with global on + restoredFromMarker: false, + } as unknown as DowntimeWorker; + const status = renderDowntimeStatus(worker); + expect(status).toContain('Downtime Off'); + expect(status).not.toContain('downtime idle'); + expect(status).not.toContain('restored'); + }); + + it('restored-from-marker worker renders an explicit notice (AC4)', () => { + const worker = { + idleSince: null, + dispatching: false, + enabled: false, // restored from .herdr-downtime-disabled + restoredFromMarker: true, + } as unknown as DowntimeWorker; + expect(renderDowntimeStatus(worker)).toContain('Downtime Off (restored)'); + }); + + it('after re-enable the restored notice is gone (AC5)', () => { + const worker = { + idleSince: null, + dispatching: false, + enabled: true, // override null + no marker ⇒ follows settings + restoredFromMarker: false, + } as unknown as DowntimeWorker; + const status = renderDowntimeStatus(worker); + expect(status).toContain('downtime busy'); + expect(status).not.toContain('restored'); + }); + + it('a real worker restored from the marker exposes restoredFromMarker and renders the notice (AC4, integration)', () => { + const root = mkdtempSync(join(tmpdir(), 'herdr-hdr-')); + const cwd = join(root, 'wlroot'); + mkdirSync(cwd, { recursive: true }); + try { + // Marker present ⇒ construction restores override=false + flag. + writeFileSync(join(cwd, '.herdr-downtime-disabled'), '', 'utf8'); + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: {} as never, + config: () => ({ + enabled: true, + thresholdMs: 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd, + noCandidateCooldownMs: 3_600_000, + }), + }); + expect(worker.override).toBe(false); + expect(worker.restoredFromMarker).toBe(true); + expect(renderDowntimeStatus(worker)).toContain('Downtime Off (restored)'); + + // Explicit re-enable (second press) clears the flag, removes the + // marker, and the notice disappears (AC3/AC5). + worker.toggle(); // → null + expect(worker.override).toBe(null); + expect(worker.restoredFromMarker).toBe(false); + expect(existsSync(join(cwd, '.herdr-downtime-disabled'))).toBe(false); + const status = renderDowntimeStatus(worker); + expect(status).not.toContain('restored'); + expect(status).toContain('downtime busy'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('a live d-press disable is NOT flagged as restored (AC4)', () => { + const root = mkdtempSync(join(tmpdir(), 'herdr-hdr-')); + const cwd = join(root, 'wlroot'); + mkdirSync(cwd, { recursive: true }); + try { + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: {} as never, + config: () => ({ + enabled: true, + thresholdMs: 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd, + noCandidateCooldownMs: 3_600_000, + }), + }); + expect(worker.restoredFromMarker).toBe(false); + worker.toggle(); // live disable (marker written, but NOT 'restored') + expect(worker.override).toBe(false); + expect(worker.restoredFromMarker).toBe(false); + expect(renderDowntimeStatus(worker)).toContain('Downtime Off'); + expect(renderDowntimeStatus(worker)).not.toContain('restored'); + // The marker was written so a future restart will restore it. + expect(existsSync(join(cwd, '.herdr-downtime-disabled'))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); // ── showIcons gating (WL-0MSBV4RYO008JL70) ───────────────────────────── @@ -2322,7 +3128,7 @@ describe('createListRenderer — showIcons gating', () => { expect(output).toContain('[OPEN]'); // status text fallback expect(output).toContain('[IDEA]'); // stage text fallback expect(output).not.toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon - expect(output).toContain('[?]'); // audit text fallback + expect(output).toContain('unknown'); // audit text label (metadata falls back to plain text, WL-0MSGIXHHI009KFW9) }); it('re-reads the getter on every render (settings re-read path)', () => { @@ -2618,6 +3424,53 @@ describe('detail view ToC for Related Docs (WL-0MSHWHULZ001FL8I)', () => { expect(firstLines).toContain('Related Docs'); expect(firstLines).toContain('1. docs/prd.md'); }); + + // ── Overflow regression: header+ToC pinned, body scrolls (WL-0MSI28AP80002F5S) ── + + const LONG_ITEM = (): WorkItem => { + const item = makeKeyFilesItem('WL-OVF', ['docs/prd.md', 'docs/episode.podcast.md']); + item.description += '\n\n' + Array.from({ length: 60 }, (_, i) => `lorem ipsum dolor line ${i}`).join('\n'); + return item; + }; + + it('formatDetailView with ToC respects the line-count invariant (≤ viewportHeight lines)', () => { + const item = LONG_ITEM(); + // viewportHeight = 24 → output must be exactly 24 lines + const view = formatDetailView(item, 80, 0, 24, undefined, true, 0, false, 0); + const lines = view.split('\n'); + expect(lines.length).toBe(24); + }); + + it('formatDetailView at scroll 0 shows header id, title, and full ToC', () => { + const item = LONG_ITEM(); + const view = formatDetailView(item, 80, 0, 24, undefined, true, 0, false, 0); + const lines = view.split('\n'); + const all = lines.join('\n'); + // Header id and title must be visible + expect(all).toContain('WL-OVF'); + expect(all).toMatch(/title/i); + // Full ToC must be visible (both entries) + expect(all).toContain('1. docs/prd.md'); + expect(all).toContain('2. docs/episode.podcast.md'); + }); + + it('ToC is rendered exactly once — no duplication', () => { + const item = LONG_ITEM(); + const view = formatDetailView(item, 80, 0, 24, undefined, true, 0, false, 0); + const lines = view.split('\n'); + const tocEntryCount = lines.filter(l => l.includes('1. docs/prd.md')).length; + expect(tocEntryCount).toBe(1); + }); + + it('no-ToC items retain full-scroll behavior unchanged', () => { + const item = makeItem('WL-PLAIN'); + item.description = Array.from({ length: 50 }, (_, i) => `line ${i}`).join('\n'); + const view = formatDetailView(item, 80, 0, 24, undefined, true); + const lines = view.split('\n'); + expect(lines.length).toBe(24); + // No ToC markers should appear + expect(lines.join('\n')).not.toContain('Related Docs'); + }); }); describe('Related Docs — open in markdown viewer (WL-0MSGTLSUT002NF29)', () => { @@ -2892,3 +3745,261 @@ describe('inline-note editing — viewer integration (WL-0MSKV6SKK008MMXR)', () expect(mockExec).not.toHaveBeenCalled(); }); }); + +// ── Dynamic list height & metadata panel layout (WL-0MSQ44MDX008U69J) ─── +// The list takes as much space as its content needs (up to the max available +// rows minus the metadata minimum of 3 rows). When the list is short the +// metadata panel fills the leftover space. When the list is long it fills +// the available space and the metadata panel sits at the bottom of the pane. + +describe('createListRenderer — dynamic list height (WL-0MSQ44MDX008U69J)', () => { + const renderer = createListRenderer(); + + // ── Short list: metadata fills the rest ──────────────────────── + + it('lets a short list take only the rows it needs; metadata fills the rest', () => { + const items: WorkItem[] = [makeItem('A'), makeItem('B'), makeItem('C')]; + const output = renderer(items, 0, 0, TERM_80x24, null, 'list', null); + const lines = output.split('\n'); + // Total lines must be ≤ rows - 1 (notification row reserved) + expect(lines.length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + // The metadata panel (── A ── separator, selected item's id) is visible + expect(output).toContain('── A ──'); + // The list header is always visible + expect(output).toContain('Work Items'); + expect(output).toContain('Item A'); + expect(output).toContain('Item B'); + expect(output).toContain('Item C'); + // Metadata panel has at least 3 rows (minimum) + const metaSeparatorIdx = lines.findIndex(l => l.includes('── A ──')); + expect(metaSeparatorIdx).toBeGreaterThan(-1); + const remainingLines = lines.length - metaSeparatorIdx; + expect(remainingLines).toBeGreaterThanOrEqual(3); + }); + + it('metadata panel expands to fill leftover space when list has one item', () => { + const items: WorkItem[] = [makeItem('A')]; + const output = renderer(items, 0, 0, TERM_80x24, null, 'list', null); + const lines = output.split('\n'); + expect(lines.length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + const metaSeparatorIdx = lines.findIndex(l => l.includes('── A ──')); + expect(metaSeparatorIdx).toBeGreaterThan(-1); + // Metadata should take most of the remaining space (at least half) + const remainingLines = lines.length - metaSeparatorIdx; + expect(remainingLines).toBeGreaterThan(lines.length / 2); + }); + + // ── Long list: list fills the pane ───────────────────────────── + + it('lets a long list fill the available space; metadata is below the fold', () => { + const items: WorkItem[] = Array.from({ length: 50 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 0, 0, TERM_80x24, null, 'list', null); + const lines = output.split('\n'); + expect(lines.length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + // Header + first items visible + expect(output).toContain('Work Items'); + expect(output).toContain('Item I0'); + // Bottom fold indicator visible (items below the fold) + expect(output).toContain('▼ more'); + // Metadata panel is present (rendered after the list, below the fold) + const metaSeparatorIdx = lines.findIndex(l => l.includes('── I0 ──')); + expect(metaSeparatorIdx).toBeGreaterThan(-1); + // With a long list the metadata panel is at the very bottom (minimum + // height), not consuming rows the list could use. + const remainingLines = lines.length - metaSeparatorIdx; + expect(remainingLines).toBeGreaterThanOrEqual(3); + expect(remainingLines).toBeLessThanOrEqual(8); + }); + + // ── Initial view at top ──────────────────────────────────────── + + it('starts view at top of selection list (header + first items)', () => { + const items: WorkItem[] = Array.from({ length: 30 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 0, 0, TERM_80x24, null, 'list', null); + // First visible item is I0 (index 0), not a scrolled offset + expect(output).toContain('Item I0'); + // Header is the first line + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('Work Items'); + // No ▲ more indicator (not scrolled down) + expect(output).not.toContain('▲ more'); + }); + + // ── Line-count invariant ─────────────────────────────────────── + + it('preserves the rows - 1 line-count invariant with dynamic layout (many groups)', () => { + const items: WorkItem[] = Array.from({ length: 60 }, (_, i) => ({ + ...makeItem(`G${i}`), + group: i, + groupLabel: `Group ${i}`, + })); + const output = renderer(items, 0, 0, TERM_80x24, null, 'list', null); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + expect(output).toContain('Work Items'); + }); + + it('preserves the rows - 1 line-count invariant with banner + dynamic layout', () => { + const items: WorkItem[] = Array.from({ length: 40 }, (_, i) => makeItem(`I${i}`)); + const output = renderer( + items, + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + null, + 0, + false, + undefined, + undefined, + 0, + false, + true, // codeFreezeActive + ); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + expect(output).toContain('CODE FREEZE'); + expect(output).toContain('Work Items'); + }); + + it('preserves the rows - 1 line-count invariant with ambiguous banner + dynamic layout', () => { + const items: WorkItem[] = Array.from({ length: 40 }, (_, i) => makeItem(`I${i}`)); + const output = renderer( + items, + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + null, + 0, + false, + undefined, + undefined, + 0, + false, + false, // codeFreezeActive (fail-open: browsing stays unblocked) + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, // showHelpText + true, // codeFreezeAmbiguous + ); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + expect(output).toContain('Ambiguous Codefreeze'); + }); + + it('preserves the rows - 1 invariant with panel + groups + both banners', () => { + const items: WorkItem[] = Array.from({ length: 50 }, (_, i) => ({ + ...makeItem(`I${i}`), + group: i, + groupLabel: `Group ${i}`, + })); + const output = renderer( + items, + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + null, + 0, + false, + undefined, + undefined, + 0, + false, + true, // codeFreezeActive + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, // showHelpText + true, // codeFreezeAmbiguous + ); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + expect(output).toContain('CODE FREEZE'); + expect(output).toContain('Ambiguous Codefreeze'); + }); + + // ── Metadata scroll clamping with new layout ─────────────────── + + it('metadata scroll clamping works under the new layout', () => { + const state = new WorkItemListState([ + { + ...makeRichItem(), + tags: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7', 'tag8', 'tag9', 'tag10'], + description: 'A very long description line that will generate lots of metadata panel content to test scroll clamping. '.repeat(10), + }, + ], TERM_80x24); + state.setSelectedIndex(0); + // metaScrollDown should not throw and should clamp correctly + expect(() => { + state.metaScrollDown(100); + }).not.toThrow(); + // metaScrollUp should clamp at 0 + state.metaScrollOffset = 5; + state.metaScrollUp(10); + expect(state.metaScrollOffset).toBe(0); + }); + + // ── Scroll offset 0 with long list ───────────────────────────── + + it('long list at scroll offset 0 shows top items and bottom indicator', () => { + const items: WorkItem[] = Array.from({ length: 40 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 0, 0, TERM_80x24, null, 'list', null); + expect(output).toContain('Item I0'); + expect(output).toContain('▼ more'); + expect(output).not.toContain('▲ more'); + }); + + // ── Different terminal sizes ─────────────────────────────────── + + it('preserves the invariant on very tall terminals (60 rows)', () => { + const tallTerm = { rows: 60, cols: 80 }; + const items: WorkItem[] = Array.from({ length: 100 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 0, 0, tallTerm, null, 'list', null); + expect(output.split('\n').length).toBeLessThanOrEqual(tallTerm.rows - 1); + }); + + it('preserves the invariant on very short terminals (10 rows)', () => { + const shortTerm = { rows: 10, cols: 40 }; + const items: WorkItem[] = Array.from({ length: 10 }, (_, i) => makeItem(`I${i}`)); + const output = renderer(items, 0, 0, shortTerm, null, 'list', null); + expect(output.split('\n').length).toBeLessThanOrEqual(shortTerm.rows - 1); + expect(output).toContain('Work Items'); + }); + + // ── Short list with groups ───────────────────────────────────── + + it('short list with groups: metadata fills the rest', () => { + const items: WorkItem[] = [ + makeItem('A'), + { ...makeItem('B'), group: 0, groupLabel: 'Group A' }, + { ...makeItem('C'), group: 0, groupLabel: 'Group A' }, + ]; + // Pass display rows so the renderer renders the Group A heading with its + // count (WL-0MSL5MPSZ003TG94 AC1). + const state = new WorkItemListState(items, TERM_80x24); + const output = renderer(state.getDisplayRows(), 0, 0, TERM_80x24, null, 'list', null); + const lines = output.split('\n'); + expect(lines.length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + expect(output).toContain('Group A (2)'); + const metaSeparatorIdx = lines.findIndex(l => l.includes('── A ──')); + expect(metaSeparatorIdx).toBeGreaterThan(-1); + // Metadata should take at least 5 rows (more than minimum) + expect(lines.length - metaSeparatorIdx).toBeGreaterThanOrEqual(5); + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 318d2946c..78ae2eb7a 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -13,24 +13,32 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve as resolvePath } from 'node:path'; -import { fetchChildrenForItem, fetchActionableCount, fetchItemsByStage, getWorklogDir, type WorkItem } from './fetcher.js'; +import { fetchChildrenForItem, fetchActionableCount, fetchItemsByStage, fetchItemsByPriority, getWorklogDir, type WorkItem } from './fetcher.js'; import { isPaneVisible, PollGate, DEFAULT_POLL_GATE_TTL_MS } from './visibility.js'; +import { HerdrEventSubscriber } from './events.js'; +import { AgentTracker, mergeAgentStatesCached } from './agent-tracker.js'; import { readCodeFreezeState, readCodeFreezeStatus } from './code-freeze.js'; import type { ShortcutRegistry, ShortcutEntry } from './shortcut-config.js'; import { statusIcon, stageIcon, priorityIcon, + epicIcon, + riskIcon, + effortIcon, auditIcon, needsProducerReviewIcon, + stageDisplayIcon, getIconPrefix, applyStageColour, stageColor, type IconOptions, -} from './icons.js'; +} from '@worklog/shared/icons'; import { runSync, heartbeatTtlForInterval } from './auto-sync.js'; import { TaskScheduler, DEFAULT_SCHEDULER_TICK_MS } from './scheduler.js'; +import { loadSettings } from './settings.js'; import { DEFAULT_DOWNTIME_POLL_INTERVAL_MS, DOWNTIME_RUN_TIMEOUT_MS, type DowntimeWorker } from './downtime-worker.js'; +import { type ModeSwitchWorker, DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS, MODE_SWITCH_RUN_TIMEOUT_MS } from './mode-switch-worker.js'; import { showToast } from './notify.js'; import { recordCommand, getLastCommand } from './command-log.js'; import { @@ -39,6 +47,7 @@ import { FormState, substituteIdentifiers, } from './form-dialog.js'; +import { readFromClipboard, writeToClipboard } from './clipboard.js'; import { ShipItDialogState, overlayShipItDialog } from './ship-it-dialog.js'; import { extractFilePaths } from './grouping.js'; import { renderMarkdown, renderMarkdownViewer } from './md-viewer.js'; @@ -93,6 +102,17 @@ export const STAGE_MAP: Record = { in_review: 'in_review', }; +// ── /wl --priority map (WL-0MSKC8T46006999S) ──────────────── +// Canonical priority names accepted by `/wl --priority

` and the +// `f p *` priority-filter chords. Invalid/unknown values fall back +// gracefully (no crash, no filter change), matching `/wl `. +export const PRIORITY_MAP: Record = { + critical: 'critical', + high: 'high', + medium: 'medium', + low: 'low', +}; + // Re-export stage colors from icons for backward compatibility export const STAGE_COLORS: Record = { idea: 247, @@ -103,12 +123,14 @@ export const STAGE_COLORS: Record = { completed: 33, }; -// ── Metadata panel sizing (WL-0MSAYNVBY006LM9X) ───────────────────────── -// The list renderer reserves the bottom of the pane for a metadata panel -// showing the selected item's fields plus its last recorded command. The -// panel share of the pane height ramps linearly between MIN_META_SHARE on -// small panes and MAX_META_SHARE on tall panes, so the list always keeps at -// least 60% of the pane. +// ── Metadata panel sizing ─────────────────────────────────────────────── +// The list renderer shows the selected item's metadata in a panel below the +// list. The list takes as much vertical space as its content needs (up to +// the full pane height) and the panel fills the remainder, keeping a small +// minimum — see computeDynamicLayout (WL-0MSQ44MDX008U69J). +// computeMetadataPanelHeight below is retained as a legacy fallback (used by +// metaScrollDown when no dynamic panel height is supplied) from the original +// fixed 20–40% reservation (WL-0MSAYNVBY006LM9X). /** Minimum share of pane rows reserved for the metadata panel (small panes). */ export const MIN_META_SHARE = 0.2; @@ -121,12 +143,12 @@ const META_SHARE_MIN_ROWS = 12; const META_SHARE_MAX_ROWS = 40; /** - * Compute the number of pane rows reserved for the metadata panel. + * Legacy fallback: compute a fixed metadata panel height from the pane rows. * * The share ramps linearly from MIN_META_SHARE at `META_SHARE_MIN_ROWS` to * MAX_META_SHARE at `META_SHARE_MAX_ROWS`. The result is clamped to a - * minimum of 3 rows (so the panel stays usable) and to MAX_META_SHARE of the - * pane (so the list keeps at least 60%). + * minimum of 3 rows. Only used by metaScrollDown when no dynamic panel + * height is provided; the renderer uses computeDynamicLayout instead. * * @param rows - Total pane height in rows. * @returns Number of panel rows (always < rows). @@ -140,6 +162,58 @@ export function computeMetadataPanelHeight(rows: number): number { return Math.max(3, Math.min(Math.round(rows * share), Math.floor(rows * MAX_META_SHARE))); } +/** + * Compute the dynamic list / metadata panel layout (WL-0MSQ44MDX008U69J). + * + * The selection list takes as much vertical space as its content needs, up to + * the maximum available (pane rows minus the reserved notification row and the + * metadata panel's minimum height). The metadata panel fills whatever space + * remains, expanding when the list is short. + * + * This function is called both by the renderer (createListRenderer) and by + * the main TUI loop (to pass the panel height into metaScrollDown for + * correct clamping). + * + * The display-rows model (WL-0MSL5MPSZ003TG94) passes `DisplayRow[]` — + * heading rows are first-class rows already counted in the window, so no + * extra separator accounting is needed. + * + * @param rows - Display rows (headings + items) to display. + * @param scrollOffset - Current scroll offset into the display rows. + * @param bannerCount - Number of code-freeze banner rows. + * @param termSize - Terminal size. + * @returns The metadata panel height, the list area, and the list height. + */ +export function computeDynamicLayout( + rows: DisplayRow[], + scrollOffset: number, + bannerCount: number, + termSize: TermSize, +): { panelHeight: number; listArea: number; listHeight: number } { + const { rows: termRows } = termSize; + const minMeta = 3; + const chromeLines = 2 + bannerCount; // header + banners + footer + + // Estimate the visible window with metadata at its minimum so the estimate + // reflects the list's true space need. Heading rows are already part of the + // window — no separate separator count. + const estListHeight = Math.max(3, termRows - 4 - minMeta); + const estVisible = rows.slice(scrollOffset, scrollOffset + estListHeight); + const estHasTopIndicator = scrollOffset > 0; + const estHasBottomIndicator = scrollOffset + estVisible.length < rows.length; + const estIndicatorRows = (estHasTopIndicator ? 1 : 0) + (estHasBottomIndicator ? 1 : 0); + const contentNeed = estIndicatorRows + estVisible.length; + + const panelHeight = Math.max(minMeta, Math.max(0, termRows - 1 - contentNeed - chromeLines)); + const listArea = Math.max(1, termRows - 1 - panelHeight); + // The initial visible window is the maximum possible (metadata at its + // minimum); the caller's trimming logic reduces it to fit the actual + // budget (fold indicators). This keeps the window generous so + // a short list is never truncated just because the metadata panel expanded. + const listHeight = Math.max(3, termRows - 4 - minMeta); + return { panelHeight, listArea, listHeight }; +} + // ── Terminal helpers ───────────────────────────────────────────────── export interface TermSize { @@ -186,6 +260,11 @@ export const ANSI = { hideCursor: '\x1b[?25l', showCursor: '\x1b[?25h', scrollRegion: (top: number, bottom: number) => `\x1b[${top};${bottom}r`, + // SGR mouse tracking (WL-0MSGHM5BQ0096BNJ AC1): enable/disable sequences + // for button-event (1000), button-event-motion (1002) and SGR-encoded + // (1006) reporting, emitted on raw-mode entry / cleanup respectively. + mouseEnable: '\x1b[?1000h\x1b[?1002h\x1b[?1006h', + mouseDisable: '\x1b[?1000l\x1b[?1002l\x1b[?1006l', }; // ── Navigation Stack ────────────────────────────────────────────────── @@ -269,6 +348,40 @@ export class NavigationStack { export type ViewMode = 'list' | 'detail' | 'filter' | 'form'; +/** + * A group heading row in the display-rows model (WL-0MSL5MPSZ003TG94). + * + * Headings are first-class rows interleaved with item rows by + * `WorkItemListState.getDisplayRows()`. They carry the group number, the + * human-readable label, the count of top-level items in the group (current + * view, post stage-filter) and the in-memory collapse state. + */ +export interface DisplayHeadingRow { + kind: 'heading'; + /** Group number (1-indexed, from regroupWorkItems). */ + group: number; + /** Human-readable group label (e.g. `Group 1`, `Idea`, `In Review`). */ + groupLabel: string; + /** Top-level items in this group in the current view (post stage-filter). */ + count: number; + /** Whether the group's items are hidden (in-memory, session-only). */ + collapsed: boolean; +} + +/** + * A single row in the display-rows model: either a group heading or a work + * item (children of expanded parents included). Navigation, clamping and + * scrolling operate over these rows so the cursor can land on headings. + */ +export type DisplayRow = DisplayHeadingRow | WorkItem; + +/** + * Type guard: is this display row a group heading row? + */ +export function isHeadingRow(row: DisplayRow): row is DisplayHeadingRow { + return 'kind' in row && row.kind === 'heading'; +} + /** * Mutable state for the work item list UI. */ @@ -293,9 +406,9 @@ export class WorkItemListState { this._resetMetaScroll(); } - /** Number of items in the flattened (display) list. */ + /** Number of rows in the display-rows model (headings + items). */ get flatCount(): number { - return this.getFlattenedItems().length; + return this.getDisplayRows().length; } /** @@ -322,9 +435,28 @@ export class WorkItemListState { /** Currently displayed detail item (when mode === 'detail'). */ detailItem: WorkItem | null = null; - /** Active stage filter (null = no filter). */ + /** Active stage filter (null = no stage filter). */ activeFilter: string | null = null; + /** + * Active priority filter (null = no priority filter). Mutually exclusive + * with `activeFilter` (replace semantics, WL-0MSKC8T46006999S): applying + * a priority filter clears the stage filter and vice versa, and sprint + * clears both. Only one axis can be active at a time. + */ + activePriorityFilter: string | null = null; + + /** + * Display label for the active filter, axis-qualified, or null when no + * filter is active (e.g. `stage in_review`, `priority critical`). The + * list header renders `(filtered:

` with a + // canonical name (critical|high|medium|low) applies the priority filter; + // unknown values fall through unhandled (no crash, no filter change), + // matching the `/wl ` behaviour below. + const wlPriorityMatch = command.match(/^\/wl\s+--priority\s+(\S+)$/); + if (wlPriorityMatch) { + const wlPriority = wlPriorityMatch[1]; + const internalPriority = PRIORITY_MAP[wlPriority]; + if (internalPriority) { + state.applyPriorityFilter(internalPriority); + return true; + } + } + // ── /wl commands (internal dispatch) ────────────── const wlStageMatch = command.match(/^\/wl\s+(\S+)$/); if (wlStageMatch) { @@ -2537,8 +3403,8 @@ export function dispatchChordCommand( // ── /wl (no arguments): return to the default unfiltered view ──── // Equivalent to the Pi TUI's `/wl` with no args (WL-0MSGSE15000746F7): - // clears the active stage filter so the next refresh shows the standard - // smart-selection list. No-op when no filter is active. + // clears the active stage AND priority filters so the next refresh shows + // the standard smart-selection list. No-op when no filter is active. if (/^\/wl\s*$/.test(command)) { state.clearFilter(); return true; @@ -2546,33 +3412,33 @@ export function dispatchChordCommand( // ── Agent skill invocations ───────────────────────────── if (command.startsWith('/skill:implement')) { - return resolveAndRouteCommand(command, state, onCommand, model); + return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh); } if (command.startsWith('/skill:audit')) { - return resolveAndRouteCommand(command, state, onCommand, model); + return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh); } if (command.startsWith('/skill:ship')) { // Dev→main release (Ship It shortcut, WL-0MSGG5N5Z0074TLY). Global // release — no substitution; routed to the agent channel like // other /skill:* commands. NOT blocked during a Code Freeze (the ship // skill gates itself); only the confirmation dialog precedes dispatch. - return resolveAndRouteCommand(command, state, onCommand, model); + return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh); } // ── Agent workflow commands ───────────────────────────── if (command.startsWith('/intake')) { - return resolveAndRouteCommand(command, state, onCommand, model); + return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh); } if (command.startsWith('/plan')) { - return resolveAndRouteCommand(command, state, onCommand, model); + return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh); } // ── Producer review / audit compound commands ─────────── if (command.startsWith('!!wl reviewed')) { - return resolveAndRouteCommand(command, state, onCommand, model); + return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh); } if (command.includes('&& wl audit-set')) { - return resolveAndRouteCommand(command, state, onCommand, model); + return resolveAndRouteCommand(command, state, onCommand, model, openPane, onRefresh); } // Unknown command — not handled @@ -2736,6 +3602,12 @@ export async function resolvePodcastTarget( * @param onCommand - Optional callback to receive resolved commands * @param codeFreezeActive - Whether the project is in Code Freeze (implement * commands are blocked). Defaults to false. + * @param model - Optional model for the routed command. + * @param onDowntimeToggle - Optional callback for the internal + * `/downtime toggle` command (see {@link dispatchChordCommand}). + * @param openPane - Optional open-pane flag (WL-0MSJLD1I70045ZUL): `false` + * runs the command in the background (no pane); `undefined`/`true` open a + * pane as today. Passed to onCommand only when explicitly set. * @returns 'dispatched' if handled by dispatchChordCommand, * 'callback' if passed to onCommand, * 'noop' if skipped (no item + requirement), @@ -2744,9 +3616,12 @@ export async function resolvePodcastTarget( export function executeResolvedCommand( command: string, state: WorkItemListState, - onCommand?: (command: string, model?: string) => void, + onCommand?: (command: string, model?: string, openPane?: boolean, onRefresh?: () => Promise) => void, codeFreezeActive = false, model?: string, + onDowntimeToggle?: () => void, + openPane?: boolean, + onRefresh?: () => Promise, ): ExecuteResult { // Code Freeze guard: never route implement commands while frozen. // This runs BEFORE dispatchChordCommand so no pane spawn, claim, or @@ -2755,9 +3630,9 @@ export function executeResolvedCommand( return 'blocked'; } - // Try dispatchChordCommand first — handles /wl, /skill:, /intake, /plan, - // !!wl reviewed, and compound audit commands - if (dispatchChordCommand(command, state, onCommand, model)) { + // Try dispatchChordCommand first — handles /wl, /downtime, /skill:, + // /intake, /plan, !!wl reviewed, and compound audit commands + if (dispatchChordCommand(command, state, onCommand, model, onDowntimeToggle, openPane, onRefresh)) { return 'dispatched'; } @@ -2766,11 +3641,10 @@ export function executeResolvedCommand( let itemId: string | undefined; if (resolvedCommand.includes('')) { - const flat = state.getFlattenedItems(); - const idx = state.selectedIndex; - if (idx >= 0 && idx < flat.length) { - resolvedCommand = resolvedCommand.replace(//g, flat[idx].id); - itemId = flat[idx].id; + const selected = state.getSelectedItem(); + if (selected) { + resolvedCommand = resolvedCommand.replace(//g, selected.id); + itemId = selected.id; } else { // No item selected and command requires — graceful no-op return 'noop'; @@ -2783,7 +3657,20 @@ export function executeResolvedCommand( logCommandForItem(resolvedCommand, itemId); if (onCommand) { - onCommand(resolvedCommand, model); + // The openPane flag is passed only when explicitly set (false): an + // undefined third arg keeps the 2-arg call identical to today's + // dispatch, so shortcuts without open_pane are byte-compatible + // (WL-0MSJLD1I70045ZUL). The onRefresh hook is appended only for + // background (no-pane) dispatches (openPane === false) so they can + // trigger a refresh when the child exits (WL-0MT1KB70U0012X6T); + // pane-opening paths keep their existing arity. + if (openPane === undefined) { + onCommand(resolvedCommand, model); + } else if (onRefresh) { + onCommand(resolvedCommand, model, openPane, onRefresh); + } else { + onCommand(resolvedCommand, model, openPane); + } } return 'callback'; } @@ -2805,7 +3692,7 @@ export async function runWorklistTui( fetcher: () => Promise, initialItems?: WorkItem[], shortcutRegistry?: { lookupChord: Function; getChordByLeader: Function; getChordByPrefix: Function; getChordEntries: Function } | ShortcutRegistry | undefined, - options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; showIcons?: boolean; getShowIcons?: () => boolean; onCommand?: (command: string, model?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number; mergeAgentStates?: (items: WorkItem[]) => Promise }, + options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; showIcons?: boolean; getShowIcons?: () => boolean; onCommand?: (command: string, model?: string, openPane?: boolean, onRefresh?: () => Promise) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number; mergeAgentStates?: (items: WorkItem[]) => Promise; subscriber?: HerdrEventSubscriber | null; agentTracker?: AgentTracker | null; onDowntimeToggle?: () => void; modeSwitchWorker?: ModeSwitchWorker; modeSwitchPollIntervalMs?: number; modeSwitchEnabled?: boolean; onRefresh?: () => Promise }, ): Promise { const opts = { autoRefresh: options?.autoRefresh ?? true, @@ -2821,6 +3708,13 @@ export async function runWorklistTui( downtimeWorker: options?.downtimeWorker, downtimePollIntervalMs: options?.downtimePollIntervalMs ?? DEFAULT_DOWNTIME_POLL_INTERVAL_MS, mergeAgentStates: options?.mergeAgentStates, + subscriber: options?.subscriber ?? null, + agentTracker: options?.agentTracker ?? null, + onDowntimeToggle: options?.onDowntimeToggle, + modeSwitchWorker: options?.modeSwitchWorker, + modeSwitchPollIntervalMs: options?.modeSwitchPollIntervalMs ?? 10_000, + modeSwitchEnabled: options?.modeSwitchEnabled ?? true, + onRefresh: options?.onRefresh, }; let termSize = getTermSize(); @@ -2901,15 +3795,161 @@ export async function runWorklistTui( // Updated by the gate check on each timer tick; fail-open defaults to false. let panePaused = false; + // ── Event-driven window status (WL-0MSHB7DHO004RHBJ) ────────────── + // The herdr event subscriber (when available) replaces the polling paths: + // - `pane_focused` for the CURRENT pane → immediate visibility update + // + refresh (replaces the 2s resume-poll for hidden→visible). + // - `pane_agent_status_changed` / `pane_agent_detected` / `pane_closed` / + // `pane_exited` → immediate icon updates (replaces the agent-list poll). + // Polling stays the fail-open fallback whenever events are unavailable. + const subscriber = opts.subscriber; + const agentTracker = opts.agentTracker; + + /** True while the event subscription is healthy (drives resume-poll gating). */ + let eventsActive = false; + + /** + * Sync the subscriber's per-pane subscriptions with the tracker's pane + * set (from the shared `.worklog/agent-panes.json`). Per-pane + * `pane.agent_status_changed` subscriptions must exist for every tracked + * pane so status events are received (AC1/AC3). Late-spawned agents are + * covered by `pane_agent_detected` + re-reading the shared file. + * Best-effort: subscription failures never break the event loop. + */ + const syncPaneSubscriptions = async (): Promise => { + if (!subscriber || !agentTracker) return; + try { + const current = new Set(subscriber.getTrackedPaneIds()); + for (const entry of agentTracker.snapshot()) { + if (!current.has(entry.paneId)) { + await subscriber.addPaneSubscription(entry.paneId); + } + } + } catch { + // Fail-open: a subscription sync failure keeps polling as fallback. + } + }; + + /** + * Re-apply the tracker's cached states to the live items and re-render + * (coalesced per microtask so event storms do not thrash the TUI). + */ + let agentEventRenderPending = false; + const scheduleAgentEventRender = (): void => { + if (agentEventRenderPending) return; + agentEventRenderPending = true; + Promise.resolve().then(() => { + agentEventRenderPending = false; + try { + if (agentTracker) { + mergeAgentStatesCached(state.items, agentTracker); + } + render(); + } catch { + // Fail-open: render failures never crash the TUI. + } + }); + }; + + if (subscriber) { + subscriber.setCallbacks({ + // A pane_focused event for the CURRENT pane means its tab is focused + // (a pane cannot hold focus while its tab is hidden) → visible. + // Applies the event value to the gate (no `herdr tab get` exec), + // clears the paused indicator, and refreshes immediately — replacing + // the 2s resume-poll for the hidden → visible transition. + onPaneFocused: (data) => { + const currentPaneId = process.env.HERDR_PANE_ID; + if (!currentPaneId || data.pane_id !== currentPaneId) return; + if (data.focused === false) { + paneGate.setVisibleFromEvent(false); + panePaused = true; + return; + } + paneGate.setVisibleFromEvent(true); + panePaused = false; + stopResumePoll(); + doRefresh(true); + }, + // Agent-status events update the tracker's cached state map and + // re-render the icons immediately (no `herdr agent list` exec). + onAgentStatusChanged: (data) => { + if (!agentTracker) return; + agentTracker.applyAgentStatusChanged(data.pane_id, data.agent_status); + scheduleAgentEventRender(); + }, + // A new agent pane appeared (possibly from another herdr instance): + // re-read the shared file for late associations and add the pane to + // the per-pane subscription set (AC3). + onAgentDetected: (data) => { + if (!agentTracker || !subscriber) return; + const grew = agentTracker.applyAgentDetected(); + if (grew) void syncPaneSubscriptions(); + void subscriber.addPaneSubscription(data.pane_id).catch(() => {}); + scheduleAgentEventRender(); + }, + // A pane closed/exited: prune its associations so its icons disappear + // immediately and drop the per-pane subscription (best-effort). + onPaneClosed: (data) => { + if (!agentTracker || !subscriber) return; + agentTracker.applyPaneGone(data.pane_id); + void subscriber.removePaneSubscription(data.pane_id).catch(() => {}); + scheduleAgentEventRender(); + }, + onPaneExited: (data) => { + if (!agentTracker || !subscriber) return; + agentTracker.applyPaneGone(data.pane_id); + void subscriber.removePaneSubscription(data.pane_id).catch(() => {}); + scheduleAgentEventRender(); + }, + onError: () => { + // Socket errors are already handled by the subscriber's reconnect + // logic; no TUI action needed (fail-open). + }, + }); + + // Start the subscription (fail-open: an unreachable socket keeps the + // existing polling cadence and the resume-poll fallback). + subscriber.connect().then((result) => { + eventsActive = result.type === 'subscribed'; + if (eventsActive) { + // The event path now handles hidden → visible transitions; stop the + // resume-poll fallback (it may have started during the connect + // window while eventsActive was still false). + stopResumePoll(); + void syncPaneSubscriptions(); + } + }); + } + + // Check if we're in raw mode (stdin is a TTY) const isInteractive = process.stdin.isTTY; let rawMode = false; + // SGR mouse tracking is enabled by default (WL-0MT0AP2LR000JFWN). This is + // a toggle state so the user can temporarily disable mouse tracking and use + // the terminal's native text-selection to copy content from the terminal. + let mouseTrackingEnabled = true; + + // Alt+m toggle handler (WL-0MT0AP2LR000JFWN): toggles mouse tracking on/off + // by emitting the matching enable/disable ANSI sequences to the terminal. + const toggleMouseTracking = (): void => { + mouseTrackingEnabled = !mouseTrackingEnabled; + process.stdout.write( + mouseTrackingEnabled ? ANSI.mouseEnable : ANSI.mouseDisable, + ); + }; + if (isInteractive) { try { process.stdin.setRawMode?.(true); process.stdin.resume(); rawMode = true; + // SGR mouse tracking (WL-0MSGHM5BQ0096BNJ AC1): emit the enable + // sequences when raw mode is entered so the terminal starts + // delivering mouse events to stdin. + process.stdout.write(ANSI.mouseEnable); } catch { // Not a TTY, use line-buffered mode } @@ -2926,12 +3966,24 @@ export async function runWorklistTui( } catch { // ignore } + // Disable SGR mouse tracking on exit (AC1) — only when raw mode was + // entered. + process.stdout.write(ANSI.mouseDisable); } process.stdin.pause(); process.stdout.write(ANSI.showCursor); process.stdout.write(ANSI.reset); }; + // Event-subscriber teardown on TUI exit (WL-0MSHB7DHO004RHBJ F6): close + // the socket and cancel reconnect timers so no connection leaks when the + // plugin pane exits. Fail-open: a close failure is swallowed. + const closeEventSubscriber = (): void => { + if (subscriber) { + subscriber.close().catch(() => {}); + } + }; + // Data reading callback /** @@ -2985,7 +4037,7 @@ export async function runWorklistTui( } }; const [newItems] = await Promise.all([ - fetchItemsForView(state.activeFilter, fetcher), + fetchItemsForView(state.activeFilter, state.activePriorityFilter, fetcher), ...expanded.map(fetchExpandedChildren), ]); const oldLen = state.items.length; @@ -3049,21 +4101,33 @@ export async function runWorklistTui( } }; + // Wire the completion-triggered refresh hook (WL-0MT1KB70U0012X6T): + // background (no-pane) shortcut dispatches receive this callback so their + // child's 'exit' event can trigger a refresh of the current view. The + // callback is fire-and-forget and non-blocking — doRefresh has its own + // single-flight guard and never blocks the TUI event loop or the + // dispatched command's own execution. Scoped to this TUI instance (no + // global watcher, no cross-instance effects). + opts.onRefresh = opts.onRefresh ?? (() => doRefresh(false)); + /** * True when the resolved command is a `/wl` view command — a stage filter - * (`/wl `, shorthand alias or canonical name) or the clear-filter - * `/wl` with no arguments. Used after dispatch to trigger a view refetch: - * filtered views show every root item in that stage matching the stage's - * status rule (`wl list --status --stage --root-only`; - * see STAGE_STATUS in fetcher.ts) — most stages show `open`-status items - * only, while the in_review stage additionally includes `completed` and + * (`/wl `, shorthand alias or canonical name), a priority filter + * (`/wl --priority

`, canonical name), or the clear-filter `/wl` with + * no arguments. Used after dispatch to trigger a view refetch: filtered + * views show every root item matching the filter's rule (`wl list --status + * --stage --root-only` / `--priority

`; see + * STAGE_STATUS in fetcher.ts) — most stages show `open`-status items only, + * while the in_review stage additionally includes `completed` and * `in-progress` items (WL-0MSKCRX730052IIW); clearing the filter restores * the default view (WL-0MSGSE15000746F7). */ const isWlViewCommand = (cmd: string): boolean => { if (/^\/wl\s*$/.test(cmd)) return true; - const m = cmd.match(/^\/wl\s+(\S+)$/); - return m !== null && STAGE_MAP[m[1]] !== undefined; + const stageMatch = cmd.match(/^\/wl\s+(\S+)$/); + if (stageMatch !== null && STAGE_MAP[stageMatch[1]] !== undefined) return true; + const priorityMatch = cmd.match(/^\/wl\s+--priority\s+(\S+)$/); + return priorityMatch !== null && PRIORITY_MAP[priorityMatch[1]] !== undefined; }; // Run `wl sync` and surface the outcome as a toast so sync status is @@ -3118,7 +4182,7 @@ export async function runWorklistTui( if (frozen) { codeFreezeActive = true; } - const result = executeResolvedCommand(SHIP_IT_COMMAND, state, opts.onCommand, frozen, model); + const result = executeResolvedCommand(SHIP_IT_COMMAND, state, opts.onCommand, frozen, model, opts.onDowntimeToggle, undefined, opts.onRefresh); if (result === 'noop') { showToast('Skipped', { body: `${SHIP_IT_COMMAND} (no item)` }); } else { @@ -3137,9 +4201,43 @@ export async function runWorklistTui( render(); }; + // Double-click state tracker — persists across onData calls for the TUI + // lifetime. Updated on every left-press so handleMouseInput can detect + // double-clicks within {@link DOUBLE_CLICK_WINDOW_MS}. (WL-0MSGHM5BQ0096BNJ AC3) + let clickState: MouseClickState = { lastClick: null, now: 0 }; + + // ── Mouse event dispatch (WL-0MSGHM5BQ0096BNJ AC2–AC6) ───────── + // handleMouseInput parses SGR mouse events from the raw chunk BEFORE the + // keypress path and dispatches them (click select, double-click open, + // wheel/scroll, filter tap). Mouse-shaped sequences are fully consumed; + // plain keys and foreign escapes fall through to the keyboard handler + // untouched (AC6). See the fix note in handleMouseInput for the + // single-click-select vs double-click-open ordering (WL-0MSZBWT500034E74). + const dispatchMouse = (key: string): boolean => + handleMouseInput(state, key, termSize, clickState); + const onData = async (chunk: Buffer): Promise => { const key = chunk.toString(); + // Alt+m toggle shortcut (WL-0MT0AP2LR000JFWN): always available, + // even in modal states. Toggles mouse tracking on/off so the user + // can use the terminal's native text-selection to copy content. + if (key === '\x1bm') { + toggleMouseTracking(); + render(); + return; + } + + // Try mouse event dispatch first — only runs when the pane is not in a + // modal state (code-freeze notice, form, ship-it dialog), matching the + // keyboard path. Mouse input is ignored during those modal states. + if (!codeFreezeNotice && formState === null && shipItDialog === null) { + if (dispatchMouse(key)) { + render(); + return; + } + } + // ── Code Freeze notice handling ───────────────────────────── // While the notice is showing, Esc/Enter/q dismiss it and return to the // list. All other keys are consumed (the notice is modal). The blocked @@ -3155,7 +4253,7 @@ export async function runWorklistTui( // ── Form mode handling ────────────────────────────────────── if (formState !== null) { const result = formState.handleInput(key); - if (result === 'submitted') { + if (result.type === 'submitted') { const resolved = formState.getResult(); formState = null; state.mode = preFormMode; @@ -3164,10 +4262,35 @@ export async function runWorklistTui( // every form submission spawns TWO agent panes (WL-0MSAL0RN1009YNJ7). showToast('Sent', { body: resolved.length > 60 ? resolved.substring(0, 57) + '...' : resolved }); render(); - } else if (result === 'cancelled') { + } else if (result.type === 'cancelled') { formState = null; state.mode = preFormMode; render(); + } else if (result.type === 'paste') { + // Ctrl+V: read the OS clipboard asynchronously (never freezes the + // TUI), then commit the text verbatim or surface a graceful failure + // and keep the form open (WL-0MSW6KCTA0092DCV). + const read = await readFromClipboard(); + if (read.success && read.text !== undefined) { + formState.pasteText(read.text); + } else { + const reason = read.error ?? 'Clipboard read failed'; + formState.notifyPasteFailed(reason); + showToast('Paste failed', { body: reason }); + } + render(); + } else if (result.type === 'cut') { + // Ctrl+X: copy the active field value to the OS clipboard + // asynchronously and surface feedback. On failure the field value is + // restored so no data is lost (WL-0MSW6KCTA0092DCV). + const copy = await writeToClipboard(result.text); + if (copy.success) { + showToast('Copied', { body: result.text.length > 40 ? result.text.substring(0, 37) + '...' : (result.text || '(empty)') }); + } else { + formState.pasteText(result.text); + showToast('Copy failed', { body: copy.error ?? 'Clipboard unavailable' }); + } + render(); } else { render(); } @@ -3212,8 +4335,12 @@ export async function runWorklistTui( // Chord resolved — execute the command let command = chordState.resolvedCommand; const model = chordState.resolvedModel; + // openPane: undefined (default) = open a pane; false = background, + // no pane (WL-0MSJLD1I70045ZUL). Cleared after execution. + const openPane = chordState.resolvedOpenPane; chordState.resolvedCommand = null; chordState.resolvedModel = null; + chordState.resolvedOpenPane = undefined; if (command) { // Podcast-progression markers (// // /) are resolved from the selected @@ -3372,10 +4499,9 @@ export async function runWorklistTui( // Handle resolution let finalCmd = resolved; if (finalCmd.includes('')) { - const flat = state.getFlattenedItems(); - const idx = state.selectedIndex; - if (idx >= 0 && idx < flat.length) { - finalCmd = finalCmd.replace(//g, flat[idx].id); + const selected = state.getSelectedItem(); + if (selected) { + finalCmd = finalCmd.replace(//g, selected.id); } } // Record the submitted command against its work item before @@ -3409,7 +4535,7 @@ export async function runWorklistTui( if (frozen) { codeFreezeActive = true; } - const result = executeResolvedCommand(command, state, opts.onCommand, frozen, model ?? undefined); + const result = executeResolvedCommand(command, state, opts.onCommand, frozen, model ?? undefined, opts.onDowntimeToggle, openPane, opts.onRefresh); if (result === 'blocked') { // Code Freeze — show the notice dialog; the command was NOT // routed, no pane spawned, no work item claimed. @@ -3452,7 +4578,20 @@ export async function runWorklistTui( // Save mode before processing — selectItem() changes mode to 'detail', // but we need to distinguish "just entered detail" from "confirm in detail". const prevMode = state.mode; - const action = handleKeypress(state, key, termSize); + // Compute the dynamic panel height so metaScrollDown clamps correctly + // (WL-0MSQ44MDX008U69J). The value is used only for the m/M scroll + // clamping — the actual rendering is done by createListRenderer. + const displayItems = state.mode === 'list' + ? state.getDisplayRows() + : state.items; + const bannerCount = (codeFreezeActive ? 1 : 0) + (codeFreezeAmbiguous ? 1 : 0); + const { panelHeight } = computeDynamicLayout( + displayItems, + state.scrollOffset, + bannerCount, + termSize, + ); + const action = handleKeypress(state, key, termSize, panelHeight); // If key wasn't handled as navigation and chord registry exists, // check if it's a shortcut or part of a chord sequence @@ -3468,6 +4607,9 @@ export async function runWorklistTui( if (singleEntry) { let singleCmd = singleEntry.command; const singleModel = singleEntry.model ?? undefined; + // openPane: undefined (default) = open a pane; false = background, + // no pane (WL-0MSJLD1I70045ZUL). + const singleOpenPane = singleEntry.openPane ?? undefined; // Podcast-progression markers (// // /) are resolved from the selected // item's context BEFORE the generic modal-form check so they never @@ -3508,10 +4650,9 @@ export async function runWorklistTui( (resolved: string) => { let finalCmd = resolved; if (finalCmd.includes('')) { - const flat = state.getFlattenedItems(); - const idx = state.selectedIndex; - if (idx >= 0 && idx < flat.length) { - finalCmd = finalCmd.replace(//g, flat[idx].id); + const selected = state.getSelectedItem(); + if (selected) { + finalCmd = finalCmd.replace(//g, selected.id); } } // Record the submitted command against its work item before @@ -3543,7 +4684,7 @@ export async function runWorklistTui( if (frozen) { codeFreezeActive = true; } - const result = executeResolvedCommand(singleCmd, state, opts.onCommand, frozen, singleModel); + const result = executeResolvedCommand(singleCmd, state, opts.onCommand, frozen, singleModel, opts.onDowntimeToggle, singleOpenPane, opts.onRefresh); if (result === 'blocked') { // Code Freeze — show the notice dialog; no pane spawned. codeFreezeNotice = true; @@ -3597,9 +4738,8 @@ export async function runWorklistTui( } if (action === 'toggle-expand' && state.mode === 'list') { - const flat = state.getFlattenedItems(); - if (state.selectedIndex < flat.length) { - const selected = flat[state.selectedIndex]; + const selected = state.getSelectedItem(); + if (selected) { // If children data not yet loaded, fetch them on demand if (selected.childCount && selected.childCount > 0 && (!selected.children || selected.children.length === 0)) { render(); // immediate render while fetch is pending @@ -3693,8 +4833,10 @@ export async function runWorklistTui( return; } - // Use flattened items for hierarchy display - const displayItems = state.mode === 'list' ? state.getFlattenedItems() : state.items; + // Use the display-rows model for list rendering (headings + items, + // WL-0MSL5MPSZ003TG94). Heading rows have no stage/issueType, so + // shortcut hints fall back to defaults for heading selections. + const displayItems = state.mode === 'list' ? state.getDisplayRows() : state.items; // ── Compute stage-appropriate shortcut hints for the footer ── let dynamicHints = ''; @@ -3704,8 +4846,12 @@ export async function runWorklistTui( const selItem = displayItems.length > 0 && selIdx < displayItems.length ? displayItems[selIdx] : undefined; - const selStage = selItem?.stage; - const selIssueType = selItem?.issueType; + // Narrow the display-row union: heading rows carry no work-item + // fields (stage/issueType belong to WorkItem rows only). + const selStage = + selItem !== undefined && !isHeadingRow(selItem) ? selItem.stage : undefined; + const selIssueType = + selItem !== undefined && !isHeadingRow(selItem) ? selItem.issueType : undefined; const isEmpty = displayItems.length === 0; const relevantEntries = reg.getEntriesForStage(selStage, codeFreezeActive, selIssueType) @@ -3744,6 +4890,17 @@ export async function runWorklistTui( } } + // Mouse-tracking toggle hint (WL-0MT0AP2LR000JFWN): appends the Alt+m + // toggle to the footer shortcut hints so the user knows how to switch + // between mouse interaction and native text-selection (drag-select). + // Shown whenever help text is enabled. + if (opts.getShowHelpText()) { + const mouseHint = mouseTrackingEnabled + ? `alt+m mouse on` + : `alt+m mouse off`; + dynamicHints = dynamicHints ? `${dynamicHints} ${mouseHint}` : ` ${mouseHint}`; + } + // Look up the selected item's last recorded command for the metadata // panel (only shown for in_progress items). Best effort: a missing or // unreadable log yields undefined and the panel falls back gracefully. @@ -3764,7 +4921,7 @@ export async function runWorklistTui( state.selectedIndex, state.scrollOffset, termSize, - state.activeFilter, + state.activeFilterLabel, state.mode, state.detailItem, totalActionableCount, @@ -3846,6 +5003,14 @@ export async function runWorklistTui( scheduler.setDisabled('resume-poll', false); }; + /** + * Whether the resume-poll fallback should run. The resume-poll is only + * the FALLBACK for the hidden → visible transition — when the event + * subscription is active, `pane_focused` events handle it instead, so the + * polling task stays disabled (WL-0MSHB7DHO004RHBJ F3). + */ + const resumePollEnabled = (): boolean => !eventsActive; + // Auto-refresh task — fetches fresh items on an interval. NOTE: this task // does NOT run `wl sync`; the dedicated sync task below is the single sync // source. Running sync from both tasks caused a double-spawn per pane that @@ -3860,7 +5025,7 @@ export async function runWorklistTui( run: async () => { if (!(await paneGate.visible())) { panePaused = true; - startResumePoll(); + if (resumePollEnabled()) startResumePoll(); return; } stopResumePoll(); @@ -3890,7 +5055,7 @@ export async function runWorklistTui( run: async () => { if (!(await paneGate.visible())) { panePaused = true; - startResumePoll(); + if (resumePollEnabled()) startResumePoll(); return; } stopResumePoll(); @@ -3938,6 +5103,15 @@ export async function runWorklistTui( scheduler.addTask({ id: 'downtime', intervalMs: opts.downtimePollIntervalMs, + // Probe jitter (WL-0MSSRED76008LGB6): the effective poll interval is + // jittered ±50% of the configured downtimePollIntervalMs per tick + // (random, injectable via the registry's RNG) so instances with + // identical configuration do not probe in lockstep — the jittered + // interval is recomputed fresh on EVERY reschedule. Fallback: when + // the worker's registry is unavailable, the static interval stands. + getIntervalMs: opts.downtimeWorker + ? () => opts.downtimeWorker!.jitterPollIntervalMs(opts.downtimePollIntervalMs) + : undefined, singleFlight: true, runTimeoutMs: DOWNTIME_RUN_TIMEOUT_MS, run: async () => { @@ -3946,11 +5120,45 @@ export async function runWorklistTui( }); } + // Mode-switch worker task — polls the llama-proxy for idle state and, + // after the configured idle threshold, switches from fast (cloud) to + // cheap (local) mode (parent WL-0MSN3FWV5008KQE9). Pattern-matched on the + // downtime task: single-flight + runTimeoutMs watchdog (a hung tick can + // never wedge the task), visibility-independent (runs while the worklist + // pane is open). The task is only registered when the feature is enabled + // at startup (modeSwitchEnabled); the run ALSO re-reads settings every + // tick so a disable or threshold change applies without a plugin restart. + // Probe jitter is intentionally NOT applied: each instance's idle clock + // resets at construction, so panes' cheap-switch timings are already + // naturally desynchronized (unlike the downtime poller's shared registry). + const modeSwitchEnabledAtStartup = + loadSettings().modeSwitchEnabled ?? opts.modeSwitchEnabled; + if (opts.modeSwitchWorker && modeSwitchEnabledAtStartup) { + scheduler.addTask({ + id: 'mode-switch', + intervalMs: opts.modeSwitchPollIntervalMs, + singleFlight: true, + runTimeoutMs: MODE_SWITCH_RUN_TIMEOUT_MS, + run: async () => { + // Re-read settings every tick so modeSwitchEnabled / idle threshold + // changes apply without a plugin restart (matching downtime config). + const s = loadSettings(); + await opts.modeSwitchWorker?.tick({ + enabled: s.modeSwitchEnabled ?? opts.modeSwitchEnabled, + idleThresholdMs: s.modeSwitchIdleThresholdMs ?? DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS, + proxyUrl: s.downtimeProxyUrl, + proxyStatus: null, // worker fetches its own proxy status + }); + }, + }); + } + scheduler.start(); // Cleanup on promise resolution promise.finally(() => { scheduler.stop(); + closeEventSubscriber(); cleanup(); process.stdout.removeListener('resize', onResize); process.stdin.removeListener('data', onData); diff --git a/packages/shared/README.md b/packages/shared/README.md index 87a511c1e..68052082d 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -118,3 +118,16 @@ and never re-timestamp items (WL-0MSORD6HC005QVZX). The incoming normalized content is still persisted — only `updatedAt` is preserved. All other fields (tags, status, priority, …) use strict comparison; genuine content changes still bump `updatedAt`. + +### Metadata flag flips do not bump `updatedAt` (WL-0MSN6ZCTN0027U2R) + +In `update()`, `needsProducerReview` is treated as **metadata**, not content: +a flag-only flip is persisted (written to the store) but does **not** bump +`updatedAt`. Content fields (title, description, status, stage, priority, +sortIndex, parentId, tags, assignee, issueType, risk, effort) still bump the +timestamp exactly as before, and a combined content+flag update bumps too. + +Rationale: the flag is a review-state signal, not item content. Bumping +`updatedAt` on a flag flip made previously valid audits look stale to herdr's +`isAuditFresh` predicate, degrading the TUI icon and re-dispatching +redundant `/skill:audit` runs for unchanged content. diff --git a/packages/shared/package.json b/packages/shared/package.json index 83e638607..644bf5dcb 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -21,6 +21,10 @@ "./lease-release": { "types": "./dist/lease-release.d.ts", "import": "./dist/lease-release.js" + }, + "./icons": { + "types": "./dist/icons.d.ts", + "import": "./dist/icons.js" } }, "files": [ diff --git a/packages/shared/src/database.test.ts b/packages/shared/src/database.test.ts index a8bb4fa81..7dcb7e70b 100644 --- a/packages/shared/src/database.test.ts +++ b/packages/shared/src/database.test.ts @@ -22,7 +22,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { WorklogDatabase } from './database.js'; -import { WorkItem } from './types.js'; +import { WorkItem, Comment } from './types.js'; const FIXED_TS = '2026-01-01T00:00:00.000Z'; @@ -269,3 +269,287 @@ describe('upsertItems() no-op guard (WL-0MSORD6HC005QVZX)', () => { expect(stored?.updatedAt).not.toBe(FIXED_TS); }); }); + +// ── WL-0MSN6ZCTN0027U2R: needsProducerReview flag flips must not bump updatedAt ── + +describe('update() needsProducerReview flag-only does not bump updatedAt (WL-0MSN6ZCTN0027U2R)', () => { + function seed(): WorkItem { + const item = makeItem({ id: 'WI-NPR1', title: 'Flag test', description: 'has content', needsProducerReview: false }); + db.import([item]); + return item; + } + + it('flips needsProducerReview true but preserves updatedAt', () => { + const seeded = seed(); + const updated = db.update(seeded.id, { needsProducerReview: true }); + expect(updated?.needsProducerReview).toBe(true); + expect(updated?.updatedAt).toBe(FIXED_TS); + // Verify the change is persisted to the database. + expect(db.get(seeded.id)?.needsProducerReview).toBe(true); + expect(db.get(seeded.id)?.updatedAt).toBe(FIXED_TS); + }); + + it('flips needsProducerReview false but preserves updatedAt', () => { + const seeded = seed(); + // Flip to true first so we can flip back. + db.update(seeded.id, { needsProducerReview: true }); + const updated = db.update(seeded.id, { needsProducerReview: false }); + expect(updated?.needsProducerReview).toBe(false); + expect(updated?.updatedAt).toBe(FIXED_TS); + expect(db.get(seeded.id)?.needsProducerReview).toBe(false); + }); + + it('content field + needsProducerReview flag bumps updatedAt (combined update)', () => { + const seeded = seed(); + const updated = db.update(seeded.id, { title: 'Updated title', needsProducerReview: true }); + expect(updated?.title).toBe('Updated title'); + expect(updated?.needsProducerReview).toBe(true); + expect(updated?.updatedAt).not.toBe(FIXED_TS); + expect(db.get(seeded.id)?.updatedAt).not.toBe(FIXED_TS); + }); + + it('no-op update (needsProducerReview already true) preserves updatedAt', () => { + const seeded = seed(); + db.update(seeded.id, { needsProducerReview: true }); + const updated = db.update(seeded.id, { needsProducerReview: true }); + expect(updated?.updatedAt).toBe(FIXED_TS); + }); + + it('no-op update (needsProducerReview already false) preserves updatedAt', () => { + const seeded = seed(); + const updated = db.update(seeded.id, { needsProducerReview: false }); + expect(updated?.updatedAt).toBe(FIXED_TS); + }); + + it('content-only update still bumps updatedAt (regression)', () => { + const seeded = seed(); + const updated = db.update(seeded.id, { title: 'New title' }); + expect(updated?.title).toBe('New title'); + expect(updated?.updatedAt).not.toBe(FIXED_TS); + }); +}); + +// ── WL-0MT3FLZJ20076X9B: riskOrdinal / effortOrdinal verbose matching ── + +describe('riskOrdinal handles verbose agent-produced descriptions (WL-0MT3FLZJ20076X9B)', () => { + // Access private method via any-cast (consistent with the test file's pattern). + // db is (re)initialized per-test in the top-level beforeEach; grab it lazily. + function ordinal(): any { + return (db as any).riskOrdinal.bind(db); + } + + it('recognises plain risk levels', () => { + const o = ordinal(); + expect(o('low')).toBe(1); + expect(o('medium')).toBe(2); + expect(o('high')).toBe(3); + expect(o('severe')).toBe(4); + expect(o('critical')).toBe(4); + }); + + it('recognises verbose risk descriptions with em-dash', () => { + const o = ordinal(); + expect(o('Medium — NVIDIA driver changes can affect GPU functionality')).toBe(2); + expect(o('High — Critical security vulnerability in authentication module')).toBe(3); + expect(o('Low — Minor cosmetic issue in settings page')).toBe(1); + }); + + it('recognises verbose risk with en-dash and colon', () => { + const o = ordinal(); + expect(o('Severe — System stability impact')).toBe(4); + expect(o('Critical: Data loss possible')).toBe(4); + }); + + it('handles case-insensitive matching', () => { + const o = ordinal(); + expect(o('MEDIUM')).toBe(2); + expect(o('Medium — test')).toBe(2); + expect(o(' mEdIuM ')).toBe(2); + }); + + it('returns null for unknown / unset values (fail-closed)', () => { + const o = ordinal(); + expect(o(null)).toBe(null); + expect(o(undefined)).toBe(null); + expect(o('')).toBe(null); + expect(o('unknown')).toBe(null); + expect(o('Medium — ')).toBe(2); // keyword still extracted + }); +}); + +describe('effortOrdinal handles verbose agent-produced descriptions (WL-0MT3FLZJ20076X9B)', () => { + function ordinal(): any { + return (db as any).effortOrdinal.bind(db); + } + + it('recognises plain effort levels and short spellings', () => { + const o = ordinal(); + expect(o('xs')).toBe(1); + expect(o('s')).toBe(2); + expect(o('m')).toBe(3); + expect(o('l')).toBe(4); + expect(o('xl')).toBe(5); + expect(o('extra small')).toBe(1); + expect(o('small')).toBe(2); + expect(o('medium')).toBe(3); + expect(o('large')).toBe(4); + expect(o('extra large')).toBe(5); + }); + + it('recognises verbose effort descriptions with word-boundary fallback', () => { + const o = ordinal(); + expect(o('1–4 hours — Small. Diagnostic investigation')).toBe(2); + expect(o('2–6 hours — Medium. Refactoring needed')).toBe(3); + expect(o('1–2 days — Large. Major rewrite')).toBe(4); + expect(o('2–6 hours — Small. Diagnostic')).toBe(2); + }); + + it('short spellings work in verbose strings', () => { + const o = ordinal(); + expect(o('1–4 hours — S. Small task')).toBe(2); + expect(o('Quick — M. Medium complexity')).toBe(3); + }); + + it('long forms still work (regression)', () => { + const o = ordinal(); + expect(o('Extra Small')).toBe(1); + expect(o('extra small')).toBe(1); + expect(o('Extra Large')).toBe(5); + expect(o('extra large')).toBe(5); + }); + + it('handles case-insensitive matching', () => { + const o = ordinal(); + expect(o('MEDIUM')).toBe(3); + expect(o('Small — test')).toBe(2); + expect(o(' XL ')).toBe(5); + }); + + it('returns null for unknown / unset values (fail-closed)', () => { + const o = ordinal(); + expect(o(null)).toBe(null); + expect(o(undefined)).toBe(null); + expect(o('')).toBe(null); + expect(o('unknown')).toBe(null); + }); +}); + +describe('matchesRiskEffort filters correctly with verbose descriptions (WL-0MT3FLZJ20076X9B)', () => { + function matches(): any { + return (db as any).matchesRiskEffort.bind(db); + } + // The item fields below intentionally carry lowercase/verbose/unknown + // VALUES (test-data realism): worklog items store raw strings and + // `matchesRiskEffort` normalizes them. `Partial` types risk/ + // effort as the Capitalized unions, so widen the helper's accepted + // fields and delegate via an explicit cast (build-greening fix, + // WL-0MT4TBXMB00SKZZZ — keeps the runtime assertions untouched). + function item( + overrides: Omit, 'risk' | 'effort'> & { risk?: string; effort?: string } = {}, + ): WorkItem { + return makeFactoryItem(overrides as Partial); + } + + it('at-most medium risk filter accepts plain medium and verbose medium', () => { + const m = matches(); + expect(m(item({ risk: 'medium' }), 'medium', undefined)).toBe(true); + expect(m(item({ risk: 'Medium — something' }), 'medium', undefined)).toBe(true); + expect(m(item({ risk: 'low' }), 'medium', undefined)).toBe(true); + }); + + it('at-most medium risk filter rejects high / severe risk', () => { + const m = matches(); + expect(m(item({ risk: 'high' }), 'medium', undefined)).toBe(false); + expect(m(item({ risk: 'Severe — stability' }), 'medium', undefined)).toBe(false); + expect(m(item({ risk: 'Critical: data loss' }), 'medium', undefined)).toBe(false); + }); + + it('at-most medium effort filter accepts plain small and verbose small', () => { + const m = matches(); + expect(m(item({ effort: 'small' }), undefined, 'medium')).toBe(true); + expect(m(item({ effort: '1–4 hours — Small. Investigation' }), undefined, 'medium')).toBe(true); + expect(m(item({ effort: 'extra small' }), undefined, 'medium')).toBe(true); + }); + + it('at-most small effort filter rejects medium / large effort', () => { + const m = matches(); + expect(m(item({ effort: 'medium' }), undefined, 'small')).toBe(false); + expect(m(item({ effort: 'Large. Major rewrite' }), undefined, 'small')).toBe(false); + }); + + it('both filters applied together work correctly', () => { + const m = matches(); + expect(m(item({ risk: 'Medium — something', effort: 'Small — quick fix' }), 'medium', 'medium')).toBe(true); + expect(m(item({ risk: 'High', effort: 'Small' }), 'medium', 'medium')).toBe(false); + expect(m(item({ risk: 'Medium', effort: 'Large' }), 'medium', 'medium')).toBe(false); + }); + + it('unknown risk / effort on item causes fail-closed', () => { + const m = matches(); + expect(m(item({ risk: 'unknown', effort: 'medium' }), 'medium', 'medium')).toBe(false); + expect(m(item({ risk: 'medium', effort: 'unknown' }), 'medium', 'medium')).toBe(false); + }); +}); + +// Helper to build items for matchesRiskEffort tests (avoids circular dependency with makeItem). +function makeFactoryItem(overrides: Partial = {}): WorkItem { + return { + id: 'WI-0001', + title: 'Test item', + description: 'Test description', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: FIXED_TS, + updatedAt: FIXED_TS, + tags: [], + assignee: '', + stage: 'idea', + issueType: 'bug', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + needsProducerReview: false, + ...overrides, + }; +} + +// ── WL-0MT2KYCNB000CYWV: delta pull persists COMMENTS non-destructively ── + +describe('upsertComments() non-destructive merge (WL-0MT2KYCNB000CYWV)', () => { + function makeComment(id: string, itemId: string, text: string): Comment { + return { + id, + workItemId: itemId, + author: 'test@example.com', + comment: text, + createdAt: FIXED_TS, + references: [], + }; + } + + it('keeps local comments absent from the incoming delta and adds new ones', () => { + // Comments carry a FOREIGN KEY to a work item — seed both items first. + db.import([makeItem({ id: 'WI-0001' }), makeItem({ id: 'WI-0002' })]); + const localComment = makeComment('C-1', 'WI-0001', 'local comment'); + db.importComments([localComment]); + + // A delta arrives containing an overlap (C-1 updated) and a brand-new + // comment for a different item—but NOT a comment the local store has. + db.upsertComments([ + { ...localComment, comment: 'local comment updated by remote' }, + makeComment('C-3', 'WI-0002', 'new remote comment'), + ]); + + const all = db.getAllComments(); + // Non-destructive: C-1 kept (converged to the remote value); C-3 added. + expect(all.some(c => c.id === 'C-1' && c.comment === 'local comment updated by remote')).toBe(true); + expect(all.some(c => c.id === 'C-3' && c.workItemId === 'WI-0002')).toBe(true); + // No duplicates after the overlap upsert (INSERT OR REPLACE by id). + expect(all.filter(c => c.id === 'C-1').length).toBe(1); + expect(all.some(c => c.id === 'C-2')).toBe(false); + }); +}); diff --git a/packages/shared/src/database.ts b/packages/shared/src/database.ts index 991ddda6e..67b9069af 100644 --- a/packages/shared/src/database.ts +++ b/packages/shared/src/database.ts @@ -5,8 +5,8 @@ import { randomBytes } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import { WorkItem, WorkItemPriority, CreateWorkItemInput, UpdateWorkItemInput, WorkItemQuery, Comment, CreateCommentInput, UpdateCommentInput, NextWorkItemResult, DependencyEdge, AuditResult, DemotedParent } from './types.js'; -import { SqlitePersistentStore, FtsSearchResult, PersistentStoreServices, PersistentStoreCacheOptions } from './persistent-store.js'; +import { WorkItem, WorkItemPriority, CreateWorkItemInput, UpdateWorkItemInput, WorkItemQuery, Comment, CreateCommentInput, UpdateCommentInput, NextWorkItemResult, DependencyEdge, AuditResult, DemotedParent, RevertedItem } from './types.js'; +import { SqlitePersistentStore, FtsSearchResult, PersistentStoreServices, PersistentStoreCacheOptions, LastExportTimestamps } from './persistent-store.js'; import { normalizeStatusValue } from './status-stage-rules.js'; // ── Injectable service types ──────────────────────────────────────────── @@ -19,6 +19,8 @@ export interface JsonlImportResult { comments: Comment[]; dependencyEdges: DependencyEdge[]; auditResults: AuditResult[]; + /** Sync header kind when the file starts with a sync header (incremental sync). */ + kind?: 'full' | 'delta'; } /** @@ -396,7 +398,25 @@ export class WorklogDatabase { * Export current database state to JSONL for sync operations. * This is used by the sync command before pushing to Git. * The JSONL file should be deleted after successful push (ephemeral pattern). - * + * + * Delta-aware export (WL-0MT2KXPOQ009G026 / WL-0MSAKUBKW006FN8Q): + * + * - `options.mode: 'full'` (default) — export ALL records. + * - `options.mode: 'delta'` — export only records changed after the + * per-type watermarks. If `options.since` is provided it is used as the + * watermark set; otherwise the stored watermarks + * (`getLastExportTimestamps()`) are read automatically. When a type has no + * watermark (no baseline), ALL records of that type are exported for that + * type (full baseline per type). When NO watermarks exist at all, the + * delta degrades to a full export (the design's "no-baseline → full + * export" rule), because a delta with empty watermarks would be empty + * and lose data on the push side. + * + * Exporting NEVER auto-advances the watermarks in any mode — the push path + * advances them only AFTER a successful push (markLastExportTimestamps, + * WL-0MT2KY0RQ008F50Q AC5). A failed push must not advance the baseline + * past data that was never published. + * * @returns The path to the exported JSONL file */ async exportForSync(options?: any): Promise { @@ -405,17 +425,122 @@ export class WorklogDatabase { throw new Error('jsonl services not provided to WorklogDatabase — cannot export for sync'); } - const items = this.store.getAllWorkItems(); - const comments = this.store.getAllComments(); - const dependencyEdges = this.store.getAllDependencyEdges(); - const auditResults = this.store.getAllAuditResults(); - - // Export to JSONL - await jsonlSvc.exportToJsonlAsync(items, comments, this.jsonlPath, dependencyEdges, auditResults, { onProgress: options?.onProgress }); + const mode: 'full' | 'delta' = + options?.mode === 'delta' && process.env.WL_DELTA_EXPORT_DISABLED !== '1' ? 'delta' : 'full'; + + // In delta mode resolve the per-type watermarks: explicit `since` wins, + // otherwise read the stored watermarks (no baseline → full fallback). + let since: LastExportTimestamps | undefined; + if (mode === 'delta') { + const candidate = options?.since ?? this.getLastExportTimestamps(); + const hasAnyWatermark = Boolean(candidate.workitems || candidate.comments || candidate.edges || candidate.audit_results); + if (hasAnyWatermark) { + since = candidate; + } + // No baseline at all — a delta would export nothing. Fall back to full + // so the remote always receives a complete snapshot on first sync. + } + + // Capture the resolved watermarks so the filter callbacks close over a + // definite value (narrowing does not survive into the closures). + const wItems = since?.workitems; + const wComments = since?.comments; + const wEdges = since?.edges; + const wAudits = since?.audit_results; + + const items = wItems + ? this.store.getAllWorkItems().filter(i => i.updatedAt && new Date(i.updatedAt).getTime() > new Date(wItems).getTime()) + : this.store.getAllWorkItems(); + const comments = wComments + ? this.store.getAllComments().filter(c => c.createdAt && new Date(c.createdAt).getTime() > new Date(wComments).getTime()) + : this.store.getAllComments(); + const dependencyEdges = wEdges + ? this.store.getAllDependencyEdges().filter(e => e.createdAt && new Date(e.createdAt).getTime() > new Date(wEdges).getTime()) + : this.store.getAllDependencyEdges(); + const auditResults = wAudits + ? this.store.getAllAuditResults().filter(a => a.auditedAt && new Date(a.auditedAt).getTime() > new Date(wAudits).getTime()) + : this.store.getAllAuditResults(); + + // Export to JSONL — the `kind` is surfaced to the JSONL writer so the + // output carries a sync header distinguishing full vs delta + // (WL-0MT2KXPOQ009G026). + await jsonlSvc.exportToJsonlAsync(items, comments, this.jsonlPath, dependencyEdges, auditResults, { onProgress: options?.onProgress, kind: mode }); + + // Watermark advancement is deliberately NOT performed here in any mode: + // the push path advances them only AFTER a successful push + // (markLastExportTimestamps, WL-0MT2KY0RQ008F50Q AC5). A failed push must + // not advance the baseline past data that was never published. return this.jsonlPath; } + /** + * Read the per-record-type last-export watermarks used by delta sync + * (WL-0MT2KWFUJ001OGHF). If none have ever been recorded, returns an empty + * object — callers treat that as "no baseline → full export". + */ + getLastExportTimestamps(): LastExportTimestamps { + return this.store.getLastExportTimestamps(); + } + + /** + * Advance the per-record-type last-export watermarks (after a successful + * delta push, or a whole-store import that publishes all records). Only the + * provided types are updated; others are left untouched. + */ + markLastExportTimestamps(ts: LastExportTimestamps): void { + this.store.setLastExportTimestamps(ts); + } + + /** + * Total number of dirty records across all four types given the stored + * per-type watermarks (WL-0MT2KY0RQ008F50Q / WL-0MSAKUBKW006FN8Q §5.4 + * zero-change fast path). A record is dirty when its timestamp is strictly + * greater than the watermark of its type. When a type has no watermark (no + * baseline), every record of that type counts as dirty, and when NO + * watermarks exist at all the store is treated as fully dirty (this is what + * makes the very first sync a full export). + */ + countDirtyRecords(): { items: number; comments: number; edges: number; audits: number; total: number } { + const wm = this.getLastExportTimestamps(); + const wItems = wm.workitems; + const wComments = wm.comments; + const wEdges = wm.edges; + const wAudits = wm.audit_results; + + const items = wItems + ? this.store.getAllWorkItems().filter(i => i.updatedAt && new Date(i.updatedAt).getTime() > new Date(wItems).getTime()).length + : this.store.getAllWorkItems().length; + const comments = wComments + ? this.store.getAllComments().filter(c => c.createdAt && new Date(c.createdAt).getTime() > new Date(wComments).getTime()).length + : this.store.getAllComments().length; + const edges = wEdges + ? this.store.getAllDependencyEdges().filter(e => e.createdAt && new Date(e.createdAt).getTime() > new Date(wEdges).getTime()).length + : this.store.getAllDependencyEdges().length; + const audits = wAudits + ? this.store.getAllAuditResults().filter(a => a.auditedAt && new Date(a.auditedAt).getTime() > new Date(wAudits).getTime()).length + : this.store.getAllAuditResults().length; + + return { items, comments, edges, audits, total: items + comments + edges + audits }; + } + + /** + * Read the delta-sync cadence metadata (WL-0MT2KY0RQ008F50Q §5.3): the + * number of consecutive delta syncs since the last full snapshot, and the + * accumulated JSONL bytes pushed in that window. Used to decide when the + * next full snapshot is due (defaults: every 10 delta syncs OR 1 MB). + */ + getDeltaSyncMetadata(): { deltaSyncCount: number; deltaBytes: number } { + return this.store.getDeltaSyncMetadata(); + } + + /** + * Persist the delta-sync cadence metadata ({@link getDeltaSyncMetadata}). + */ + setDeltaSyncMetadata(deltaSyncCount: number, deltaBytes: number): void { + this.store.setDeltaSyncMetadata(deltaSyncCount, deltaBytes); + } + /** * Delete the local JSONL file. * This should be called after successful Git push (ephemeral pattern). @@ -679,7 +804,7 @@ export class WorklogDatabase { needsProducerReview?: boolean; issueType?: string; } - ): { results: FtsSearchResult[]; ftsUsed: boolean } { + ): { results: FtsSearchResult[]; ftsUsed: boolean; warning?: string } { this.services.searchMetrics?.increment?.('search.total'); const idResults: FtsSearchResult[] = []; const seenIds = new Set(); @@ -751,11 +876,36 @@ export class WorklogDatabase { // --- Regular FTS / fallback search --- let ftsUsed = false; let ftsResults: FtsSearchResult[] = []; + let warning: string | undefined; if (this.store.ftsAvailable) { - ftsResults = this.store.searchFts(query, options); - ftsUsed = true; - this.services.searchMetrics?.increment?.('search.fts'); + try { + ftsResults = this.store.searchFts(query, options); + ftsUsed = true; + this.services.searchMetrics?.increment?.('search.fts'); + } catch (err) { + // The raw query tripped the FTS5 parser (e.g. an unquoted punctuated + // term like `v0.1.11` or a file path). Retry as a quoted phrase so + // users searching for version strings / paths still get results, and + // signal the auto-quote with a warning so it is never silent. + const message = err instanceof Error ? err.message : String(err); + const escaped = query.replace(/\\"/g, '').replace(/"/g, '""'); + let quotedResults: FtsSearchResult[]; + try { + quotedResults = this.store.searchFts(`"${escaped}"`, options); + } catch (err2) { + // Auto-quoting also failed — surface a hard, distinguishable error + // so the user knows the query is invalid (never a silent empty list). + const inner = err2 instanceof Error ? err2.message : String(err2); + throw new Error( + `invalid query syntax: "${query}" — quote phrases or punctuated terms. ${inner}` + ); + } + ftsResults = quotedResults; + ftsUsed = true; + this.services.searchMetrics?.increment?.('search.fts'); + warning = `query "${query}" was auto-quoted as a phrase because it is not valid FTS5 syntax: ${message}`; + } } else { if (!this.silent) { this.debug('FTS5 is not available; falling back to application-level search'); @@ -773,7 +923,7 @@ export class WorklogDatabase { } } - return { results: merged, ftsUsed }; + return { results: merged, ftsUsed, warning }; } /** @@ -1116,9 +1266,12 @@ export class WorklogDatabase { // this comparison because the update method above explicitly preserves // the existing values for these fields (prevents manual update from // overwriting GitHub metadata). Only hasWorkItemChanged() checks them. - const fieldsToCompare: (keyof WorkItem)[] = [ + const contentFieldsToCompare: (keyof WorkItem)[] = [ 'title', 'description', 'status', 'priority', 'sortIndex', 'parentId', - 'tags', 'assignee', 'stage', 'issueType', 'risk', 'effort', + 'tags', 'assignee', 'stage', 'issueType', 'risk', 'effort' + ]; + const fieldsToCompare: (keyof WorkItem)[] = [ + ...contentFieldsToCompare, 'needsProducerReview' ]; // Shared comparator: whitespace-only diffs in title/description are not @@ -1133,8 +1286,19 @@ export class WorklogDatabase { return updated; } - // At least one field changed — bump the timestamp. - updated.updatedAt = new Date().toISOString(); + // At least one tracked field changed. Check whether a content field + // actually changed — only then bump the timestamp. This prevents + // needsProducerReview-only flips from invalidating audits + // (WL-0MSN6ZCTN0027U2R). A metadata-only change still persists the + // flag but preserves the timestamp so that existing audits remain fresh. + const contentChanged = this.compareTrackedFields(item, updated, contentFieldsToCompare); + if (contentChanged) { + updated.updatedAt = new Date().toISOString(); + } else { + // Metadata-only change (e.g. needsProducerReview): preserve the + // original updatedAt so existing audits remain fresh. + updated.updatedAt = item.updatedAt; + } if (process.env.WL_DEBUG_SQL_BINDINGS) { try { @@ -1367,6 +1531,45 @@ export class WorklogDatabase { }; } + /** + * Revert an item to `open`/`plan_complete` after a "not ready to close" + * audit verdict. + * + * When an item in `in_review` (status `completed`) receives a + * not-ready-to-close verdict ("Ready to close: No" via `--audit-text`, or + * `--ready-to-close no` via `wl audit-set`), it is moved back to + * `open`/`plan_complete` so it drops out of the ready-to-close queue and + * returns to the planning queue for further work. The item's priority is + * preserved (only status/stage change). + * + * Only items in exactly `completed`/`in_review` are reverted: a `done` + * item or an item already `open`/`in-progress` is left untouched. + * + * @param itemId - id of the work item whose audit verdict is not-ready-to-close + * @returns the reversion details (item, from status/stage, to status/stage) + * or `null` when the item is not in an eligible state (or does not exist) + */ + revertToPlanComplete(itemId: string): RevertedItem | null { + const item = this.get(itemId); + if (!item) { + return null; + } + const eligible = item.status === 'completed' && item.stage === 'in_review'; + if (!eligible) { + return null; + } + const from = { status: item.status, stage: item.stage }; + const updated = this.update(itemId, { status: 'open', stage: 'plan_complete' }); + if (!updated) { + return null; + } + return { + item: updated, + from, + to: { status: updated.status, stage: updated.stage }, + }; + } + /** * Get the number of direct children for each work item. * Returns a Map. @@ -1446,11 +1649,15 @@ export class WorklogDatabase { * Ordinal rank of a risk level on the canonical scale * (Low < Medium < High < Severe/Critical). Both the type-level spelling * ('Severe') and the icon-scale spelling ('critical') map to the top - * rank. Unset/unknown values map to null (fail-closed: they are never + * rank. Agents may produce verbose risk fields like "Medium — NVIDIA + * driver changes…"; extract the leading keyword before any delimiter. + * Unset/unknown values map to null (fail-closed: they are never * matched by an at-most risk filter). */ private riskOrdinal(risk: string | undefined | null): number | null { - switch ((risk ?? '').trim().toLowerCase()) { + // Extract the leading keyword before any delimiter (—, -, :, whitespace). + const normalized = (risk ?? '').trim().toLowerCase().split(/[-:–—\s]+/)[0]; + switch (normalized) { case 'low': return 1; case 'medium': return 2; case 'high': return 3; @@ -1465,11 +1672,21 @@ export class WorklogDatabase { * (Extra Small < Small < Medium < Large < Extra Large). Accepts both the * short CLI spellings (XS/S/M/L/XL) and the long-form effort-and-risk * skill spellings (extra small/small/medium/large/extra large), - * normalized case-insensitively. Unset/unknown values map to null - * (fail-closed: they are never matched by an at-most effort filter). + * normalized case-insensitively. Agents may produce verbose effort fields + * like "1–4 hours — Small. Diagnostic investigation…"; extract the + * keyword via exact match on stripped string first, then fall back to + * word-boundary search. + * Unset/unknown values map to null (fail-closed: they are never matched + * by an at-most effort filter). */ private effortOrdinal(effort: string | undefined | null): number | null { - switch ((effort ?? '').trim().toLowerCase().replace(/[\s-]+/g, '')) { + const normalized = (effort ?? '').trim().toLowerCase(); + // Try exact match on stripped version first (simple cases: "small", "medium", + // "Extra Small" → "extrasmall"). If that fails, fall back to searching for + // the keyword anywhere in the string — agents produce verbose fields like + // "1–4 hours — Small. Diagnostic investigation…" (keyword after the dash). + const stripped = normalized.replace(/[\s-]+/g, ''); + switch (stripped) { case 'xs': case 'extrasmall': return 1; case 's': @@ -1480,7 +1697,20 @@ export class WorklogDatabase { case 'large': return 4; case 'xl': case 'extralarge': return 5; - default: return null; + default: + // Fallback: search for the keyword bounded by non-word characters. + // Checked in order of specificity (longest first) so "small" wins over "s". + if (/(?:^|\W)extrasmall(?:\W|$)/.test(normalized)) return 1; + if (/(?:^|\W)xs(?:\W|$)/.test(normalized)) return 1; + if (/(?:^|\W)small(?:\W|$)/.test(normalized)) return 2; + if (/(?:^|\W)s(?:\W|$)/.test(normalized)) return 2; + if (/(?:^|\W)extralarge(?:\W|$)/.test(normalized)) return 5; + if (/(?:^|\W)large(?:\W|$)/.test(normalized)) return 4; + if (/(?:^|\W)xl(?:\W|$)/.test(normalized)) return 5; + if (/(?:^|\W)l(?:\W|$)/.test(normalized)) return 4; + if (/(?:^|\W)m(?:\W|$)/.test(normalized)) return 3; + if (/(?:^|\W)medium(?:\W|$)/.test(normalized)) return 3; + return null; } } @@ -2740,6 +2970,14 @@ export class WorklogDatabase { } }); + // Rebuild FTS index for all imported items — the transaction cleared the + // FTS table via clearWorkItems, so every imported item needs a fresh row. + if (this.store.ftsAvailable) { + for (const item of items) { + this.store.upsertFtsEntry(item); + } + } + this.triggerAutoSync(); } @@ -2778,6 +3016,10 @@ export class WorklogDatabase { ? { ...item, updatedAt: new Date().toISOString() } : item; this.store.saveWorkItem(itemToSave); + // Keep the FTS index fresh for the upserted item + if (this.store.ftsAvailable) { + this.store.upsertFtsEntry(itemToSave); + } } if (dependencyEdges) { @@ -2925,6 +3167,10 @@ export class WorklogDatabase { updatedAt: new Date().toISOString(), }; this.store.saveWorkItem(updated); + // Keep the FTS index fresh so status-filtered search reflects the change + if (this.store.ftsAvailable) { + this.store.upsertFtsEntry(updated); + } this.triggerAutoSync(); return true; } @@ -2948,6 +3194,10 @@ export class WorklogDatabase { updatedAt: new Date().toISOString(), }; this.store.saveWorkItem(updated); + // Keep the FTS index fresh so status-filtered search reflects the change + if (this.store.ftsAvailable) { + this.store.upsertFtsEntry(updated); + } this.triggerAutoSync(); if (process.env.WL_DEBUG) { process.stderr.write(`[wl:dep] re-blocked ${itemId} (active blockers remain)\n`); @@ -2965,6 +3215,10 @@ export class WorklogDatabase { updatedAt: new Date().toISOString(), }; this.store.saveWorkItem(updated); + // Keep the FTS index fresh so status-filtered search reflects the change + if (this.store.ftsAvailable) { + this.store.upsertFtsEntry(updated); + } this.triggerAutoSync(); if (process.env.WL_DEBUG) { process.stderr.write(`[wl:dep] unblocked ${itemId} (no active blockers remain)\n`); @@ -3116,6 +3370,31 @@ export class WorklogDatabase { return this.store.getAllDependencyEdges(); } + /** + * Upsert comments non-destructively (incremental-sync delta pull, + * WL-0MT2KYCNB000CYWV). Unlike {@link importComments} this does NOT clear + * existing comments first: each provided comment is saved via the store's + * INSERT OR REPLACE so local comments absent from the incoming delta are + * preserved, while overlapping ids converge to the merged value. + */ + upsertComments(comments: Comment[]): void { + for (const comment of comments) { + this.store.saveComment(comment); + } + // Comment text is embedded in the parent item's FTS row, so update + // all parent items to keep the index fresh. + if (this.store.ftsAvailable && comments.length > 0) { + const affectedIds = new Set(comments.map(c => c.workItemId)); + const items = this.store.getAllWorkItems(); + for (const item of items) { + if (affectedIds.has(item.id)) { + this.store.upsertFtsEntry(item); + } + } + } + this.triggerAutoSync(); + } + /** * Import comments */ @@ -3124,6 +3403,13 @@ export class WorklogDatabase { for (const comment of comments) { this.store.saveComment(comment); } + // Re-index all items whose comments changed (FTS rows embed comment text) + if (this.store.ftsAvailable) { + const items = this.store.getAllWorkItems(); + for (const item of items) { + this.store.upsertFtsEntry(item); + } + } this.triggerAutoSync(); } diff --git a/packages/shared/src/dirty-tracking.test.ts b/packages/shared/src/dirty-tracking.test.ts new file mode 100644 index 000000000..91b456bcd --- /dev/null +++ b/packages/shared/src/dirty-tracking.test.ts @@ -0,0 +1,384 @@ +/** + * Tests for per-record-type last-export watermarks used by delta (incremental) + * sync (WL-0MT2KWFUJ001OGHF / parent WL-0MSAKUBKW006FN8Q). + * + * Dirty tracking is stored in the `last_export_timestamps` DB table, exposed + * through the public `WorklogDatabase` API: + * - `getLastExportTimestamps()` — read the watermarks + * - `markLastExportTimestamps()` — advance watermarks after a successful push + * - `exportForSync({ since })` — filter records by watermark (delta export) + * + * These tests pin observable behaviour through that public API: watermark + * read/write round-trip, per-type filtering in delta exports, full-export + * auto-advance of watermarks, and no-baseline → full export. + * + * The jsonl service is injected as a mock so the shared module is tested in + * isolation (the CLI wires the real implementation in src/database.ts). + * + * Run: npx vitest run packages/shared/src/dirty-tracking.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { WorklogDatabase, WorklogDatabaseServices } from './database.js'; +import { WorkItem } from './types.js'; + +const FIXED_TS = '2026-01-01T00:00:00.000Z'; +const LATER_TS = '2026-02-01T00:00:00.000Z'; + +/** Build a complete WorkItem with a fixed timestamp. */ +function makeItem(overrides: Partial = {}): WorkItem { + return { + id: 'WI-0001', + title: 'Fix the bug', + description: 'A description with some detail.', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: FIXED_TS, + updatedAt: FIXED_TS, + tags: [], + assignee: '', + stage: 'idea', + issueType: 'bug', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + needsProducerReview: false, + ...overrides, + }; +} + +/** A mock jsonl service that records the arrays handed to exportToJsonlAsync. */ +function makeJsonlService() { + const calls: Array<{ items: WorkItem[]; kind?: string }> = []; + const exportToJsonlAsync = vi.fn(async (items: WorkItem[], _comments: any[], _path: string, _deps: any[], _audits: any[], options?: any): Promise => { + calls.push({ items: items as WorkItem[], kind: options?.kind }); + return 0; + }); + return { + service: { + jsonl: { + importFromJsonl: vi.fn(() => ({ items: [], comments: [], dependencyEdges: [], auditResults: [] })), + importFromJsonlContent: vi.fn(() => ({ items: [], comments: [], dependencyEdges: [], auditResults: [] })), + exportToJsonlAsync, + getDefaultDataPath: vi.fn(() => ''), + }, + } as unknown as WorklogDatabaseServices, + calls, + exportToJsonlAsync, + }; +} + +let tempDir: string; +let db: WorklogDatabase; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'wl-dirty-tracking-')); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe('last-export watermark round-trip (WL-0MT2KWFUJ001OGHF)', () => { + it('returns an empty set when no watermarks have ever been recorded', () => { + expect(db.getLastExportTimestamps()).toEqual({}); + }); + + it('persists and reads all four per-type watermarks', () => { + const ts = { + workitems: FIXED_TS, + comments: LATER_TS, + edges: '2026-03-01T00:00:00.000Z', + audit_results: '2026-04-01T00:00:00.000Z', + }; + db.markLastExportTimestamps(ts); + expect(db.getLastExportTimestamps()).toEqual(ts); + }); + + it('updates only the provided types, leaving others untouched', () => { + db.markLastExportTimestamps({ workitems: FIXED_TS }); + db.markLastExportTimestamps({ comments: LATER_TS }); + const current = db.getLastExportTimestamps(); + expect(current.workitems).toBe(FIXED_TS); + expect(current.comments).toBe(LATER_TS); + expect(current.edges).toBeUndefined(); + expect(current.audit_results).toBeUndefined(); + }); + + it('survives a database reopen (persisted, not in-memory)', () => { + const dbPath = join(tempDir, 'worklog.db'); + const jsonlPath = join(tempDir, 'data.jsonl'); + db.markLastExportTimestamps({ workitems: FIXED_TS, comments: LATER_TS }); + db = new WorklogDatabase('TEST', dbPath, jsonlPath, true); + expect(db.getLastExportTimestamps().workitems).toBe(FIXED_TS); + expect(db.getLastExportTimestamps().comments).toBe(LATER_TS); + }); +}); + +describe('exportForSync delta filtering by watermark (WL-0MT2KXPOQ009G026)', () => { + it('delta mode with no watermarks falls back to a full export (no baseline)', async () => { + const { service, calls } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001', updatedAt: FIXED_TS }), makeItem({ id: 'WI-0002', updatedAt: LATER_TS })]); + + // No baseline has ever been recorded → delta must degrade to full export. + await db.exportForSync({ mode: 'delta' }); + + expect(calls.length).toBe(1); + expect(calls[0].items.length).toBe(2); + }); + + it('delta mode reads stored watermarks automatically (no explicit since)', async () => { + const { service, calls } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001', updatedAt: FIXED_TS }), makeItem({ id: 'WI-0002', updatedAt: LATER_TS })]); + db.markLastExportTimestamps({ workitems: FIXED_TS }); + + // Only WI-0002 was updated after the stored watermark (strict >). + await db.exportForSync({ mode: 'delta' }); + + expect(calls.length).toBe(1); + expect(calls[0].items.map((i: WorkItem) => i.id)).toEqual(['WI-0002']); + // The writer must receive the delta kind so the JSONL carries a delta header. + expect(calls[0].kind).toBe('delta'); + }); + + it('delta mode advances no watermarks on export', async () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.markLastExportTimestamps({ workitems: FIXED_TS }); + await db.exportForSync({ mode: 'delta' }); + expect(db.getLastExportTimestamps().workitems).toBe(FIXED_TS); + }); + + it('explicit since overrides stored watermarks in delta mode', async () => { + const { service, calls } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001', updatedAt: FIXED_TS }), makeItem({ id: 'WI-0002', updatedAt: LATER_TS })]); + db.markLastExportTimestamps({ workitems: LATER_TS }); + + // Stored watermark LATER_TS would export nothing; the explicit since=FIXED_TS + // takes precedence and leaves WI-0002 dirty. + await db.exportForSync({ mode: 'delta', since: { workitems: FIXED_TS } }); + expect(calls[0].items.map((i: WorkItem) => i.id)).toEqual(['WI-0002']); + }); + + it('exports only items updated after the workitems watermark', async () => { + const { service, calls } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001', updatedAt: FIXED_TS }), makeItem({ id: 'WI-0002', updatedAt: LATER_TS })]); + await db.exportForSync({ mode: 'delta', since: { workitems: FIXED_TS } }); + + expect(calls.length).toBe(1); + const ids = calls[0].items.map((i: WorkItem) => i.id); + expect(ids).toEqual(['WI-0002']); + }); + + it('exports only comments created after the comments watermark', async () => { + const { service, calls } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001' })]); + db.createComment({ workItemId: 'WI-0001', author: 'a', comment: 'old', references: [] }); + const oldComment = db.getAllComments()[0]; + await db.exportForSync({ mode: 'delta', since: { comments: oldComment.createdAt } }); + + const commentCalls = (service.jsonl as any).exportToJsonlAsync.mock.calls; + const exportedComments = commentCalls[0][1]; + expect(exportedComments.length).toBe(0); + }); + + it('exports edges created after the edges watermark', async () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001' }), makeItem({ id: 'WI-0002' })]); + db.addDependencyEdge('WI-0001', 'WI-0002'); + const edge = db.getAllDependencyEdges()[0]; + await db.exportForSync({ mode: 'delta', since: { edges: edge.createdAt } }); + + const edgeCalls = (service.jsonl as any).exportToJsonlAsync.mock.calls; + const exportedEdges = edgeCalls[0][3]; + expect(exportedEdges.length).toBe(0); + }); + + it('exports audit results after the audit_results watermark', async () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001' })]); + db.saveAuditResult({ workItemId: 'WI-0001', readyToClose: true, auditedAt: LATER_TS, summary: 's', rawOutput: null, author: 'a' }); + await db.exportForSync({ mode: 'delta', since: { audit_results: FIXED_TS } }); + + const auditCalls = (service.jsonl as any).exportToJsonlAsync.mock.calls; + const exportedAudits = auditCalls[0][4]; + expect(exportedAudits.length).toBe(1); + expect(exportedAudits[0].workItemId).toBe('WI-0001'); + }); + + it('with no watermark filter exports everything (full baseline)', async () => { + const { service, calls } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001' }), makeItem({ id: 'WI-0002' })]); + await db.exportForSync({}); + + expect(calls.length).toBe(1); + expect(calls[0].items.length).toBe(2); + }); +}); + +describe('watermark advancement on the push path (WL-0MT2KY0RQ008F50Q)', () => { + it('a full export does NOT auto-advance watermarks (push path advances after success)', async () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem()]); + await db.exportForSync({}); + + // AC5: exporting alone must never advance the baseline — a failed push + // would otherwise skip unpublished records on the next delta. + expect(db.getLastExportTimestamps()).toEqual({}); + }); + + it('the push path advances all four watermarks after a successful full push', () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem()]); + + // performSync contract: export → push → markLastExportTimestamps(now). + db.markLastExportTimestamps({ + workitems: LATER_TS, + comments: LATER_TS, + edges: LATER_TS, + audit_results: LATER_TS, + }); + const ts = db.getLastExportTimestamps(); + expect(ts.workitems).toBe(LATER_TS); + expect(ts.comments).toBe(LATER_TS); + expect(ts.edges).toBe(LATER_TS); + expect(ts.audit_results).toBe(LATER_TS); + }); + + it('does NOT auto-advance watermarks on a delta export', async () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.markLastExportTimestamps({ workitems: FIXED_TS }); + await db.exportForSync({ since: { workitems: FIXED_TS }, mode: 'delta' }); + expect(db.getLastExportTimestamps().workitems).toBe(FIXED_TS); + }); +}); + +describe('dirty-record counts (WL-0MT2KY0RQ008F50Q §5.4 zero-change fast path)', () => { + it('no baseline → everything is dirty', () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem(), makeItem({ id: 'WI-0002' })]); + db.createComment({ workItemId: 'WI-0001', author: 'a', comment: 'c', references: [] }); + db.addDependencyEdge('WI-0001', 'WI-0002'); + + const dirty = db.countDirtyRecords(); + expect(dirty.items).toBe(2); + expect(dirty.comments).toBe(1); + expect(dirty.edges).toBe(1); + expect(dirty.audits).toBe(0); + expect(dirty.total).toBe(4); + }); + + it('watermark at the newest timestamp → nothing is dirty', () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ updatedAt: FIXED_TS })]); + // createComment stamps the comment and touches the parent item with NOW — + // so advance the watermarks to a point AFTER the comment was created, + // which is the realistic push-path sequence (export → push → mark). + db.createComment({ workItemId: 'WI-0001', author: 'a', comment: 'c', references: [] }); + const after = new Date().toISOString(); + db.markLastExportTimestamps({ + workitems: after, + comments: after, + }); + + const dirty = db.countDirtyRecords(); + expect(dirty.items).toBe(0); + expect(dirty.comments).toBe(0); + expect(dirty.total).toBe(0); + }); + + it('records updated after the watermark are dirty (strict >)', () => { + const { service } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ updatedAt: FIXED_TS }), makeItem({ id: 'WI-0002', updatedAt: LATER_TS })]); + db.markLastExportTimestamps({ workitems: FIXED_TS }); + + expect(db.countDirtyRecords().items).toBe(1); + expect(db.countDirtyRecords().total).toBe(1); + }); +}); + +describe('delta-sync cadence metadata (WL-0MT2KY0RQ008F50Q §5.3)', () => { + it('defaults to zero count and zero bytes', () => { + expect(db.getDeltaSyncMetadata()).toEqual({ deltaSyncCount: 0, deltaBytes: 0 }); + }); + + it('round-trips the delta count and accumulated bytes', () => { + db.setDeltaSyncMetadata(7, 123456); + expect(db.getDeltaSyncMetadata()).toEqual({ deltaSyncCount: 7, deltaBytes: 123456 }); + }); + + it('survives a database reopen (persisted, not in-memory)', () => { + const dbPath = join(tempDir, 'worklog.db'); + const jsonlPath = join(tempDir, 'data.jsonl'); + db.setDeltaSyncMetadata(3, 999); + db = new WorklogDatabase('TEST', dbPath, jsonlPath, true); + expect(db.getDeltaSyncMetadata()).toEqual({ deltaSyncCount: 3, deltaBytes: 999 }); + }); + + it('does not collide with per-type watermarks', () => { + db.setDeltaSyncMetadata(2, 42); + db.markLastExportTimestamps({ workitems: FIXED_TS }); + expect(db.getLastExportTimestamps().workitems).toBe(FIXED_TS); + expect(db.getLastExportTimestamps().comments).toBeUndefined(); + const all = db.getDeltaSyncMetadata(); + expect(all).toEqual({ deltaSyncCount: 2, deltaBytes: 42 }); + }); +}); +// ── WL-0MT2KZH0I005XUWE: deletion propagation through delta export/merge ── + +describe('deletion propagation (WL-0MT2KZH0I005XUWE §4.3)', () => { + it('AC1: a soft-deleted record is included in the delta export (updatedAt bumped by delete)', async () => { + const { service, calls } = makeJsonlService(); + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, service); + db.import([makeItem({ id: 'WI-0001', updatedAt: FIXED_TS }), makeItem({ id: 'WI-0002', updatedAt: FIXED_TS })]); + db.markLastExportTimestamps({ workitems: FIXED_TS }); + + // Delete WI-0002: deleteSingle bumps updatedAt to now → dirty predicate fires. + db.delete('WI-0002'); + const deleted = db.get('WI-0002'); + expect(deleted?.status).toBe('deleted'); + expect(deleted?.updatedAt).not.toBe(FIXED_TS); + + await db.exportForSync({ mode: 'delta' }); + expect(calls.length).toBe(1); + const ids = calls[0].items.map((i: WorkItem) => i.id); + expect(ids).toContain('WI-0002'); + const deletedExported = calls[0].items.find((i: WorkItem) => i.id === 'WI-0002'); + expect(deletedExported?.status).toBe('deleted'); + }); + + it('AC3: upserting a delta deleted record into the local store converges (pull side)', async () => { + db = new WorklogDatabase('TEST', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true, false, undefined, makeJsonlService() as any); + db.import([makeItem({ id: 'WI-0001', status: 'completed', stage: 'in_review', updatedAt: FIXED_TS })]); + expect(db.get('WI-0001')?.status).toBe('completed'); + + // A peer deleted WI-0001; its delta record carries status deleted + newer ts. + const now = new Date().toISOString(); + db.upsertItems([makeItem({ id: 'WI-0001', status: 'deleted', stage: 'in_review', updatedAt: now })]); + + // Converged: local must reflect the remote delete even though it was completed. + expect(db.get('WI-0001')?.status).toBe('deleted'); + }); +}); diff --git a/packages/herdr/src/icons.ts b/packages/shared/src/icons.ts similarity index 80% rename from packages/herdr/src/icons.ts rename to packages/shared/src/icons.ts index bbcbcd8c5..7ad10af65 100644 --- a/packages/herdr/src/icons.ts +++ b/packages/shared/src/icons.ts @@ -1,9 +1,22 @@ /** - * packages/herdr/src/icons.ts — Icon utilities for Herdr work item display + * packages/shared/src/icons.ts — Shared icon/colour data for the herdr plugin * - * Provides consistent icon rendering (emoji or text fallback) for work - * item status, priority, stage, audit results, and metadata indicators. - * Adapted from the main project src/icons.ts without Pi dependencies. + * Dependency-free module providing icon maps, icon functions, stage-colour + * helpers, and display-width utilities. Herdr imported its own adapted copy + * (packages/herdr/src/icons.ts); this module is that data's single canonical + * home so the plugin no longer carries a duplicate that can drift. + * + * The icon glyphs deliberately preserve the values the herdr worklist has + * always rendered (❓ unknown audit, ⏳ stale-passed, ⊙ epic, 💥 critical + * risk, `[ready]`/`[?]` text fallbacks) so removing the local copy does not + * change any rendered output (parent WL-0MSJ4BT4Z002HH9B AC3: "the worklist + * renders icons/colours identically"). + * + * The CLI (src/theme.ts) keeps its own self-contained icons with slightly + * different glyph choices; this module serves herdr's needs without pulling + * in chalk or other Pi dependencies. + * + * No external dependencies — pure data + functions. */ // ── Options ─────────────────────────────────────────────────────────── @@ -138,18 +151,18 @@ export function statusIcon(status: string, opts?: IconOptions): string { /** * Get the icon for a work item stage. */ -export function stageIcon(stage: string | undefined, opts?: IconOptions): string { +export function stageIcon(stage: string | undefined | null, opts?: IconOptions): string { const key = (stage || '').toLowerCase(); if (opts?.noIcons) { return STAGE_FALLBACK[key] || `[${key.toUpperCase()}]`; } - return STAGE_ICONS[key] || '\u{2753}'; + return STAGE_ICONS[key] || '\u{2753}'; // ❓ } /** * Get the icon for a work item priority. */ -export function priorityIcon(priority: string | undefined, opts?: IconOptions): string { +export function priorityIcon(priority: string | undefined | null, opts?: IconOptions): string { const key = (priority || '').toLowerCase().trim(); if (opts?.noIcons) { return PRIORITY_FALLBACK[key] || ''; @@ -210,7 +223,7 @@ export function agentStatusIcon(state: string | undefined, opts?: IconOptions): /** * Get the risk icon. */ -export function riskIcon(risk: string | undefined, opts?: IconOptions): string { +export function riskIcon(risk: string | undefined | null, opts?: IconOptions): string { const key = (risk || '').toLowerCase().trim(); if (!key) return ''; if (opts?.noIcons) return `[${key.toUpperCase()}]`; @@ -220,7 +233,7 @@ export function riskIcon(risk: string | undefined, opts?: IconOptions): string { /** * Get the effort icon. */ -export function effortIcon(effort: string | undefined, opts?: IconOptions): string { +export function effortIcon(effort: string | undefined | null, opts?: IconOptions): string { const key = (effort || '').toLowerCase().trim(); if (!key) return ''; if (opts?.noIcons) return `[${key.toUpperCase()}]`; @@ -246,6 +259,12 @@ export function needsProducerReviewIcon( /** * Determine whether an audit result is fresh (not stale) based on the * 60-second staleness buffer. + * + * Guarantees: `updatedAt` is only bumped on content changes (title, description, + * status, stage, priority, etc.). Flag-only flips of `needsProducerReview` do + * not move `updatedAt`, so a previously valid audit remains fresh — the TUI + * continues showing the passed icon and the downtime dispatcher does not + * re-dispatch a redundant audit. (WL-0MSN6ZCTN0027U2R) */ export function isAuditFresh( auditedAt: string | null | undefined, @@ -258,6 +277,33 @@ export function isAuditFresh( return auditTime > updateTime - 60000; } +/** + * Get the display icon for an item's stage with the list's audit-aware + * `in_review` handling: a fresh audit shows the audit-result icon + * (✅/❌/❓), a stale-but-passed audit shows the stale-passed hourglass + * (⏳), a stale or missing audit on an `in_review` item falls back to the + * plain stage icon (🔍), and every other stage shows the plain stage icon. + * + * Shared by the list row prefix (`getIconPrefix`) and the metadata Stage + * row so the two sections can never diverge (WL-0MSGIXHHI009KFW9 AC2). + */ +export function stageDisplayIcon( + item: { stage?: string; auditResult?: boolean | null; auditedAt?: string | null; updatedAt?: string }, + opts?: IconOptions, +): string { + const noIcons = opts?.noIcons ?? false; + if (item.stage === 'in_review') { + const fresh = isAuditFresh(item.auditedAt, item.updatedAt); + if (fresh) { + return auditIcon(item.auditResult, { noIcons }); + } + if (item.auditResult === true) { + return auditStaleIcon(item.auditResult, { noIcons }); + } + } + return stageIcon(item.stage, { noIcons }); +} + // ── Stage colour ────────────────────────────────────────────────────── /** @@ -361,24 +407,10 @@ export function getIconPrefix( const sIcon = statusIcon(item.status, { noIcons }); - // Column 2: stage or audit-aware icon for in_review - let secondIcon: string; - if (item.stage === 'in_review') { - const fresh = isAuditFresh(item.auditedAt, item.updatedAt); - if (fresh) { - // Fresh audit: show based on audit result - secondIcon = auditIcon(item.auditResult, { noIcons }); - } else { - // No audit or stale audit: show stale-passed icon if passed, else stage icon - if (item.auditResult === true) { - secondIcon = auditStaleIcon(item.auditResult, { noIcons }); - } else { - secondIcon = stageIcon(item.stage, { noIcons }); - } - } - } else { - secondIcon = stageIcon(item.stage, { noIcons }); - } + // Column 2: stage or audit-aware icon for in_review — via the shared + // stageDisplayIcon helper so the list prefix and the metadata Stage row + // can never diverge (WL-0MSGIXHHI009KFW9). + const secondIcon = stageDisplayIcon(item, { noIcons }); // Column 3: producer review flag const prIcon = needsProducerReviewIcon(item.needsProducerReview, { noIcons }); @@ -400,4 +432,4 @@ export function getIconPrefix( } return prefix; -} +} \ No newline at end of file diff --git a/packages/shared/src/persistent-store.ts b/packages/shared/src/persistent-store.ts index 384e5378a..2461e0d9e 100644 --- a/packages/shared/src/persistent-store.ts +++ b/packages/shared/src/persistent-store.ts @@ -48,6 +48,22 @@ interface DbMetadata { schemaVersion: number; } +/** + * Per-record-type last-export timestamps used for delta-aware export + * (WL-0MT2KWFUJ001OGHF / WL-0MSAKUBKW006FN8Q). + * + * Records whose `updatedAt` (or `createdAt` for comments) is strictly greater + * than the corresponding timestamp are considered dirty and eligible for a + * delta (incremental) export. An absent/missing type timestamp means "no + * baseline" → treat all records of that type as dirty (full export). + */ +export interface LastExportTimestamps { + workitems?: string; // ISO 8601 — items changed after this are dirty + comments?: string; // ISO 8601 — comments created after this are dirty + edges?: string; // ISO 8601 — dependency edges created after this are dirty + audit_results?: string; // ISO 8601 — audit results after this are dirty +} + const SCHEMA_VERSION = 8; // ── In-memory cache types (Phase 5) ──────────────────────────────── @@ -350,6 +366,17 @@ export class SqlitePersistentStore { ) `); + // Create last_export_timestamps table for tracking per-record-type + // last-export watermarks used by delta (incremental) sync + // (WL-0MT2KWFUJ001OGHF / WL-0MSAKUBKW006FN8Q). Only the four known + // record types are stored; each holds a single ISO-8601 watermark. + this.db.exec(` + CREATE TABLE IF NOT EXISTS last_export_timestamps ( + record_type TEXT PRIMARY KEY, + exported_at TEXT NOT NULL + ) + `); + // Create indexes for common queries this.db.exec(` CREATE INDEX IF NOT EXISTS idx_workitems_status ON workitems(status); @@ -405,6 +432,80 @@ export class SqlitePersistentStore { }; } + /** + * Read the per-record-type last-export watermarks used by delta (incremental) + * sync (WL-0MT2KWFUJ001OGHF). Returns an object with the watermark timestamp + * for each record type that has one; absent types are `undefined` (treated as + * "no baseline" → full export by the caller). + */ + getLastExportTimestamps(): LastExportTimestamps { + const stmt = this.db.prepare('SELECT record_type, exported_at FROM last_export_timestamps'); + const rows = stmt.all() as Array<{ record_type: string; exported_at: string }>; + const out: LastExportTimestamps = {}; + for (const row of rows) { + if (row.record_type === 'workitems') out.workitems = row.exported_at; + else if (row.record_type === 'comments') out.comments = row.exported_at; + else if (row.record_type === 'edges') out.edges = row.exported_at; + else if (row.record_type === 'audit_results') out.audit_results = row.exported_at; + } + return out; + } + + /** + * Upsert the per-record-type last-export watermarks. Passing `undefined` for + * a type leaves that type's existing watermark untouched (or absent if never + * set). + */ + setLastExportTimestamps(ts: LastExportTimestamps): void { + const upsert = this.db.prepare( + 'INSERT OR REPLACE INTO last_export_timestamps (record_type, exported_at) VALUES (?, ?)' + ); + if (ts.workitems !== undefined) upsert.run('workitems', ts.workitems); + if (ts.comments !== undefined) upsert.run('comments', ts.comments); + if (ts.edges !== undefined) upsert.run('edges', ts.edges); + if (ts.audit_results !== undefined) upsert.run('audit_results', ts.audit_results); + } + + /** + * Delta-sync cadence metadata (WL-0MT2KY0RQ008F50Q / WL-0MSAKUBKW006FN8Q), + * persisted by the push path after each successful delta/full sync: + * - `deltaSyncCount` — number of consecutive delta syncs since the last + * full snapshot (full snapshots reset it to 0). + * - `deltaBytes` — accumulated JSONL bytes pushed since the last full + * snapshot. + * + * Stored as dedicated rows in `last_export_timestamps` (special record + * types `__delta_count__` / `__delta_bytes__`); the watermark reader + * (`getLastExportTimestamps`) ignores these rows, so they never collide + * with per-type watermarks. When no full-snapshot cadence policy is in + * place the rows simply never appear and the default (0, 0) is returned. + */ + getDeltaSyncMetadata(): { deltaSyncCount: number; deltaBytes: number } { + const stmt = this.db.prepare('SELECT record_type, exported_at FROM last_export_timestamps WHERE record_type IN (?, ?)'); + const rows = stmt.all('__delta_count__', '__delta_bytes__') as Array<{ record_type: string; exported_at: string }>; + let deltaSyncCount = 0; + let deltaBytes = 0; + for (const row of rows) { + const parsed = Number(row.exported_at); + if (Number.isFinite(parsed)) { + if (row.record_type === '__delta_count__') deltaSyncCount = parsed; + else deltaBytes = parsed; + } + } + return { deltaSyncCount, deltaBytes }; + } + + /** + * Persist the delta-sync cadence metadata ({@link getDeltaSyncMetadata}). + */ + setDeltaSyncMetadata(deltaSyncCount: number, deltaBytes: number): void { + const upsert = this.db.prepare( + 'INSERT OR REPLACE INTO last_export_timestamps (record_type, exported_at) VALUES (?, ?)' + ); + upsert.run('__delta_count__', String(deltaSyncCount)); + upsert.run('__delta_bytes__', String(deltaBytes)); + } + /** * Save a work item */ @@ -866,6 +967,8 @@ export class SqlitePersistentStore { */ clearWorkItems(): void { this.db.prepare('DELETE FROM workitems').run(); + // Also clear FTS entries to avoid stale results after import + this.db.prepare('DELETE FROM worklog_fts').run(); this.invalidateWorkItemCaches(); this.invalidateCommentCaches(); this.invalidateDependencyEdgeCaches(); @@ -965,6 +1068,10 @@ export class SqlitePersistentStore { */ clearComments(): void { this.db.prepare('DELETE FROM comments').run(); + // Clear all FTS entries — comments are embedded in item FTS rows, + // so without a full rebuild we'd have stale comment data. The caller + // (e.g. importComments) re-upserts FTS entries after this. + this.db.prepare('DELETE FROM worklog_fts').run(); this.invalidateCommentCaches(); } @@ -1519,9 +1626,13 @@ export class SqlitePersistentStore { } return results; - } catch (_err) { - // If the query syntax is invalid, return empty results - return []; + } catch (err) { + // Never swallow FTS5 failures silently — the caller (WorklogDatabase.search) + // classifies them: FTS5 syntax errors (e.g. unquoted punctuated terms like + // `v0.1.11`, `path/to/file`) trigger an auto-quoted phrase retry, and only + // when that also fails is the error surfaced to the user. + const message = err instanceof Error ? err.message : String(err); + throw new Error(message); } } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4706ceaf9..655fb68a7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -183,6 +183,18 @@ export interface WorklogConfig { auditWriteEnabled?: boolean; syncRemote?: string; syncBranch?: string; + /** + * Force a full (non-incremental) JSONL snapshot after this many consecutive + * delta syncs (WL-0MT2KY0RQ008F50Q / WL-0MSAKUBKW006FN8Q §5.3). + * Default 10. The counter is reset whenever a full snapshot is pushed. + */ + syncFullSnapshotEveryN?: number; + /** + * Force a full (non-incremental) JSONL snapshot when the accumulated delta + * payload exceeds this many bytes (WL-0MT2KY0RQ008F50Q §5.3, advisory + * threshold). Default 1_000_000 (1 MB). + */ + syncDeltaSizeThreshold?: number; /** * Allow `wl sync` to merge commits authored by a different identity than * the store's configured `user.email` (identity gate WL-0MSOYWWS4009HTCB). @@ -333,3 +345,21 @@ export interface DemotedParent { /** The parent's status/stage after the demotion */ to: { status: string; stage: string }; } + +/** + * Result of reverting an item whose audit verdict is "not ready to close". + * + * When an `in_review` item (status `completed`) receives a not-ready-to-close + * audit verdict, it is moved back to `open`/`plan_complete` so it returns to + * the planning queue instead of being swept up by heartbeat/release tooling. + * This shape captures the lifecycle transition so callers can report it + * (e.g. `completed`/`in_review` → `open`/`plan_complete`). + */ +export interface RevertedItem { + /** The work item after the reversion */ + item: WorkItem; + /** The item's status/stage before the reversion */ + from: { status: string; stage: string }; + /** The item's status/stage after the reversion */ + to: { status: string; stage: string }; +} diff --git a/packages/tui/extensions/Worklog/session-health.test.ts b/packages/tui/extensions/Worklog/session-health.test.ts index 41237ff78..d85cff53f 100644 --- a/packages/tui/extensions/Worklog/session-health.test.ts +++ b/packages/tui/extensions/Worklog/session-health.test.ts @@ -31,10 +31,17 @@ vi.mock('@earendil-works/pi-coding-agent', () => ({ })); // ── Mock @earendil-works/pi-tui ─────────────────────────────────────── +// Real pi-tui's visibleWidth/truncateToWidth are ANSI-aware (escape codes +// occupy zero terminal columns). The mock reproduces that behavior so +// theme-styled strings measure the same way they do in the real TUI. vi.mock('@earendil-works/pi-tui', () => ({ - truncateToWidth: (text: string, width: number) => text.slice(0, width), - visibleWidth: (text: string) => text.length, + truncateToWidth: (text: string, width: number) => { + const stripped = text.replace(/\x1b\[[0-9;]*m/g, ''); + if (stripped.length <= width) return text; + return stripped.slice(0, width); + }, + visibleWidth: (text: string) => text.replace(/\x1b\[[0-9;]*m/g, '').length, })); // ── Mock ./model-display.js ──────────────────────────────────────────── @@ -1049,6 +1056,11 @@ describe('session-health', () => { return footerObj.render(width); } + /** Helper to strip ANSI dim/reset codes from a rendered footer line. */ + function unstyle(line: string): string { + return line.replace(/\x1b\[[0-9;]*m/g, ''); + } + beforeEach(() => { // Reset mock return values before each test mocks.mockGetResolvedModel.mockReturnValue(null); @@ -1063,50 +1075,55 @@ describe('session-health', () => { mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4'); mocks.mockGetSelectedModel.mockReturnValue('code'); - const lines = fabricateFooterLines({ ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }); + const lines = fabricateFooterLines({ ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }); // With no extension statuses: lines[0]=session health, lines[1]=model info expect(lines.length).toBe(2); // Format: " → " expect(lines[1]).toContain('code → openai/gpt-4'); - expect(lines[1]).toContain('[dim'); + // Model is right-aligned: the visible content fills the line width and + // the model's right edge is at the final column. + const visible = unstyle(lines[1]); + expect(visible.length).toBe(500); + expect(visible.endsWith('code → openai/gpt-4')).toBe(true); + expect(lines[1]).toContain('\x1b[2m'); }); it('shows "alias → (resolving)" when model selected but not yet resolved', () => { mocks.mockGetResolvedModel.mockReturnValue(null); mocks.mockGetSelectedModel.mockReturnValue('code'); - const lines = fabricateFooterLines({ ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }); + const lines = fabricateFooterLines({ ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }); // With no extension statuses: lines[0]=session health, lines[1]=alias → (resolving) expect(lines.length).toBe(2); expect(lines[1]).toContain('code → (resolving)'); - expect(lines[1]).toContain('[dim'); + expect(lines[1]).toContain('\x1b[2m'); }); it('shows a grey dash when no model selected and no resolved model', () => { mocks.mockGetResolvedModel.mockReturnValue(null); mocks.mockGetSelectedModel.mockReturnValue(null); - const lines = fabricateFooterLines({ ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }); + const lines = fabricateFooterLines({ ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }); // With no extension statuses: lines[0]=session health, lines[1]=dash expect(lines.length).toBe(2); expect(lines[1]).toContain('—'); - expect(lines[1]).toContain('[dim'); + expect(lines[1]).toContain('\x1b[2m'); }); it('shows just the resolved model when no model alias is selected', () => { mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4'); mocks.mockGetSelectedModel.mockReturnValue(null); - const lines = fabricateFooterLines({ ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }); + const lines = fabricateFooterLines({ ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }); // With no extension statuses: lines[0]=session health, lines[1]=resolved model only expect(lines.length).toBe(2); expect(lines[1]).toContain('openai/gpt-4'); expect(lines[1]).not.toContain('→'); - expect(lines[1]).toContain('[dim'); + expect(lines[1]).toContain('\x1b[2m'); }); it('shows initial prompt preview alongside model info on line 3', () => { @@ -1114,18 +1131,22 @@ describe('session-health', () => { mocks.mockGetSelectedModel.mockReturnValue('code'); const lines = fabricateFooterLines( - { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, 'Fix the bug by adding validation', ); expect(lines.length).toBe(2); - // Format: " │ → " — command preview on - // the left, model identifier on the right (no quotes). + // Format: " … → " — the + // prompt is left-aligned, the model is flush right, with flexible + // padding between them (no quotes). expect(lines[1]).toContain('Fix the bug by adding validation'); expect(lines[1]).toContain('code → openai/gpt-4'); - expect(lines[1]).toContain('Fix the bug by adding validation │ code → openai/gpt-4'); + expect(lines[1]).toContain('│ code → openai/gpt-4'); expect(lines[1]).not.toContain('"'); - expect(lines[1]).toContain('[dim'); + expect(lines[1]).toContain('\x1b[2m'); + // Right-aligned: the model's right edge is at the line's width. + const visible = unstyle(lines[1]); + expect(visible.endsWith('code → openai/gpt-4')).toBe(true); }); it('gives the command preview the majority of the line width', () => { @@ -1135,44 +1156,103 @@ describe('session-health', () => { const longPrompt = 'Implement the authentication flow with refresh token rotation and session persistence across restarts'; const lines = fabricateFooterLines( - { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, longPrompt, 500, ); expect(lines.length).toBe(2); - // Command budget = width(500) − model(17) − separator(5) = 478, so the - // prompt fits untruncated and the model sits after the separator at the - // end of the line. - expect(lines[1]).toContain(`${longPrompt} │ code → openai/gpt-4`); + // Right-aligned model: the prompt is left-aligned and the model is flush + // right. The separator directly precedes the model at the line's end. + expect(lines[1]).toContain(longPrompt); + expect(lines[1]).toContain('│ code → openai/gpt-4'); // Measure the two parts around the │ separator: the command part must // be (a) the full untruncated prompt and (b) longer than the model part. - // (The mock theme wraps the label as `[dim` + label + `]`.) - const content = lines[1].replace(/^\[dim/, '').replace(/\]$/, ''); - const sepIndex = content.indexOf('│'); + // (The mock theme wraps the label in ANSI dim codes; strip them first.) + const content = unstyle(lines[1]); + const sepIndex = content.lastIndexOf('│'); const commandPart = content.slice(0, sepIndex).trim(); const modelPart = content.slice(sepIndex + 1).trim(); expect(commandPart).toBe(longPrompt); expect(modelPart).toBe('code → openai/gpt-4'); expect(commandPart.length).toBeGreaterThan(modelPart.length); + // Right edge flush with terminal width. + expect(content.endsWith('code → openai/gpt-4')).toBe(true); }); - it('caps the model identifier width so the command preview gets the space', () => { + it('shows the complete model name (no 30-col cap)', () => { mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); mocks.mockGetSelectedModel.mockReturnValue('code'); const lines = fabricateFooterLines( - { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, 'Fix the bug', 500, ); // modelPart = "code → openai/gpt-4-abcdefghijklmnopqrstuvwxyz" (46 cols) - // → capped to 30 visible columns; the rest of the line goes to the prompt. + // → NOT capped; full model name visible. + expect(lines.length).toBe(2); + expect(lines[1]).toContain('code → openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); + expect(lines[1]).toContain('qrstuvwxyz'); + }); + + it('right-aligns the model so its right edge is at terminal width', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, + 'Fix the bug', + 500, + ); + + // Model is right-aligned: the line content (after removing theme + // ANSI codes) should fill the full width with the model flush right. + const visible = unstyle(lines[1]); + expect(visible.length).toBe(500); + // The last character should be the model's last char, not a space. + expect(visible[499]).not.toBe(' '); + expect(visible.endsWith('code → openai/gpt-4')).toBe(true); + }); + + it('truncates the prompt to yield space for the model on narrow widths', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + // With a 70-col terminal the long model (46 cols) + separator (5) = 51, + // leaving only 19 cols for the prompt — should be truncated. + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, + 'Fix the bug by adding validation here', + 70, + ); + + expect(lines.length).toBe(2); + // The prompt should be truncated with '...' + expect(lines[1]).toContain('...'); + // The model should still be complete + expect(lines[1]).toContain('openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); + // Line should not exceed width (ANSI codes aside) + expect(unstyle(lines[1]).length).toBeLessThanOrEqual(70); + }); + + it('drops the prompt when the model alone exceeds terminal width', () => { + mocks.mockGetResolvedModel.mockReturnValue('openai/gpt-4-abcdefghijklmnopqrstuvwxyz'); + mocks.mockGetSelectedModel.mockReturnValue('code'); + + // Model part = "code → openai/gpt-4-abcdefghijklmnopqrstuvwxyz" (46 cols). + // At width 40 the model cannot fit; the final clamp clips it. + const lines = fabricateFooterLines( + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, + 'Fix the bug by adding validation', + 40, + ); + expect(lines.length).toBe(2); - expect(lines[1]).toContain('Fix the bug │ code → openai/gpt-4-abcdefghij'); - expect(lines[1]).not.toContain('qrstuvwxyz'); + // Line must not exceed width (ANSI codes aside) + expect(unstyle(lines[1]).length).toBeLessThanOrEqual(40); }); it('never overflows the line at very narrow widths', () => { @@ -1182,13 +1262,13 @@ describe('session-health', () => { // Width 20 < model cap (30) + separator (5): the command budget is 0, // the label overflows, and the final clamp keeps the line at 20 cols. const lines = fabricateFooterLines( - { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, 'Fix the bug by adding validation', 20, ); expect(lines.length).toBe(2); - expect(lines[1].length).toBeLessThanOrEqual(20); + expect(unstyle(lines[1]).length).toBeLessThanOrEqual(20); }); it('truncates long initial prompt preview', () => { @@ -1200,18 +1280,18 @@ describe('session-health', () => { // With width=120, model="code → (resolving)" (19 cols), separator=5, // available=max(15, 120-19-5)=96, prompt length=151 > 96 const lines = fabricateFooterLines( - { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, longPrompt, 120, ); expect(lines.length).toBe(2); // Truncated prompt with ... followed by the model identifier after the - // separator; the final clamp keeps the line at most `width` columns - // (may clip the model tail at very narrow widths — documented edge case). + // separator; the final clamp keeps the line at most `width` visible + // columns (ANSI codes aside). expect(lines[1]).toContain('... │ code →'); - expect(lines[1].length).toBeLessThanOrEqual(120); - expect(lines[1]).toContain('[dim'); + expect(unstyle(lines[1]).length).toBeLessThanOrEqual(120); + expect(lines[1]).toContain('\x1b[2m'); }); it('shows just model info when no initial prompt available', () => { @@ -1219,7 +1299,7 @@ describe('session-health', () => { mocks.mockGetSelectedModel.mockReturnValue('code'); const lines = fabricateFooterLines( - { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) } } }, + { ...mockCtx, mode: 'tui', ui: { ...mockCtx.ui, theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) } } }, null, // no initial prompt ); @@ -1240,7 +1320,7 @@ describe('session-health', () => { mode: 'tui', ui: { ...mockCtx.ui, - theme: { fg: vi.fn((c: string, t: string) => `[${c}${t}]`) }, + theme: { fg: vi.fn((c: string, t: string) => `\x1b[2m${t}\x1b[0m`) }, setFooter: vi.fn((factory: Function) => { // Call factory to get dispose/invalidate/render const result = factory( diff --git a/packages/tui/extensions/Worklog/session-health.ts b/packages/tui/extensions/Worklog/session-health.ts index 68bb178b9..9f5bb73ec 100644 --- a/packages/tui/extensions/Worklog/session-health.ts +++ b/packages/tui/extensions/Worklog/session-health.ts @@ -621,39 +621,55 @@ export function registerSessionHealth(pi: ExtensionAPI): void { modelPart = '—'; } - // Cap the model identifier at a fixed maximum so the command - // preview gets as much of the line as possible. truncateToTerminalWidth - // appends an ellipsis when the model part exceeds the cap. - const modelPartMaxWidth = 30; - const cappedModelPart = truncateToTerminalWidth(modelPart, modelPartMaxWidth); - // Separator between the command preview (left) and the model // identifier (right). const separator = ' │ '; // Build initial prompt portion — unquoted preview with generous - // space allocation. The line is ultimately truncated to `width` by - // truncateToTerminalWidth, so we use a dynamic limit. + // space allocation. The model part is never capped; the prompt + // yields space to it so the full model name always renders. let promptPart: string | null = null; if (initialPrompt) { // Truncate work item IDs to compact form (e.g., WL-0MQL0T5TR0060AEH // → WL...68HD) before applying length truncation. const compacted = truncateWorkItemId(initialPrompt); - // Reserve only what the (capped) model part and separator need; - // everything else goes to the command preview. The final - // truncateToTerminalWidth(…, width) clamp below still guards - // against overflow at very narrow widths. const separatorWidth = visibleWidth(separator); - const modelWidth = visibleWidth(cappedModelPart); - const maxLen = Math.max(0, width - modelWidth - separatorWidth); + const modelWidth = visibleWidth(modelPart); + // The prompt gets whatever budget remains after reserving room + // for the full model and separator. + const promptBudget = Math.max(0, width - modelWidth - separatorWidth); const preview = - compacted.length > maxLen && maxLen > 3 - ? `${compacted.slice(0, maxLen - 3)}...` + promptBudget > 3 && compacted.length > promptBudget + ? `${compacted.slice(0, promptBudget - 3)}...` : compacted; - promptPart = preview; + promptPart = promptBudget > 3 ? preview : null; + } + + // Right-align the model: its right edge is flush with the terminal + // width. If there is no room for prompt + separator + model + // (ultra-narrow), drop the prompt/separator so the model alone + // right-aligns. The final clamp clips only when the model itself + // exceeds the terminal width (physically unavoidable). + const modelWidth = visibleWidth(modelPart); + const separatorWidth = visibleWidth(separator); + + const promptFits = + promptPart !== null && + width - visibleWidth(promptPart) - separatorWidth - modelWidth >= 0; + + let label: string; + if (promptFits) { + const pad = width - visibleWidth(promptPart) - separatorWidth - modelWidth; + label = `${promptPart}${' '.repeat(pad)}${separator}${modelPart}`; + } else { + // Model-only fallback, right-aligned. + if (width >= modelWidth) { + label = ' '.repeat(width - modelWidth) + modelPart; + } else { + label = modelPart; + } } - const label = promptPart ? `${promptPart}${separator}${cappedModelPart}` : modelPart; lines.push(truncateToTerminalWidth(theme.fg('dim', label), width)); return lines; diff --git a/scripts/install-herdr.sh b/scripts/install-herdr.sh index 9f57a793a..c63eee539 100755 --- a/scripts/install-herdr.sh +++ b/scripts/install-herdr.sh @@ -4,6 +4,10 @@ # Links the `worklog-selection-list` Herdr plugin and registers the # `prefix+l` keybinding in the Herdr config, so the worklist works out of # the box after `npm run build` (invoked via the root `postbuild` hook). +# Hook step 0 installs the plugin's npm dependencies (marked, the +# @worklog/shared file dep) into packages/herdr first, so a fresh clone or +# a node_modules cleanup does not leave the plugin crashing with +# ERR_MODULE_NOT_FOUND on startup. # # Safe to run on every build: # - The plugin is always linked from the MAIN checkout, even when the @@ -11,7 +15,9 @@ # linking from a worktree leaves a dangling registry entry once the # worktree is deleted, silently breaking the prefix+l keybinding. # `herdr plugin link` updates an existing link in place, so a stale -# worktree link is corrected automatically on the next build. +# worktree link is corrected automatically on the next build. The +# dependency install (step 0) also targets the MAIN checkout for the +# same reason (WL-0MSRLH52R003CK49). # - The keybinding block is inserted only when a binding for # `worklog-selection-list.open-podcast-editor-tab` is not already present. # - A legacy `open-worklist` prefix+l binding is migrated in-place to the @@ -49,7 +55,7 @@ CONFIG_PATH="${HERDR_CONFIG_PATH:-${HOME}/.config/herdr/config.toml}" KEYBINDING_BLOCK='[[keys.command]] key = "prefix+l" command = "herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab" -description = "Open the Podcast Editing tab (Worklog work item selection pane)."' +description = "Open the Worklog tab (Worklog work item selection pane)."' # Legacy binding command (v0.1.x) that prefix+l previously pointed at. # Migrated in-place to the new action so re-running a build never leaves @@ -57,6 +63,33 @@ description = "Open the Podcast Editing tab (Worklog work item selection pane)." LEGACY_BINDING='herdr plugin action invoke worklog-selection-list.open-worklist' NEW_BINDING='herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab' +# ── 0. Install herdr plugin dependencies ───────────────────────────────── +# Ensures marked and @worklog/shared are available so the plugin does not +# crash on startup (ERR_MODULE_NOT_FOUND). Uses npm ci when a lockfile +# exists (faster, deterministic), falling back to npm install. +HERDR_DIR="${REPO_ROOT}/packages/herdr" +if [ -d "${HERDR_DIR}" ] && [ -f "${HERDR_DIR}/package.json" ]; then + if command -v npm >/dev/null 2>&1; then + if [ -f "${HERDR_DIR}/package-lock.json" ]; then + if npm ci --prefix "${HERDR_DIR}" 2>/dev/null; then + echo "Installed herdr plugin dependencies (npm ci: ${HERDR_DIR})" + else + echo "Warning: 'npm ci' failed in ${HERDR_DIR}, trying 'npm install'..." >&2 + npm install --prefix "${HERDR_DIR}" 2>/dev/null || \ + echo "Warning: both 'npm ci' and 'npm install' failed in ${HERDR_DIR} — plugin may crash on startup." >&2 + fi + else + if npm install --prefix "${HERDR_DIR}" 2>/dev/null; then + echo "Installed herdr plugin dependencies (npm install: ${HERDR_DIR})" + else + echo "Warning: 'npm install' failed in ${HERDR_DIR} — plugin may crash on startup." >&2 + fi + fi + else + echo "Warning: 'npm' not found on PATH — skipping herdr dependency install." >&2 + fi +fi + # ── 1. Link the plugin (updates an existing link in place) ─────────────── if command -v herdr >/dev/null 2>&1; then if herdr plugin link "${MANIFEST_PATH}"; then diff --git a/src/api.ts b/src/api.ts index 480c00c52..0b4145479 100644 --- a/src/api.ts +++ b/src/api.ts @@ -146,12 +146,13 @@ export function createAPI(db: WorklogDatabase) { res.status(404).json({ error: 'Work item not found' }); return; } - let normalizedBody: any = req.body; // If the caller provided an `audit` field, route it to the audit_results table // instead of the legacy `workitems.audit` column. - const rawAudit = (req.body as any).audit; - delete normalizedBody.audit; - const { input: updateInput, auditResult: updateAuditResult } = normalizeUpdateInputWithAudit(normalizedBody, req.params.id, db); + // NOTE: normalizeUpdateInputWithAudit strips the `audit` field from the + // input and returns it as an auditResult; deleting it here first would + // defeat the audit write entirely (pre-existing bug fixed in + // WL-0MT0T1EQJ009DHO8). + const { input: updateInput, auditResult: updateAuditResult } = normalizeUpdateInputWithAudit(req.body, req.params.id, db); const item = db.update(req.params.id, updateInput); if (!item) { res.status(404).json({ error: 'Work item not found' }); @@ -161,7 +162,19 @@ export function createAPI(db: WorklogDatabase) { if (updateAuditResult) { writeAuditResult(db, updateAuditResult); } - res.json(item); + // Reversion: a "not ready to close" audit verdict on an in_review + // (completed) item moves it back to open/plan_complete so it leaves the + // ready-to-close queue — consistent with the CLI audit write paths + // (WL-0MSKHYI5U0069FVV). Best-effort; failures never fail the request. + let reverted: any = null; + if (updateAuditResult && !updateAuditResult.readyToClose) { + try { + reverted = db.revertToPlanComplete(req.params.id); + } catch (_e) { + // Best-effort: surface in the response but do not fail the request + } + } + res.json(reverted ? { ...reverted.item, reverted } : item); } catch (error) { const message = (error as Error).message || 'Invalid request'; res.status(400).json({ error: message }); @@ -356,10 +369,12 @@ export function createAPI(db: WorklogDatabase) { res.status(404).json({ error: 'Work item not found' }); return; } - let normalizedBody: any = req.body; // Route audit field to the audit_results table - delete normalizedBody.audit; - const { input: updateInput, auditResult: updateAuditResult } = normalizeUpdateInputWithAudit(normalizedBody, req.params.id, db); + // NOTE: normalizeUpdateInputWithAudit strips the `audit` field from the + // input and returns it as an auditResult; deleting it here first would + // defeat the audit write entirely (pre-existing bug fixed in + // WL-0MT0T1EQJ009DHO8). + const { input: updateInput, auditResult: updateAuditResult } = normalizeUpdateInputWithAudit(req.body, req.params.id, db); const item = db.update(req.params.id, updateInput); if (!item) { res.status(404).json({ error: 'Work item not found' }); @@ -369,7 +384,18 @@ export function createAPI(db: WorklogDatabase) { if (updateAuditResult) { writeAuditResult(db, updateAuditResult); } - res.json(item); + // Reversion: a "not ready to close" audit verdict on an in_review + // (completed) item moves it back to open/plan_complete — consistent + // with the CLI audit write paths (WL-0MSKHYI5U0069FVV). Best-effort. + let reverted: any = null; + if (updateAuditResult && !updateAuditResult.readyToClose) { + try { + reverted = db.revertToPlanComplete(req.params.id); + } catch (_e) { + // Best-effort: surface in the response but do not fail the request + } + } + res.json(reverted ? { ...reverted.item, reverted } : item); } catch (error) { const message = (error as Error).message || 'Invalid request'; res.status(400).json({ error: message }); diff --git a/src/cli-types.ts b/src/cli-types.ts index 5f3446a8a..b0a9d2f86 100644 --- a/src/cli-types.ts +++ b/src/cli-types.ts @@ -66,7 +66,7 @@ export interface ListOptions { icons?: boolean; } -export interface ShowOptions { children?: boolean; prefix?: string; noPager?: boolean; icons?: boolean } +export interface ShowOptions { children?: boolean; prefix?: string; noPager?: boolean; icons?: boolean; exact?: boolean } export interface AuditOptions { prefix?: string } diff --git a/src/commands/audit-result.ts b/src/commands/audit-result.ts index cdb66e1bf..d67029e5d 100644 --- a/src/commands/audit-result.ts +++ b/src/commands/audit-result.ts @@ -180,10 +180,25 @@ export default function register(ctx: PluginContext): void { process.exit(1); } + // Reversion: when the verdict is "not ready to close" on an item in + // `in_review` (status `completed`), move it back to `open` / + // `plan_complete` so it drops out of the ready-to-close queue and + // returns to the planning queue (WL-0MSKHYI5U0069FVV). Best-effort: + // a reversion failure must not abort the command — surface a warning. + let reverted = null; + if (rtc === 'no') { + try { + reverted = db.revertToPlanComplete(normalizedId); + } catch (err) { + console.error(`Warning: failed to revert ${normalizedId} to open/plan_complete: ${err instanceof Error ? err.message : String(err)}`); + } + } + if (options.json || utils.isJsonMode()) { output.json({ success: true, workItemId: normalizedId, + ...(reverted ? { reverted } : {}), audit: { workItemId: normalizedId, readyToClose, @@ -200,5 +215,8 @@ export default function register(ctx: PluginContext): void { console.log(` Ready to close: ${readyToClose ? 'Yes' : 'No'}`); console.log(` Audited at: ${auditedAt}`); if (author) console.log(` Author: ${author}`); + if (reverted) { + console.log(`[${reverted.item.id} reverted from ${reverted.from.status}/${reverted.from.stage} to ${reverted.to.status}/${reverted.to.stage}]`); + } }); } \ No newline at end of file diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index cba7e477b..638a14a82 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -514,6 +514,18 @@ export default function register(ctx: PluginContext): void { 'Rewrite worklog ref: remove foreign items', gitTarget ); + // The rewritten ref is a full clean snapshot — advance the + // per-type watermarks so the next sync runs an incremental delta + // from this baseline (WL-0MT2KY0RQ008F50Q AC5: advance only after + // a successful push). + const now = new Date().toISOString(); + db.markLastExportTimestamps({ + workitems: now, + comments: now, + edges: now, + audit_results: now, + }); + db.setDeltaSyncMetadata(0, 0); // Ephemeral JSONL pattern: remove the local file after a successful push. try { db.deleteLocalJsonl(); } catch (_) { /* best-effort */ } return tipSha; diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 9bc404ac1..e4666efdd 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -10,7 +10,7 @@ import type { WorklogDatabase } from '../database.js'; import { loadConfig } from '../config.js'; import { renderCliMarkdown, shouldUseFormattedOutput, isTty, resolveMarkdownEnabled } from '../cli-output.js'; import { getStageLabel, getStatusLabel, loadStatusStageRules } from '../status-stage-rules.js'; -import { priorityIcon, statusIcon, priorityFallback, statusFallback, iconsEnabled } from '../icons.js'; +import { priorityIcon, statusIcon, priorityFallback, statusFallback, iconsEnabled } from '../theme.js'; import type { Command } from 'commander'; // Priority ordering for sorting work items (higher number = higher priority) diff --git a/src/commands/init.ts b/src/commands/init.ts index a8fc14778..53e8f318d 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -8,6 +8,7 @@ import type { DependencyEdge } from '../types.js'; import { initConfig, loadConfig, configExists, isInitialized, readInitSemaphore, writeInitSemaphore, type InitConfigOptions } from '../config.js'; import { resolveWorklogDir } from '../worklog-paths.js'; import { exportToJsonlAsync } from '../jsonl.js'; +import { PRE_PUSH_HOOK_CONTENT } from '../doctor/hook-upgrade.js'; import { getRemoteDataFileContent, gitPushDataFileToBranch, mergeWorkItems, mergeComments, mergeDependencyEdges, mergeAuditResults } from '../sync.js'; import { DEFAULT_GIT_REMOTE, DEFAULT_GIT_BRANCH } from '../sync-defaults.js'; import { importFromJsonlContent } from '../jsonl.js'; @@ -18,7 +19,7 @@ import * as readline from 'readline'; import { fileURLToPath } from 'url'; import { theme } from '../theme.js'; -const WORKLOG_PRE_PUSH_HOOK_MARKER = 'worklog:pre-push-hook:v1'; +const WORKLOG_PRE_PUSH_HOOK_MARKER = 'worklog:pre-push-hook:v2'; const WORKLOG_POST_PULL_HOOK_MARKER = 'worklog:post-pull-hook:v1'; const WORKLOG_POST_CHECKOUT_HOOK_MARKER = 'worklog:post-checkout-hook:v1'; const WORKLOG_GITIGNORE_SECTION_START = 'Worklog Specific Ignores'; @@ -28,7 +29,24 @@ const WORKLOG_AGENT_DESTINATION_FILENAME = 'AGENTS.md'; const WORKFLOW_TEMPLATE_RELATIVE_PATH = 'templates/WORKFLOW.md'; const WORKFLOW_DESTINATION_FILENAME = 'WORKFLOW.md'; const WORKLOG_GITIGNORE_TEMPLATE_RELATIVE_PATH = 'templates/GITIGNORE_WORKLOG.txt'; -const WORKLOG_AGENT_POINTER_LINE = 'Follow the global AGENTS.md in addition to the rules below. The local rules below take priority in the event of a conflict.'; +/** + * Marker used to detect that the canonical "Global agent guidance" reference + * structure is already present in a project AGENTS.md. The template + * (templates/AGENTS.md) self-references the global file, so projects that + * install or append it must NOT duplicate the reference on re-run. + */ +const CANONICAL_GLOBAL_REFERENCE_MARKER = '## Global agent guidance'; +const CANONICAL_GLOBAL_REFERENCE_LINE = 'Read the global agent instructions at `~/.pi/agent/AGENTS.md`'; + +/** + * Scheduled-prompts provisioning (WL-0MSS1Q5ER007QDKX AC1): `wl init` copies + * templates/scheduled-prompts.json into .worklog/scheduled-prompts.json when + * the file is absent (create-if-absent — never clobbers user edits or + * lastTriggeredAt state). The file is consumed by the herdr downtime + * worker's scheduled-prompts dispatch tier. + */ +const SCHEDULED_PROMPTS_TEMPLATE_RELATIVE_PATH = 'templates/scheduled-prompts.json'; +const SCHEDULED_PROMPTS_DESTINATION_FILENAME = 'scheduled-prompts.json'; const DEFAULT_COMMITTED_HOOKS_DIR = '.githooks'; @@ -165,57 +183,10 @@ function installPrePushHook(options: { silent: boolean }): { installed: boolean; const hooksDir = path.isAbsolute(hooksPath) ? hooksPath : path.join(repoRoot, hooksPath); const hookFile = path.join(hooksDir, 'pre-push'); - const hookScript = - `#!/bin/sh\n` + - `# ${WORKLOG_PRE_PUSH_HOOK_MARKER}\n` + - `# Auto-sync Worklog data before pushing.\n` + - `# Set WORKLOG_SKIP_PRE_PUSH=1 to bypass.\n` + - `\n` + - `set -e\n` + - `\n` + - `if [ \"$WORKLOG_SKIP_PRE_PUSH\" = \"1\" ]; then\n` + - ` exit 0\n` + - `fi\n` + - `\n` + - `# Skip when inside a temp worktree created by withTempWorktree.\n` + - `case \"$PWD\" in\n` + - ` *tmp-worktree-*)\n` + - ` exit 0\n` + - ` ;;\n` + - `esac\n` + - `\n` + - `# Skip when inside a git worktree (not the main checkout).\n` + - `if [ \"$(git rev-parse --git-dir 2>/dev/null)\" != \"$(git rev-parse --git-common-dir 2>/dev/null)\" ]; then\n` + - ` exit 0\n` + - `fi\n` + - `\n` + - `# Avoid recursion when worklog sync pushes refs/worklog/data.\n` + - `skip=0\n` + - `while read local_ref local_sha remote_ref remote_sha; do\n` + - ` if [ \"$remote_ref\" = \"refs/worklog/data\" ]; then\n` + - ` skip=1\n` + - ` fi\n` + - `done\n` + - `\n` + - `if [ \"$skip\" = \"1\" ]; then\n` + - ` exit 0\n` + - `fi\n` + - `\n` + - `if command -v wl >/dev/null 2>&1; then\n` + - ` WL=wl\n` + - `elif command -v worklog >/dev/null 2>&1; then\n` + - ` WL=worklog\n` + - `else\n` + - ` echo \"worklog: wl/worklog not found; skipping pre-push sync\" >&2\n` + - ` exit 0\n` + - `fi\n` + - `\n` + - `$WL sync --git-branch refs/worklog/data || {\n` + - ` echo \"worklog: pre-push sync failed (pushing anyway)\" >&2\n` + - ` exit 0\n` + - `}\n` + - `\n` + - `exit 0\n`; + // Canonical v2 pre-push hook (branch policy + context-budget gate + sync), + // shared with hook-upgrade.ts and matching the committed `.githooks/pre-push` + // template (WL-0MT33QNTL006LHQA). + const hookScript = PRE_PUSH_HOOK_CONTENT; try { fs.mkdirSync(hooksDir, { recursive: true }); @@ -420,76 +391,7 @@ function installCommittedHooks(options: { silent: boolean }): { installed: boole `exec \"${centralPath}\" \"$@\"\n` ); - const prePushContent = [ - '#!/bin/sh', - `# ${WORKLOG_PRE_PUSH_HOOK_MARKER}`, - '#', - '# Auto-sync Worklog data before pushing.', - '#', - '# This hook runs `wl sync` before any push to ensure worklog data is', - '# committed and pushed to the dedicated refs/worklog/data branch.', - '# It uses a temporary worktree to avoid corrupting the project working tree', - '# (see WL-0MQRBT8BS00355AB for the bug this prevents).', - '#', - '# BYPASS: Set WORKLOG_SKIP_PRE_PUSH=1 to skip the sync entirely.', - '# export WORKLOG_SKIP_PRE_PUSH=1', - '# git push origin HEAD:refs/heads/dev', - '#', - 'set -e', - '', - 'if [ "$WORKLOG_SKIP_PRE_PUSH" = "1" ]; then', - ' echo "worklog: pre-push sync skipped (WORKLOG_SKIP_PRE_PUSH=1)"', - ' exit 0', - 'fi', - '', - '# Skip when running inside a temp worktree created by withTempWorktree', - "# for internal sync operations. These worktrees don't have worklog", - '# initialized and the push is already done with --no-verify as defense.', - 'case "$PWD" in', - ' *tmp-worktree-*)', - ' exit 0', - ' ;;', - 'esac', - '', - '# Skip when inside a git worktree (not the main checkout).', - "# Worktrees are for feature development and typically don't have worklog", - '# initialized. The sync should run from the main checkout.', - 'if [ "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" ]; then', - ' exit 0', - 'fi', - '', - '# Read stdin to check which refs are being pushed.', - '# If pushing to refs/worklog/data, skip the sync to avoid infinite loops.', - 'skip=0', - 'while read local_ref local_sha remote_ref remote_sha; do', - ' if [ "$remote_ref" = "refs/worklog/data" ]; then', - ' skip=1', - ' fi', - 'done', - '', - 'if [ "$skip" = "1" ]; then', - ' exit 0', - 'fi', - '', - 'if command -v wl >/dev/null 2>&1; then', - ' WL=wl', - 'elif command -v worklog >/dev/null 2>&1; then', - ' WL=worklog', - 'else', - ' echo "worklog: wl/worklog not found; skipping pre-push sync" >&2', - ' exit 0', - 'fi', - '', - '# Force the data branch to refs/worklog/data regardless of config.', - '# This prevents the sync from accidentally pushing to a standard branch', - '# if the user has overridden syncBranch in their worklog config.', - '"$WL" sync --git-branch refs/worklog/data || {', - ' echo "worklog: pre-push sync failed (pushing anyway)" >&2', - ' exit 0', - '}', - 'exit 0', - '', - ].join('\n'); + const prePushContent = PRE_PUSH_HOOK_CONTENT; const postCheckoutContent = `#!/bin/sh\n` + @@ -633,6 +535,44 @@ function locateGitignoreTemplate(): string | null { return fs.existsSync(candidate) ? candidate : null; } +function locateScheduledPromptsTemplate(): string | null { + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const packageRoot = path.resolve(moduleDir, '..', '..'); + const candidate = path.join(packageRoot, SCHEDULED_PROMPTS_TEMPLATE_RELATIVE_PATH); + return fs.existsSync(candidate) ? candidate : null; +} + +/** + * Provision the scheduled-prompts config (WL-0MSS1Q5ER007QDKX AC1): copy + * templates/scheduled-prompts.json into `/scheduled-prompts.json` + * CREATE-IF-ABSENT — an existing file (user edits or runtime lastTriggeredAt + * state) is never clobbered. A missing template is skipped (never fatal). + */ +function ensureScheduledPromptsInstalled(options: { + silent: boolean; +}): { installed: boolean; skipped: boolean; destinationPath?: string; reason?: string } { + const templatePath = locateScheduledPromptsTemplate(); + if (!templatePath) { + return { installed: false, skipped: true, reason: 'scheduled-prompts template not found' }; + } + const worklogDir = resolveWorklogDir(); + const destinationPath = path.join(worklogDir, SCHEDULED_PROMPTS_DESTINATION_FILENAME); + try { + if (fs.existsSync(destinationPath)) { + // Create-if-absent: never clobber user edits or lastTriggeredAt state. + return { installed: false, skipped: true, reason: 'already in place', destinationPath }; + } + fs.mkdirSync(worklogDir, { recursive: true }); + fs.copyFileSync(templatePath, destinationPath); + if (!options.silent) { + console.log(`✓ Provisioned scheduled prompts at ${destinationPath}`); + } + return { installed: true, skipped: false, destinationPath }; + } catch (e) { + return { installed: false, skipped: true, reason: (e as Error).message, destinationPath }; + } +} + function hasWorklogGitignoreSection(content: string): boolean { return content.includes(WORKLOG_GITIGNORE_SECTION_START) && content.includes(WORKLOG_GITIGNORE_SECTION_END); } @@ -781,12 +721,19 @@ function normalizeContent(content: string): string { return content.replace(/\r\n/g, '\n').trimEnd(); } -function analyzeAgentContent(existingContent: string): { hasPointer: boolean; trimmed: string; eol: string; hasOnlyWhitespace: boolean } { +function analyzeAgentContent(existingContent: string): { hasGlobalReference: boolean; trimmed: string; eol: string; hasOnlyWhitespace: boolean } { const lines = existingContent.split(/\r?\n/); const firstNonEmpty = lines.find(line => line.trim().length > 0); const eol = existingContent.includes('\r\n') ? '\r\n' : '\n'; return { - hasPointer: firstNonEmpty === WORKLOG_AGENT_POINTER_LINE, + // Detect the canonical global-reference structure (template emits + // "## Global agent guidance" referencing ~/.pi/agent/AGENTS.md). Also + // treat the legacy pointer line as already-referenced so previously + // installed projects stay idempotent on re-run. + hasGlobalReference: + existingContent.includes(CANONICAL_GLOBAL_REFERENCE_MARKER) || + existingContent.includes(CANONICAL_GLOBAL_REFERENCE_LINE) || + firstNonEmpty?.startsWith('Follow the global AGENTS.md') === true, trimmed: existingContent.trimEnd(), eol, hasOnlyWhitespace: firstNonEmpty === undefined @@ -803,7 +750,7 @@ async function promptAgentTemplateAction(): Promise { 'O - Overwrite the existing AGENTS.md\n' + '\tPRO: no chance of a conflict between existing AGENTS.md and the Workflow instructions\n' + '\tCON: DESTRUCTIVE to your existing configuration\n\n' + - 'A - Add a pointer to the global AGENTS.md\n' + + 'A - Add the global-agents reference above your existing rules\n' + '\tPRO: retains your existing instructions and simply adds to them\n' + '\tCON: potentially conflicting instructions between the two files\n\n' + 'M - Manual management, read the docs and set things up as you like\n' + @@ -839,17 +786,16 @@ async function ensureAgentTemplateInstalled(options: { silent: boolean; action?: const templateContent = normalizeContent(fs.readFileSync(templatePath, 'utf-8')); if (fs.existsSync(destinationPath)) { const existingRaw = fs.readFileSync(destinationPath, 'utf-8'); - const { hasPointer, trimmed: existingContent, eol, hasOnlyWhitespace } = analyzeAgentContent(existingRaw); - if (hasPointer) { - return { installed: false, skipped: true, reason: 'pointer already present', templatePath, destinationPath }; + const { hasGlobalReference, trimmed: existingContent, eol, hasOnlyWhitespace } = analyzeAgentContent(existingRaw); + if (hasGlobalReference) { + return { installed: false, skipped: true, reason: 'global reference already present', templatePath, destinationPath }; } if (options.action === 'skip') { return { installed: false, skipped: true, reason: 'user chose to manage manually', templatePath, destinationPath }; } if (options.action === 'overwrite') { - const pointerTemplate = `${WORKLOG_AGENT_POINTER_LINE}\n\n${templateContent}`; - fs.writeFileSync(destinationPath, `${pointerTemplate}\n`, { encoding: 'utf-8' }); + fs.writeFileSync(destinationPath, `${templateContent}\n`, { encoding: 'utf-8' }); if (!options.silent) { console.log(`✓ Overwrote AGENTS template at ${destinationPath}`); } @@ -857,14 +803,16 @@ async function ensureAgentTemplateInstalled(options: { silent: boolean; action?: } if (options.action === 'append') { + // Prepend the canonical global-reference template above existing + // content so project-specific local rules stay below the reference. const insertion = hasOnlyWhitespace - ? `${WORKLOG_AGENT_POINTER_LINE}${eol}${eol}${templateContent}` - : `${WORKLOG_AGENT_POINTER_LINE}${existingContent ? `${eol}${eol}${existingContent}` : ''}`; + ? templateContent + : `${templateContent}${existingContent ? `${eol}${eol}${existingContent}` : ''}`; fs.writeFileSync(destinationPath, `${insertion}${eol}`, { encoding: 'utf-8' }); if (!options.silent) { - console.log(`✓ Updated AGENTS.md with Worklog pointer at ${destinationPath}`); + console.log(`✓ Updated AGENTS.md with global-agents reference at ${destinationPath}`); } - return { installed: true, skipped: false, templatePath, destinationPath, insertedPointer: true }; + return { installed: true, skipped: false, templatePath, destinationPath, insertedReference: true }; } if (options.silent) { @@ -878,8 +826,7 @@ async function ensureAgentTemplateInstalled(options: { silent: boolean; action?: } if (resolvedAction === 'overwrite') { - const pointerTemplate = `${WORKLOG_AGENT_POINTER_LINE}\n\n${templateContent}`; - fs.writeFileSync(destinationPath, `${pointerTemplate}\n`, { encoding: 'utf-8' }); + fs.writeFileSync(destinationPath, `${templateContent}\n`, { encoding: 'utf-8' }); if (!options.silent) { console.log(`✓ Overwrote AGENTS template at ${destinationPath}`); } @@ -887,17 +834,16 @@ async function ensureAgentTemplateInstalled(options: { silent: boolean; action?: } const insertion = hasOnlyWhitespace - ? `${WORKLOG_AGENT_POINTER_LINE}${eol}${eol}${templateContent}` - : `${WORKLOG_AGENT_POINTER_LINE}${existingContent ? `${eol}${eol}${existingContent}` : ''}`; + ? templateContent + : `${templateContent}${existingContent ? `${eol}${eol}${existingContent}` : ''}`; fs.writeFileSync(destinationPath, `${insertion}${eol}`, { encoding: 'utf-8' }); if (!options.silent) { - console.log(`✓ Updated AGENTS.md with Worklog pointer at ${destinationPath}`); + console.log(`✓ Updated AGENTS.md with global-agents reference at ${destinationPath}`); } - return { installed: true, skipped: false, templatePath, destinationPath, insertedPointer: true }; + return { installed: true, skipped: false, templatePath, destinationPath, insertedReference: true }; } - const pointerTemplate = `${WORKLOG_AGENT_POINTER_LINE}\n\n${templateContent}`; - fs.writeFileSync(destinationPath, `${pointerTemplate}\n`, { encoding: 'utf-8' }); + fs.writeFileSync(destinationPath, `${templateContent}\n`, { encoding: 'utf-8' }); if (!options.silent) { console.log(`✓ Installed AGENTS template at ${destinationPath}`); } @@ -973,7 +919,7 @@ export default function register(ctx: PluginContext): void { .option('--prefix ', 'Issue ID prefix (e.g., WI, PROJ, TASK)') .option('--auto-export ', 'Auto-export data to JSONL after changes') .option('--auto-sync ', 'Auto-sync data to git after changes') - .option('--agents-template ', 'What to do when AGENTS.md exists (append inserts the pointer line; omit to prompt)') + .option('--agents-template ', 'What to do when AGENTS.md exists (append inserts the global-agents reference above existing content; omit to prompt)') .option('--workflow-inline ', 'Inline workflow into AGENTS.md when prompted (omit to prompt interactively)') .option('--stats-plugin-overwrite ', 'Overwrite existing stats plugin if present (default: no)') .action(async (_options: InitOptions) => { @@ -1031,6 +977,7 @@ export default function register(ctx: PluginContext): void { } } const statsPluginResult = await ensureStatsPluginInstalled({ silent: true, overwrite: normalizedOptions.statsPluginOverwrite }); + const scheduledPromptsResult = ensureScheduledPromptsInstalled({ silent: true }); output.json({ success: true, message: 'Configuration already exists', @@ -1045,7 +992,8 @@ export default function register(ctx: PluginContext): void { postPullHooks: postPullResult, committedHooks: committedHooksResult, agentTemplate: agentTemplateResult, - statsPlugin: statsPluginResult + statsPlugin: statsPluginResult, + scheduledPrompts: scheduledPromptsResult }); return; } else { @@ -1128,10 +1076,10 @@ export default function register(ctx: PluginContext): void { silent: false, action: normalizedOptions.agentsTemplateAction }); - if (!agentTemplateResult.installed && agentTemplateResult.reason === 'pointer already present') { - console.log('AGENTS.md already contains the Worklog pointer.'); + if (!agentTemplateResult.installed && agentTemplateResult.reason === 'global reference already present') { + console.log('AGENTS.md already contains the global-agents reference.'); } - if (!agentTemplateResult.installed && agentTemplateResult.reason && agentTemplateResult.reason !== 'pointer already present') { + if (!agentTemplateResult.installed && agentTemplateResult.reason && agentTemplateResult.reason !== 'global reference already present') { console.log(`Note: AGENTS template not installed: ${agentTemplateResult.reason}`); } console.log(''); @@ -1244,8 +1192,8 @@ export default function register(ctx: PluginContext): void { // agent template if (agentTemplateResult.installed) { console.log(' - AGENTS.md: installed'); - } else if (agentTemplateResult.skipped && agentTemplateResult.reason === 'pointer already present') { - console.log(' - AGENTS.md: pointer already present'); + } else if (agentTemplateResult.skipped && agentTemplateResult.reason === 'global reference already present') { + console.log(' - AGENTS.md: global reference already present'); } else if (agentTemplateResult.skipped) { console.log(` - AGENTS.md: skipped${agentTemplateResult.reason ? `: ${agentTemplateResult.reason}` : ''}`); } @@ -1259,6 +1207,15 @@ export default function register(ctx: PluginContext): void { } else if (statsPluginResult.skipped) { console.log(` - Stats plugin: skipped${statsPluginResult.reason ? `: ${statsPluginResult.reason}` : ''}`); } + // scheduled prompts (WL-0MSS1Q5ER007QDKX AC1) + const scheduledPromptsResult = ensureScheduledPromptsInstalled({ silent: true }); + if (scheduledPromptsResult.installed) { + console.log(` - Scheduled prompts: provisioned at ${scheduledPromptsResult.destinationPath} (base set: /skill:refactor every 3 days)`); + } else if (scheduledPromptsResult.skipped && scheduledPromptsResult.reason === 'already in place') { + console.log(' - Scheduled prompts: already in place (not overwritten)'); + } else if (scheduledPromptsResult.skipped) { + console.log(` - Scheduled prompts: skipped${scheduledPromptsResult.reason ? `: ${scheduledPromptsResult.reason}` : ''}`); + } console.log('\nNote: `wl init` is idempotent and can safely be run again if any options need to be changed.'); return; @@ -1307,6 +1264,7 @@ export default function register(ctx: PluginContext): void { } } const statsPluginResult = await ensureStatsPluginInstalled({ silent: true, overwrite: normalizedOptions.statsPluginOverwrite }); + const scheduledPromptsResult = ensureScheduledPromptsInstalled({ silent: true }); output.json({ success: true, message: 'Configuration initialized', @@ -1321,7 +1279,8 @@ export default function register(ctx: PluginContext): void { postPullHooks: postPullResult, committedHooks: committedHooksResult, agentTemplate: agentTemplateResult, - statsPlugin: statsPluginResult + statsPlugin: statsPluginResult, + scheduledPrompts: scheduledPromptsResult }); } @@ -1462,10 +1421,10 @@ export default function register(ctx: PluginContext): void { // We no longer print a workflowReport summary; helpers print output } - if (!agentTemplateResult.installed && agentTemplateResult.reason === 'pointer already present') { - console.log('AGENTS.md already contains the Worklog pointer.'); + if (!agentTemplateResult.installed && agentTemplateResult.reason === 'global reference already present') { + console.log('AGENTS.md already contains the global-agents reference.'); } - if (!agentTemplateResult.installed && agentTemplateResult.reason && agentTemplateResult.reason !== 'pointer already present') { + if (!agentTemplateResult.installed && agentTemplateResult.reason && agentTemplateResult.reason !== 'global reference already present') { console.log(`Note: AGENTS template not installed: ${agentTemplateResult.reason}`); } // Offer to install example stats plugin @@ -1480,6 +1439,18 @@ export default function register(ctx: PluginContext): void { } else if (statsPluginResult.skipped && statsPluginResult.reason) { console.log(`Stats plugin: ${statsPluginResult.reason}`); } + + // Scheduled-prompts provisioning (WL-0MSS1Q5ER007QDKX AC1): + // create-if-absent — never clobber user edits or lastTriggeredAt + // state on re-init. + const scheduledPromptsResult = ensureScheduledPromptsInstalled({ silent: false }); + if (scheduledPromptsResult.installed) { + console.log(`✓ Provisioned scheduled prompts at ${scheduledPromptsResult.destinationPath} (base set: /skill:refactor every 3 days)`); + } else if (scheduledPromptsResult.skipped && scheduledPromptsResult.reason === 'already in place') { + console.log('Scheduled prompts config already present (not overwritten).'); + } else if (scheduledPromptsResult.skipped && scheduledPromptsResult.reason) { + console.log(`Scheduled prompts: ${scheduledPromptsResult.reason}`); + } } } catch (error) { output.error('Error: ' + (error as Error).message, { success: false, error: (error as Error).message }); diff --git a/src/commands/search.ts b/src/commands/search.ts index b87f85d57..bca4793f7 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -8,6 +8,7 @@ import type { PluginContext } from '../plugin-types.js'; import type { SearchOptions } from '../cli-types.js'; +import type { FtsSearchResult } from '../persistent-store.js'; import { formatTitleAndId } from './helpers.js'; import { theme } from '../theme.js'; import { resolveWorklogDir } from '../worklog-paths.js'; @@ -132,20 +133,32 @@ export default function register(ctx: PluginContext): void { } // Execute search - const rawResults = db.search(query, { - status: options.status, - parentId, - tags, - limit: isNaN(limit) || limit < 1 ? 20 : limit, - priority: options.priority, - assignee: options.assignee, - stage: options.stage, - deleted: options.deleted, - needsProducerReview, - issueType: options.issueType, - }); - - let { results, ftsUsed } = rawResults; + let rawResults: { results: FtsSearchResult[]; ftsUsed: boolean; warning?: string }; + try { + rawResults = db.search(query, { + status: options.status, + parentId, + tags, + limit: isNaN(limit) || limit < 1 ? 20 : limit, + priority: options.priority, + assignee: options.assignee, + stage: options.stage, + deleted: options.deleted, + needsProducerReview, + issueType: options.issueType, + }); + } catch (err) { + // An invalid query (e.g. an unquoted punctuated term that could not + // be auto-quoted either) must never look like a silent "no results". + const message = err instanceof Error ? err.message : String(err); + output.error(`Invalid search query: ${message}`, { + success: false, + error: 'invalid-query', + }); + process.exit(1); + } + + let { results, ftsUsed, warning } = rawResults; // ── Semantic search enhancement ────────────────────────────── if (options.semantic || options.semanticOnly) { @@ -229,6 +242,9 @@ export default function register(ctx: PluginContext): void { workItems: jsonResults, results: jsonResults, }; + if (warning) { + outputPayload.warning = warning; + } if (options.semantic || options.semanticOnly) { outputPayload.semanticAvailable = rawResults.ftsUsed; } @@ -247,6 +263,11 @@ export default function register(ctx: PluginContext): void { console.log(''); } + if (warning) { + console.log(theme.text.warning(`Warning: ${warning}`)); + console.log(''); + } + if (results.length === 0) { console.log('No results found.'); return; @@ -274,7 +295,7 @@ export default function register(ctx: PluginContext): void { const snippetLabel = theme.text.muted(`[${result.matchedColumn}]`); // Replace highlight markers << >> with chalk bold const highlighted = result.snippet - .replace(/<<(.*?)>>/g, (_, match) => theme.text.warning(match)); + .replace(/<<(.*?)>>/g, (_: string, match: string) => theme.text.warning(match)); console.log(` ${snippetLabel} ${highlighted}`); } diff --git a/src/commands/show.ts b/src/commands/show.ts index 6bd8483d1..1c2843a51 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -19,6 +19,7 @@ export default function register(ctx: PluginContext): void { .option('--prefix ', 'Override the default prefix') .option('--no-pager', 'Disable interactive paging even in a TTY') .option('--no-icons', 'Disable icon rendering for clean text output') + .option('--exact', 'Force strict exact-match (skip substring fallback)') .action((id: string, options: ShowOptions) => { // Apply --no-icons flag by setting env var before any icon functions are called if (options.icons === false) { @@ -28,7 +29,42 @@ export default function register(ctx: PluginContext): void { const db = utils.getDatabase(options.prefix); const normalizedId = utils.normalizeCliId(id, options.prefix) || id; - const item = db.get(normalizedId); + let item: WorkItem | null = null; + + if (options.exact) { + // Strict exact-match: only normalize + db.get, no fallback. + item = db.get(normalizedId); + } else { + // Tolerant resolution: exact first, then unique substring. + item = db.get(normalizedId); + if (!item) { + const lower = normalizedId.toLowerCase(); + const allItems = db.getAll(); + const matches = allItems.filter(i => i.id.toLowerCase().includes(lower)); + if (matches.length === 1) { + item = matches[0]; + } else if (matches.length > 1) { + // Ambiguous match + if (program.opts().json) { + output.error('Ambiguous work item ID', { + success: false, + error: 'ambiguous-match', + candidates: matches.map(m => m.id), + }); + } else { + const cliOut = createCliOutputFromCommand(program.opts(), utils.getConfig() ?? undefined); + cliOut.printError( + `Ambiguous work item ID: "${normalizedId}" matches ${matches.length} item(s):\n` + + matches.map(m => ` - ${m.id}`).join('\n') + + '\nHint: use a longer prefix, or specify the exact ID.' + ); + } + process.exit(1); + } + // If matches.length === 0, fall through to "not found" below. + } + } + if (!item) { // Use the CLI output renderer for stderr when available so errors // look consistent with other CLI output in TTY. In JSON mode we @@ -46,7 +82,10 @@ export default function register(ctx: PluginContext): void { if (utils.isJsonMode()) { // Include structured audit_result data from the dedicated table. // The legacy `audit` field on WorkItem is no longer used. - const auditResult = db.getAuditResult(normalizedId); + // Note: item.id may differ from normalizedId when the item was + // resolved via the tolerant substring fallback, so all follow-up + // lookups use the resolved item's id. + const auditResult = db.getAuditResult(item.id); const result: ShowJsonOutput = { success: true, workItem: item }; // Include structured audit result from the dedicated table @@ -61,9 +100,9 @@ export default function register(ctx: PluginContext): void { }; } - result.comments = db.getCommentsForWorkItem(normalizedId) as Comment[]; + result.comments = db.getCommentsForWorkItem(item.id) as Comment[]; if (options.children) { - const children = db.getDescendants(normalizedId); + const children = db.getDescendants(item.id); const ancestors: any[] = []; let currentParentId = item.parentId; while (currentParentId) { @@ -86,7 +125,7 @@ export default function register(ctx: PluginContext): void { let finalOutput = ''; if (options.children) { - const itemsToDisplay = [item, ...db.getDescendants(normalizedId)]; + const itemsToDisplay = [item, ...db.getDescendants(item.id)]; // Render the tree into a string (keeps same formatting as before) finalOutput += '\n'; @@ -95,7 +134,7 @@ export default function register(ctx: PluginContext): void { // For non-full formats, also show comments for the root item (legacy behavior) if (chosenFormat !== 'full') { - const comments = db.getCommentsForWorkItem(normalizedId); + const comments = db.getCommentsForWorkItem(item.id); if (comments.length > 0) { finalOutput += 'Comments:\n'; comments.forEach(c => { diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 2176e233b..c5ee0605f 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -6,7 +6,7 @@ import type { PluginContext } from '../plugin-types.js'; import type { SyncOptions, SyncDebugOptions } from '../cli-types.js'; import type { WorkItem, Comment, DependencyEdge } from '../types.js'; import type { GitTarget, SyncResult } from '../sync.js'; -import { getRemoteDataFileContent, getRemoteDataFileContentWithRef, gitPushDataFileToBranch, mergeWorkItems, mergeComments, mergeDependencyEdges, assertDataFileInCwdRepo, filterRemoteDataByPrefix, enforceAuthorIdentityGate, getConfiguredUserEmail, getRemoteTrackingRefSha, writeLastSyncedRef } from '../sync.js'; +import { getRemoteDataFileContent, getRemoteDataFileContentWithRef, gitPushDataFileToBranch, mergeWorkItems, mergeComments, mergeDependencyEdges, assertDataFileInCwdRepo, filterRemoteDataByPrefix, enforceAuthorIdentityGate, getConfiguredUserEmail, getRemoteTrackingRefSha, writeLastSyncedRef, replayRemoteDeltaChain } from '../sync.js'; import { DEFAULT_GIT_REMOTE, DEFAULT_GIT_BRANCH } from '../sync-defaults.js'; import { importFromJsonlContent } from '../jsonl.js'; import { mergeAuditResults } from '../sync.js'; @@ -31,6 +31,13 @@ export function getSyncDefaults(config?: ReturnType) { }; } +// Full-snapshot cadence defaults (WL-0MT2KY0RQ008F50Q §5.3): force a full +// (non-incremental) JSONL snapshot after every N delta syncs, or once the +// accumulated delta payload exceeds this many bytes. Configurable via +// `sync.fullSnapshotEveryN` / `sync.deltaSizeThreshold`. +export const DEFAULT_FULL_SNAPSHOT_EVERY_N = 10; +export const DEFAULT_DELTA_SIZE_THRESHOLD = 1_000_000; + export async function performSync( dataPath: string, getDatabase: (prefix?: string) => any, @@ -90,6 +97,15 @@ export async function performSync( let remoteComments: Comment[] = []; let remoteEdges: DependencyEdge[] = []; + // Incremental-sync pull header detection (WL-0MT2KYCNB000CYWV, §6.1): the + // remote JSONL's first line may carry the sync header describing whether + // the file is a FULL snapshot or a DELTA (records changed since the last + // export). Legacy files are headerless and mean full. This decides how the + // pulled records are persisted (§6.1/AC3): full → replace local state with + // the merged set (existing behavior); delta → merge onto the local base + // non-destructively so local records absent from the delta are kept (AC2). + let remoteSyncKind: 'full' | 'delta' | undefined; + const localAudits = db.getAllAuditResults(); const remoteContentResult = await getRemoteDataFileContentWithRef(options.file, gitTarget); @@ -109,35 +125,93 @@ export async function performSync( } let remoteAudits: any[] = []; + // Full-snapshot fallback (WL-0MT2KZ5GJ000OJIP, §6.2 trigger 1): a remote + // DELTA tip with NO local records cannot be applied — a delta only carries + // *changed* records, so merging it onto an empty store would lose every + // record untouched since the last full snapshot (brand-new clone, or a + // store recovered from corruption). Reconstruct the full chain from the + // remote ref history instead (replayRemoteDeltaChain). If the chain is + // unrepairable we fail closed: never merge the partial delta and never push + // a (possibly empty) full snapshot over a populated remote (AC5). + let brokenDeltaChain = false; if (remoteContent) { - const remoteData = importFromJsonlContent(remoteContent); + let remoteData = importFromJsonlContent(remoteContent); + remoteSyncKind = remoteData.kind; + if ( + remoteSyncKind === 'delta' && + localItems.length === 0 && + localComments.length === 0 && + remoteContentResult.remoteTrackingRef + ) { + const replay = await replayRemoteDeltaChain( + options.file, + remoteContentResult.remoteTrackingRef + ); + if (replay) { + // Reconstructed state = newest full snapshot + every delta after it, + // i.e. a complete remote view. Treat it as a FULL snapshot so the + // normal merge applies and the subsequent export is full (no baseline + // → §5.1), re-anchoring the remote for other readers (AC4 resets the + // delta counters on the full push). + remoteData = { + items: replay.items, + comments: replay.comments, + dependencyEdges: replay.dependencyEdges, + auditResults: replay.auditResults, + kind: 'full' as const, + }; + remoteSyncKind = 'full'; + logLine(`Delta-chain fallback: reconstructed full state from remote history (${replay.items.length} items, ${replay.comments.length} comments)`); + if (!isJsonMode && !isSilent) { + console.log(`\nDelta-chain fallback: remote tip is a delta but local has no base — replaying remote history to reconstruct the full snapshot (${replay.items.length} items)`); + } + } else { + brokenDeltaChain = true; + remoteSyncKind = undefined; + logLine('Delta-chain fallback FAILED: no repairable full snapshot in remote history — failing closed (no merge, no push)'); + if (!isJsonMode && !isSilent) { + console.log('\nDelta-chain fallback FAILED: the remote ref history contains no repairable full snapshot. ' + + 'Skipping the pull and push to avoid data loss. Manual intervention required (AC5).'); + } + } + } // Cross-project prefix filter (SA-0MSC0BM1V0032UYT): never import work // items whose ID prefix does not match the project prefix, and drop their // comments/edges/audits too. Defense-in-depth behind the repo-context // guard (WL-0MSAH26DD001XXST) for stale/daemon processes that loaded // pre-fix code: foreign items cannot re-enter even if a sync reaches the // merge step with a polluted remote snapshot. - const configForPrefix = loadConfig(); - const projectPrefix = (options.prefix || configForPrefix?.prefix || 'WI').toUpperCase(); - const filtered = filterRemoteDataByPrefix( - remoteData.items, - remoteData.comments, - remoteData.dependencyEdges || [], - remoteData.auditResults || [], - projectPrefix - ); - if (filtered.droppedItems.length > 0) { - const preview = filtered.droppedItems.slice(0, 5).join(', '); - const ellipsis = filtered.droppedItems.length > 5 ? ', …' : ''; - logLine(`Foreign-prefix filter: dropped ${filtered.droppedItems.length} remote item(s) not matching prefix '${projectPrefix}' (${preview}${ellipsis})`); - if (!isJsonMode && !isSilent) { - console.log(`\nForeign-prefix filter: dropped ${filtered.droppedItems.length} remote item(s) not matching project prefix '${projectPrefix}' (${preview}${ellipsis})`); + if (!brokenDeltaChain) { + const configForPrefix = loadConfig(); + const projectPrefix = (options.prefix || configForPrefix?.prefix || 'WI').toUpperCase(); + const filtered = filterRemoteDataByPrefix( + remoteData.items, + remoteData.comments, + remoteData.dependencyEdges || [], + remoteData.auditResults || [], + projectPrefix + ); + if (filtered.droppedItems.length > 0) { + const preview = filtered.droppedItems.slice(0, 5).join(', '); + const ellipsis = filtered.droppedItems.length > 5 ? ', …' : ''; + logLine(`Foreign-prefix filter: dropped ${filtered.droppedItems.length} remote item(s) not matching prefix '${projectPrefix}' (${preview}${ellipsis})`); + if (!isJsonMode && !isSilent) { + console.log(`\nForeign-prefix filter: dropped ${filtered.droppedItems.length} remote item(s) not matching project prefix '${projectPrefix}' (${preview}${ellipsis})`); + } } + remoteItems = filtered.items; + remoteComments = filtered.comments; + remoteEdges = filtered.edges; + remoteAudits = filtered.audits; + } else { + // Fail-closed (AC5): do not merge the partial delta into local state — + // a delta alone does not represent the remote store, so importing it + // would silently drop every record untouched since the last full. + remoteItems = []; + remoteComments = []; + remoteEdges = []; + remoteAudits = []; } - remoteItems = filtered.items; - remoteComments = filtered.comments; - remoteEdges = filtered.edges; - remoteAudits = filtered.audits; } if (!isJsonMode && !isSilent) { @@ -240,11 +314,23 @@ export async function performSync( if (autoSyncEnabled) { db.setAutoSync(false); } - // SAFETY: db.import() is destructive (clears all items before inserting). - // This is safe here because itemMergeResult.merged is the complete merged - // set of local + remote items — no data is lost. - db.import(itemMergeResult.merged, edgeMergeResult.merged, auditMergeResult.merged); - db.importComments(commentMergeResult.merged); + // Persist the merged pull result. Delta remotes are merged onto the local + // base non-destructively (upsert by ID — local records absent from the + // delta are untouched, AC2/AC5); full snapshots (and legacy headerless + // files) replace local state with the merged set, the existing behavior + // (AC3/AC6). + if (isVerbose && !isSilent) { + console.log(`Remote sync kind: ${remoteSyncKind ?? 'full (legacy headerless)'}`); + } + logLine(`Remote sync kind: ${remoteSyncKind ?? 'full (legacy headerless)'}`); + if (remoteSyncKind === 'delta') { + db.upsertItems(itemMergeResult.merged, edgeMergeResult.merged); + db.upsertComments(commentMergeResult.merged); + db.importAuditResults(auditMergeResult.merged); + } else { + db.import(itemMergeResult.merged, edgeMergeResult.merged, auditMergeResult.merged); + db.importComments(commentMergeResult.merged); + } if (autoSyncEnabled) { db.setAutoSync(true, () => Promise.resolve()); } @@ -274,37 +360,131 @@ export async function performSync( } }; - const jsonlPath = await db.exportForSync({ onProgress: progressHandler }); + // Delta vs full export decision (WL-0MT2KY0RQ008F50Q AC1, §5.1-§5.4): + // + // - First sync / no baseline (no watermarks ever recorded) → full snapshot + // - Full snapshot cadence due (delta count/bytes threshold, §5.3) → full + // - WL_DELTA_EXPORT_DISABLED=1 → full + // - Nothing on remote base (ref not yet created / first push onto a remote) + // → full, so readers always have a full snapshot to anchor on + // - Otherwise → delta (incremental) export + const lastTs = db.getLastExportTimestamps(); + const deltaDisabled = process.env.WL_DELTA_EXPORT_DISABLED === '1'; + + // Fail-closed (WL-0MT2KZ5GJ000OJIP §6.2/AC5): when the remote delta chain + // is unrepairable we must NOT push anything — a full snapshot rebuilt from + // an empty/partial local store would overwrite a populated remote with less + // data. Throw so the command layer reports the error (exit 1) and local + // state stays untouched. + if (brokenDeltaChain) { + throw new Error( + 'Unrepairable remote delta chain: the remote ref history contains no full snapshot ' + + 'base, so the delta tip cannot be applied without data loss. No changes were made. ' + + 'Manual intervention required (e.g. force a full snapshot from a trusted store).' + ); + } + + const hasBaseline = Boolean( + lastTs.workitems || lastTs.comments || lastTs.edges || lastTs.audit_results + ); + const deltaMeta = db.getDeltaSyncMetadata(); + const fullSnapshotEveryN = + config?.syncFullSnapshotEveryN ?? DEFAULT_FULL_SNAPSHOT_EVERY_N; + const deltaSizeThreshold = + config?.syncDeltaSizeThreshold ?? DEFAULT_DELTA_SIZE_THRESHOLD; + const fullSnapshotDue = + // 0 disables the count-based cadence; a non-positive bytes threshold is + // corrected to the default. + (fullSnapshotEveryN > 0 && + deltaMeta.deltaSyncCount >= fullSnapshotEveryN) || + (deltaSizeThreshold > 0 && deltaMeta.deltaBytes >= deltaSizeThreshold); + const needsFullSnapshot = + !hasBaseline || fullSnapshotDue || deltaDisabled || remoteContent === null; + const syncMode: 'full' | 'delta' = needsFullSnapshot ? 'full' : 'delta'; + + // §5.4 zero-change fast path: skip export+push entirely when nothing is + // dirty and no full snapshot is due (a 0-record delta is useless — the + // remote tip would hold an effectively empty file). This preserves the + // existing last-sync-time optimization. + const dirty = db.countDirtyRecords(); + const zeroChangeFastPath = syncMode === 'delta' && dirty.total === 0; + + let jsonlPath: string | null = null; + if (!zeroChangeFastPath) { + jsonlPath = await db.exportForSync({ mode: syncMode, onProgress: progressHandler }); + } if (options.push) { - if (!isJsonMode && !isSilent) { - console.log('\nPushing changes to git...'); - } - - try { - await gitPushDataFileToBranch(jsonlPath, 'Sync work items and comments', gitTarget); + if (jsonlPath === null) { + // Zero-change fast path (§5.4) — nothing exported, nothing to push. if (!isJsonMode && !isSilent) { - console.log('Changes pushed successfully'); + console.log('\nNo changes to sync — skipping export and push'); } - - // Delete local JSONL file after successful push (ephemeral pattern) - // Only delete if push succeeded - keep for retry on failure - db.deleteLocalJsonl(); - + } else { if (!isJsonMode && !isSilent) { - console.log('Local JSONL file cleaned up (ephemeral pattern)'); + console.log('\nPushing changes to git...'); } - } catch (pushError) { - // Push failed - keep JSONL for retry, but report the error - if (!isJsonMode && !isSilent) { - console.log('\nPush failed - local JSONL file retained for retry'); + + try { + await gitPushDataFileToBranch(jsonlPath, 'Sync work items and comments', gitTarget); + if (!isJsonMode && !isSilent) { + console.log('Changes pushed successfully'); + } + + // AC5: advance the per-type watermarks ONLY after a successful push. + // (exportForSync never auto-advances; a failed push must not advance + // the baseline past data that was never published.) + const now = new Date().toISOString(); + db.markLastExportTimestamps({ + workitems: now, + comments: now, + edges: now, + audit_results: now, + }); + + // §5.3 cadence tracking: each delta accumulates count + JSONL bytes; + // a full snapshot resets the counters. + let pushedBytes = 0; + try { + pushedBytes = fs.statSync(jsonlPath).size; + } catch { + /* best-effort; keep 0 */ + } + if (syncMode === 'delta') { + db.setDeltaSyncMetadata( + deltaMeta.deltaSyncCount + 1, + deltaMeta.deltaBytes + pushedBytes + ); + } else { + db.setDeltaSyncMetadata(0, 0); + } + + // Delete local JSONL file after successful push (ephemeral pattern) + // Only delete if push succeeded - keep for retry on failure + db.deleteLocalJsonl(); + + if (!isJsonMode && !isSilent) { + console.log('Local JSONL file cleaned up (ephemeral pattern)'); + } + } catch (pushError) { + // Push failed - keep JSONL for retry, but report the error + if (!isJsonMode && !isSilent) { + console.log('\nPush failed - local JSONL file retained for retry'); + } + throw pushError; } - throw pushError; } } else { - if (!isJsonMode && !isSilent) { - console.log('\nSkipping git push (--no-push flag)'); - console.log('Local JSONL file retained (ephemeral pattern - file will be deleted on next successful push)'); + if (jsonlPath === null) { + // Zero-change fast path with --no-push: nothing to export either. + if (!isJsonMode && !isSilent) { + console.log('\nNo changes to sync — skipping export (--no-push)'); + } + } else { + if (!isJsonMode && !isSilent) { + console.log('\nSkipping git push (--no-push flag)'); + console.log('Local JSONL file retained (ephemeral pattern - file will be deleted on next successful push)'); + } } } diff --git a/src/commands/update.ts b/src/commands/update.ts index 6f101642e..41c0d140b 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -4,7 +4,7 @@ import type { PluginContext } from '../plugin-types.js'; import type { UpdateOptions } from '../cli-types.js'; -import type { UpdateWorkItemInput, WorkItem, WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel, DemotedParent } from '../types.js'; +import type { UpdateWorkItemInput, WorkItem, WorkItemStatus, WorkItemPriority, WorkItemRiskLevel, WorkItemEffortLevel, DemotedParent, RevertedItem } from '../types.js'; import { promises as fs } from 'fs'; import { humanFormatWorkItem, resolveFormat, extractFilePaths } from './helpers.js'; import { canValidateStatusStage, validateStatusStageCompatibility, validateStatusStageInput } from './status-stage-validation.js'; @@ -214,6 +214,10 @@ export default function register(ctx: PluginContext): void { for (const rawId of idsRaw) { const normalizedId = utils.normalizeCliId(rawId, options.prefix) || rawId; const updates: UpdateWorkItemInput = {}; + // Reversion record: set when a "Ready to close: No" audit verdict on + // an in_review (completed) item moves it back to open/plan_complete + // (WL-0MSKHYI5U0069FVV). Null when no reversion occurred. + let reverted: RevertedItem | null = null; if (titleCandidate) updates.title = titleCandidate; if (descriptionCandidate) updates.description = descriptionCandidate; if (priorityCandidate) updates.priority = priorityCandidate as WorkItemPriority; @@ -284,6 +288,22 @@ export default function register(ctx: PluginContext): void { // Skip remaining validation and success push for this item continue; } + + // Reversion: when the verdict is "Ready to close: No" on an item in + // `in_review` (status `completed`), move it back to `open` / + // `plan_complete` so it drops out of the ready-to-close queue and + // returns to the planning queue (WL-0MSKHYI5U0069FVV). This is an + // intentional lifecycle consequence of the audit verdict, applied + // here (before the no-field-changed guard below) so the guard never + // blocks it. Best-effort: a reversion failure must not abort the + // update — surface a warning instead. + if (auditEntry.status === 'Partial') { + try { + reverted = db.revertToPlanComplete(normalizedId); + } catch (err) { + console.error(`Warning: failed to revert ${normalizedId} to open/plan_complete: ${err instanceof Error ? err.message : String(err)}`); + } + } } // Validate status/stage per-id if needed. @@ -398,7 +418,12 @@ export default function register(ctx: PluginContext): void { (current as any).auditResult = db.getAuditResult(normalizedId); (current as any).audit = { time: auditEntryForOutput.time, author: auditEntryForOutput.author, text: auditEntryForOutput.text, status: auditEntryForOutput.status }; } - results.push({ id: normalizedId, success: true, workItem: current }); + results.push({ + id: normalizedId, + success: true, + workItem: current, + ...(reverted ? { reverted } : {}), + }); continue; } @@ -503,6 +528,7 @@ export default function register(ctx: PluginContext): void { workItem: item, ...(downgradedChildren.length > 0 ? { downgradedChildren } : {}), ...(demotedParent ? { demotedParent } : {}), + ...(reverted ? { reverted } : {}), }); } @@ -522,6 +548,9 @@ export default function register(ctx: PluginContext): void { if (r.demotedParent) { jsonOut.demotedParent = r.demotedParent; } + if (r.reverted) { + jsonOut.reverted = r.reverted; + } output.json(jsonOut); } else { @@ -543,6 +572,9 @@ export default function register(ctx: PluginContext): void { if (r.demotedParent) { console.log(`[Parent ${r.demotedParent.parent.id} demoted from ${r.demotedParent.from.status}/${r.demotedParent.from.stage} to ${r.demotedParent.to.status}/${r.demotedParent.to.stage}]`); } + if (r.reverted) { + console.log(`[${r.reverted.item.id} reverted from ${r.reverted.from.status}/${r.reverted.from.stage} to ${r.reverted.to.status}/${r.reverted.to.stage}]`); + } } else { output.error(r.message ?? r.error, { success: false, error: r.error }); } diff --git a/src/doctor/hook-upgrade.ts b/src/doctor/hook-upgrade.ts index 105ad176f..c5996dc6d 100644 --- a/src/doctor/hook-upgrade.ts +++ b/src/doctor/hook-upgrade.ts @@ -23,6 +23,130 @@ const HOOK_NAMES = [ 'worklog-post-pull', ] as const; +/** + * Canonical v2 pre-push hook content — MUST match the committed + * `.githooks/pre-push` template (branch-policy enforcement + context-budget + * gate + worklog sync). Embedded here (rather than read from disk) so the + * generator works from a dist/ install; `wl init` (src/commands/init.ts) + * imports this same constant so freshly installed hooks never drift from the + * committed template (WL-0MT33QNTL006LHQA). + */ +export const PRE_PUSH_HOOK_CONTENT = [ + '#!/bin/sh', + '# worklog:pre-push-hook:v2', + '# 1. Auto-sync Worklog data before pushing (committed hooks).', + '# 2. Enforce branch naming policy: block pushes to protected branches.', + '# 3. Context-budget regression gate (F6, SA-0MSLK7XNZ00366YY): fail the push', + '# when the startup context surface exceeds committed thresholds.', + '#', + '# Set WORKLOG_SKIP_PRE_PUSH=1 to bypass worklog sync.', + '# Set BRANCH_POLICY_SKIP=1 to bypass branch policy check (not recommended).', + '# Set CONTEXT_BUDGET_SKIP=1 to bypass the context-budget gate (not recommended).', + 'set -e', + '', + '# ── Branch policy enforcement ────────────────────────────────────────────────', + '# Check that the current branch is not a protected branch.', + 'if [ "$BRANCH_POLICY_SKIP" != "1" ]; then', + ' current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"', + ' # Protected branches that agents must never push to.', + ' blocked_branches="main master HEAD"', + ' for blocked in $blocked_branches; do', + ' if [ "$current_branch" = "$blocked" ]; then', + ' echo "ERROR: Push to \'$current_branch\' is blocked by branch policy." >&2', + ' echo " Regular agents must NOT push directly to main." >&2', + ' echo " Work in feature branches (wl--) and push into dev." >&2', + ' echo " See the framework ship skill for the push-to-dev workflow." >&2', + ' exit 1', + ' fi', + ' done', + 'fi', + '', + '# ── Context-budget regression gate ──────────────────────────────────────────', + '# Fail the push when the pi session startup context surface exceeds the', + '# committed thresholds (docs/dev/context-budget.thresholds.json). The gate is', + '# fail-open when the tooling or thresholds file is absent (e.g. worktrees of', + '# old commits, or repositories that do not ship the context-audit skill).', + '# measure_context.py is resolved first from the local skill/context-audit/', + '# path, then from the installed global symlink', + '# (~/.pi/agent/skills/context-audit/scripts/measure_context.py) installed by', + '# the framework\'s install_pi.sh.', + 'if [ "$CONTEXT_BUDGET_SKIP" != "1" ]; then', + ' repo_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"', + ' if [ -f "$repo_root/skill/context-audit/scripts/measure_context.py" ]; then', + ' measure="$repo_root/skill/context-audit/scripts/measure_context.py"', + ' elif [ -f "$HOME/.pi/agent/skills/context-audit/scripts/measure_context.py" ]; then', + ' measure="$HOME/.pi/agent/skills/context-audit/scripts/measure_context.py"', + ' else', + ' measure=""', + ' fi', + ' thresholds="$repo_root/docs/dev/context-budget.thresholds.json"', + ' if [ -n "$repo_root" ] && [ -n "$measure" ] && [ -f "$measure" ] && [ -f "$thresholds" ]; then', + ' if python3 "$measure" --repo-root "$repo_root" --include-hidden --thresholds "$thresholds" >/dev/null 2>&1; then', + ' :', + ' else', + ' echo "ERROR: Context budget exceeded committed thresholds." >&2', + ' echo " Run: python3 $measure --repo-root $repo_root --include-hidden --thresholds $thresholds" >&2', + ' echo " See docs/dev/context-budget.md. Set CONTEXT_BUDGET_SKIP=1 to bypass." >&2', + ' exit 1', + ' fi', + ' fi', + 'fi', + '', + '# ── Worklog sync ─────────────────────────────────────────────────────────────', + 'if [ "$WORKLOG_SKIP_PRE_PUSH" = "1" ]; then', + ' echo "worklog: pre-push sync skipped (WORKLOG_SKIP_PRE_PUSH=1)"', + ' exit 0', + 'fi', + '', + '# Skip when running inside a temp worktree created by withTempWorktree', + '# for internal sync operations. These worktrees don\'t have worklog', + '# initialized and the push is already done with --no-verify as defense.', + 'case "$PWD" in', + ' *tmp-worktree-*)', + ' exit 0', + ' ;;', + 'esac', + '', + '# Skip when inside a git worktree (not the main checkout).', + '# Worktrees are for feature development and typically don\'t have worklog', + '# initialized. The sync should run from the main checkout.', + 'if [ "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" ]; then', + ' exit 0', + 'fi', + '', + '# Read stdin to check which refs are being pushed.', + '# If pushing to refs/worklog/data, skip the sync to avoid infinite loops.', + 'skip=0', + 'while read local_ref local_sha remote_ref remote_sha; do', + ' if [ "$remote_ref" = "refs/worklog/data" ]; then', + ' skip=1', + ' fi', + 'done', + '', + 'if [ "$skip" = "1" ]; then', + ' exit 0', + 'fi', + '', + 'if command -v wl >/dev/null 2>&1; then', + ' WL=wl', + 'elif command -v worklog >/dev/null 2>&1; then', + ' WL=worklog', + 'else', + ' echo "worklog: wl/worklog not found; skipping pre-push sync" >&2', + ' exit 0', + 'fi', + '', + '# Force the data branch to refs/worklog/data regardless of config.', + '# This prevents the sync from accidentally pushing to a standard branch', + '# if the user has overridden syncBranch in their worklog config.', + '"$WL" sync --git-branch refs/worklog/data || {', + ' echo "worklog: pre-push sync failed (pushing anyway)" >&2', + ' exit 0', + '}', + 'exit 0', + '', + ].join('\n'); + /** * Whether a file appears to be a Worklog-managed hook. */ @@ -86,76 +210,7 @@ export function detectHooksTargetDir(): string { export function generateCanonicalHookContent(hookName: string): string | null { switch (hookName) { case 'pre-push': { - return [ - '#!/bin/sh', - '# worklog:pre-push-hook:v1', - '#', - '# Auto-sync Worklog data before pushing.', - '#', - '# This hook runs `wl sync` before any push to ensure worklog data is', - '# committed and pushed to the dedicated refs/worklog/data branch.', - '# It uses a temporary worktree to avoid corrupting the project working tree', - '# (see WL-0MQRBT8BS00355AB for the bug this prevents).', - '#', - '# BYPASS: Set WORKLOG_SKIP_PRE_PUSH=1 to skip the sync entirely.', - '# export WORKLOG_SKIP_PRE_PUSH=1', - '# git push origin HEAD:refs/heads/dev', - '#', - 'set -e', - '', - 'if [ "$WORKLOG_SKIP_PRE_PUSH" = "1" ]; then', - ' echo "worklog: pre-push sync skipped (WORKLOG_SKIP_PRE_PUSH=1)"', - ' exit 0', - 'fi', - '', - '# Skip when running inside a temp worktree created by withTempWorktree', - "# for internal sync operations. These worktrees don't have worklog", - '# initialized and the push is already done with --no-verify as defense.', - 'case "$PWD" in', - ' *tmp-worktree-*)', - ' exit 0', - ' ;;', - 'esac', - '', - '# Skip when inside a git worktree (not the main checkout).', - "# Worktrees are for feature development and typically don't have worklog", - '# initialized. The sync should run from the main checkout.', - 'if [ "$(git rev-parse --git-dir 2>/dev/null)" != "$(git rev-parse --git-common-dir 2>/dev/null)" ]; then', - ' exit 0', - 'fi', - '', - '# Read stdin to check which refs are being pushed.', - '# If pushing to refs/worklog/data, skip the sync to avoid infinite loops.', - 'skip=0', - 'while read local_ref local_sha remote_ref remote_sha; do', - ' if [ "$remote_ref" = "refs/worklog/data" ]; then', - ' skip=1', - ' fi', - 'done', - '', - 'if [ "$skip" = "1" ]; then', - ' exit 0', - 'fi', - '', - 'if command -v wl >/dev/null 2>&1; then', - ' WL=wl', - 'elif command -v worklog >/dev/null 2>&1; then', - ' WL=worklog', - 'else', - ' echo "worklog: wl/worklog not found; skipping pre-push sync" >&2', - ' exit 0', - 'fi', - '', - '# Force the data branch to refs/worklog/data regardless of config.', - '# This prevents the sync from accidentally pushing to a standard branch', - '# if the user has overridden syncBranch in their worklog config.', - '"$WL" sync --git-branch refs/worklog/data || {', - ' echo "worklog: pre-push sync failed (pushing anyway)" >&2', - ' exit 0', - '}', - 'exit 0', - '', - ].join('\n'); + return PRE_PUSH_HOOK_CONTENT; } case 'post-checkout': { return [ diff --git a/src/icons.ts b/src/icons.ts deleted file mode 100644 index 011d4c749..000000000 --- a/src/icons.ts +++ /dev/null @@ -1,613 +0,0 @@ -/** - * Icon utilities for work item priority, status, risk, effort, and more. - * - * Provides consistent icon rendering (emoji or text fallback) across - * the TUI and CLI output paths, with accessible labels for screen - * readers. - * - * Design spec: docs/icons-design.md - */ - -/** - * Options for icon rendering. - */ -export interface IconOptions { - /** When true, use text fallback instead of emoji/icon glyph. */ - noIcons?: boolean; -} - -// ─── Priority Icons ──────────────────────────────────────────────────── -// More graphical icons that visually convey priority levels - -const PRIORITY_ICON: Record = { - critical: '\u{1F6A8}', // 🚨 Rotating light - urgent/danger - high: '\u{2B50}', // ⭐ Star - important - medium: '\u{1F4CB}', // 📋 Clipboard - standard task - low: '\u{1F422}', // 🐢 Turtle - slow/low priority -}; - -const PRIORITY_FALLBACK: Record = { - critical: '[CRIT]', - high: '[HIGH]', - medium: '[MED ]', - low: '[LOW ]', -}; - -const PRIORITY_LABEL: Record = { - critical: 'Critical priority', - high: 'High priority', - medium: 'Medium priority', - low: 'Low priority', -}; - -// ─── Status Icons ─────────────────────────────────────────────────────── - -const STATUS_ICON: Record = { - open: '\u{1F513}', // 🔓 Unlocked - 'in-progress': '\u{1F504}', // 🔄 Arrows (recycling) - completed: '\u{2714}\u{FE0F}', // ✔️ Heavy check mark - blocked: '\u{26D4}', // ⛔ No entry - deleted: '\u{1F5D1}\u{FE0F}', // 🗑️ Wastebasket - input_needed: '\u{1F4AC}', // 💬 Speech balloon -}; - -const STATUS_FALLBACK: Record = { - open: '[OPEN]', - 'in-progress': '[INPR]', - completed: '[DONE]', - blocked: '[BLKD]', - deleted: '[DEL ]', - input_needed: '[HELP]', -}; - -const STATUS_LABEL: Record = { - open: 'Status: Open', - 'in-progress': 'Status: In progress', - completed: 'Status: Completed', - blocked: 'Status: Blocked', - deleted: 'Status: Deleted', - input_needed: 'Status: Input needed', -}; - -// ─── Public API ───────────────────────────────────────────────────────── - -/** - * Check whether icons should be rendered. - * - * Icons are enabled by default when running in a TTY. They can be - * disabled via the `WL_NO_ICONS` environment variable or the - * `noIcons` option. - */ -export function iconsEnabled(opts?: { noIcons?: boolean }): boolean { - // Explicit opt-out via option (takes priority over everything). - if (opts?.noIcons === true) return false; - // Explicit opt-in via option overrides env var. - if (opts?.noIcons === false) return true; - // Global env var opt-out. - if (typeof process !== 'undefined' && process.env?.WL_NO_ICONS === '1') return false; - // Default to enabled; callers can further restrict based on TTY. - return true; -} - -/** - * Get the icon string (emoji or text fallback) for a work item priority. - * - * @param priority - The priority value (e.g. 'critical', 'high', 'medium', 'low'). - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function priorityIcon(priority: string, opts?: IconOptions): string { - const key = (priority || '').toLowerCase().trim(); - if (opts?.noIcons === true) { - return PRIORITY_FALLBACK[key] ?? ''; - } - return PRIORITY_ICON[key] ?? ''; -} - -/** - * Get the icon string (emoji or text fallback) for a work item status. - * - * @param status - The status value (e.g. 'open', 'in-progress', 'completed'). - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function statusIcon(status: string, opts?: IconOptions): string { - const key = (status || '').toLowerCase().trim(); - if (opts?.noIcons === true) { - return STATUS_FALLBACK[key] ?? ''; - } - return STATUS_ICON[key] ?? ''; -} - -/** - * Get the accessible label for a priority icon. - * - * @param priority - The priority value. - * @returns A human-readable label describing the priority (e.g. "High priority"). - */ -export function priorityLabel(priority: string): string { - return PRIORITY_LABEL[(priority || '').toLowerCase().trim()] ?? ''; -} - -/** - * Get the accessible label for a status icon. - * - * @param status - The status value. - * @returns A human-readable label describing the status (e.g. "Status: Open"). - */ -export function statusLabel(status: string): string { - return STATUS_LABEL[(status || '').toLowerCase().trim()] ?? ''; -} - -/** - * Get the text fallback for a priority icon. - * - * @param priority - The priority value. - * @returns The bracketed text label (e.g. "[CRIT]"). - */ -export function priorityFallback(priority: string): string { - return PRIORITY_FALLBACK[(priority || '').toLowerCase().trim()] ?? ''; -} - -/** - * Get the text fallback for a status icon. - * - * @param status - The status value. - * @returns The bracketed text label (e.g. "[OPEN]"). - */ -export function statusFallback(status: string): string { - return STATUS_FALLBACK[(status || '').toLowerCase().trim()] ?? ''; -} - -// ─── Risk Icons ───────────────────────────────────────────────────────── - -const RISK_ICON: Record = { - low: '\u{1F331}', // 🌱 Seedling - medium: '\u{26A0}\u{FE0F}', // ⚠️ Warning - high: '\u{1F525}', // 🔥 Fire - severe: '\u{1F6A8}', // 🚨 Rotating light -}; - -const RISK_FALLBACK: Record = { - low: '[LOW]', - medium: '[MED]', - high: '[HIGH]', - severe: '[SEV]', -}; - -const RISK_LABEL: Record = { - low: 'Risk: Low', - medium: 'Risk: Medium', - high: 'Risk: High', - severe: 'Risk: Severe', -}; - -// ─── Effort Icons ─────────────────────────────────────────────────────── - -const EFFORT_ICON: Record = { - xs: '\u{1F41C}', // 🐜 Ant - s: '\u{1F407}', // 🐇 Rabbit - m: '\u{1F415}', // 🐕 Dog - l: '\u{1F418}', // 🐘 Elephant - xl: '\u{1F40B}', // 🐋 Whale - // Full-text aliases (used by Worklog CLI and effort-and-risk skill) - 'extra small': '\u{1F41C}', // 🐜 Ant - small: '\u{1F407}', // 🐇 Rabbit - medium: '\u{1F415}', // 🐕 Dog - large: '\u{1F418}', // 🐘 Elephant - 'extra large': '\u{1F40B}', // 🐋 Whale - xlarge: '\u{1F40B}', // 🐋 Whale — variant spelling -}; - -const EFFORT_FALLBACK: Record = { - xs: '[XS]', - s: '[S]', - m: '[M]', - l: '[L]', - xl: '[XL]', - // Full-text aliases - 'extra small': '[XS]', - small: '[S]', - medium: '[M]', - large: '[L]', - 'extra large': '[XL]', - xlarge: '[XL]', -}; - -const EFFORT_LABEL: Record = { - xs: 'Effort: XS (extra small)', - s: 'Effort: S (small)', - m: 'Effort: M (medium)', - l: 'Effort: L (large)', - xl: 'Effort: XL (extra large)', - // Full-text aliases - 'extra small': 'Effort: XS (extra small)', - small: 'Effort: S (small)', - medium: 'Effort: M (medium)', - large: 'Effort: L (large)', - 'extra large': 'Effort: XL (extra large)', - xlarge: 'Effort: XL (extra large)', -}; - -// ─── Epic Icons ────────────────────────────────────────────────────────── - -const EPIC_ICON: Record = { - epic: '\u{1F3F0}', // 🏰 Castle - large feature with dependencies -}; - -const EPIC_FALLBACK: Record = { - epic: '[EPIC]', -}; - -const EPIC_LABEL: Record = { - epic: 'Issue Type: Epic', -}; - -// ─── Stage Icons ─────────────────────────────────────────────────────── - -const STAGE_ICON: Record = { - idea: '\u{1F4A1}', // 💡 - intake_complete: '\u{1F4E5}', // 📥 - plan_complete: '\u{1F4CB}', // 📋 - in_progress: '\u{1F6E0}\u{FE0F}', // 🛠️ - in_review: '\u{1F50D}', // 🔍 - done: '\u{1F3C1}', // 🏁 -}; - -const STAGE_FALLBACK: Record = { - idea: '[IDEA]', - intake_complete: '[INTAKE]', - plan_complete: '[PLAN]', - in_progress: '[PROG]', - in_review: '[REVIEW]', - done: '[DONE]', -}; - -const STAGE_LABEL: Record = { - idea: 'Stage: Idea', - intake_complete: 'Stage: Intake Complete', - plan_complete: 'Stage: Plan Complete', - in_progress: 'Stage: In Progress', - in_review: 'Stage: In Review', - done: 'Stage: Done', -}; - -// ─── Audit Result Icons ──────────────────────────────────────────────── - -/** - * Audit result key for icon lookup. - * true → 'yes', false → 'no', null/undefined → 'unknown' - */ -function auditKey(result: boolean | null | undefined): string { - if (result === true) return 'yes'; - if (result === false) return 'no'; - return 'unknown'; -} - -const AUDIT_ICON: Record = { - yes: '\u{2705}', // ✅ - no: '\u{274C}', // ❌ - unknown: '\u{2754}', // ❔ -}; - -const AUDIT_FALLBACK: Record = { - yes: '[YES]', - no: '[NO]', - unknown: '[UNKN]', -}; - -const AUDIT_LABEL: Record = { - yes: 'Audit: Passed', - no: 'Audit: Failed', - unknown: 'Audit: Not run', -}; - -// ─── Risk Public API ──────────────────────────────────────────────────── - -/** - * Get the icon string (emoji or text fallback) for a work item risk level. - * - * @param risk - The risk value (e.g. 'Low', 'Medium', 'High', 'Severe'). - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function riskIcon(risk: string | undefined | null, opts?: IconOptions): string { - const key = (risk || '').toLowerCase().trim(); - if (opts?.noIcons === true) { - return RISK_FALLBACK[key] ?? ''; - } - return RISK_ICON[key] ?? ''; -} - -/** - * Get the accessible label for a risk icon. - * - * @param risk - The risk value. - * @returns A human-readable label describing the risk (e.g. "Risk: Medium"). - */ -export function riskLabel(risk: string | undefined | null): string { - return RISK_LABEL[(risk || '').toLowerCase().trim()] ?? ''; -} - -/** - * Get the text fallback for a risk icon. - * - * @param risk - The risk value. - * @returns The bracketed text label (e.g. "[MED]"). - */ -export function riskFallback(risk: string | undefined | null): string { - return RISK_FALLBACK[(risk || '').toLowerCase().trim()] ?? ''; -} - -// ─── Effort Public API ────────────────────────────────────────────────── - -/** - * Get the icon string (emoji or text fallback) for a work item effort T-shirt size. - * - * @param effort - The effort value (e.g. 'XS', 'S', 'M', 'L', 'XL'). - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function effortIcon(effort: string | undefined | null, opts?: IconOptions): string { - const key = (effort || '').toLowerCase().trim(); - if (opts?.noIcons === true) { - return EFFORT_FALLBACK[key] ?? ''; - } - return EFFORT_ICON[key] ?? ''; -} - -/** - * Get the accessible label for an effort icon. - * - * @param effort - The effort value. - * @returns A human-readable label describing the effort (e.g. "Effort: M (medium)"). - */ -export function effortLabel(effort: string | undefined | null): string { - return EFFORT_LABEL[(effort || '').toLowerCase().trim()] ?? ''; -} - -/** - * Get the text fallback for an effort icon. - * - * @param effort - The effort value. - * @returns The bracketed text label (e.g. "[M]"). - */ -export function effortFallback(effort: string | undefined | null): string { - return EFFORT_FALLBACK[(effort || '').toLowerCase().trim()] ?? ''; -} - -// ─── Stage Public API ────────────────────────────────────────────────── - -/** - * Get the icon string (emoji or text fallback) for a work item stage. - * - * @param stage - The stage value (e.g. 'idea', 'in_progress', 'done'). - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function stageIcon(stage: string | undefined | null, opts?: IconOptions): string { - const key = (stage || '').toLowerCase().trim(); - if (opts?.noIcons === true) { - return STAGE_FALLBACK[key] ?? ''; - } - return STAGE_ICON[key] ?? ''; -} - -/** - * Get the accessible label for a stage icon. - * - * @param stage - The stage value. - * @returns A human-readable label describing the stage (e.g. "Stage: In Progress"). - */ -export function stageLabel(stage: string | undefined | null): string { - return STAGE_LABEL[(stage || '').toLowerCase().trim()] ?? ''; -} - -/** - * Get the text fallback for a stage icon. - * - * @param stage - The stage value. - * @returns The bracketed text label (e.g. "[PROG]"). - */ -export function stageFallback(stage: string | undefined | null): string { - return STAGE_FALLBACK[(stage || '').toLowerCase().trim()] ?? ''; -} - -// ─── Audit Result Public API ─────────────────────────────────────────── - -/** - * Get the icon string (emoji or text fallback) for an audit result. - * - * @param result - The audit result: true (yes/passed), false (no/failed), null/undefined (unknown). - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function auditIcon(result: boolean | null | undefined, opts?: IconOptions): string { - const key = auditKey(result); - if (opts?.noIcons === true) { - return AUDIT_FALLBACK[key] ?? ''; - } - return AUDIT_ICON[key] ?? ''; -} - -/** - * Get the accessible label for an audit result icon. - * - * @param result - The audit result value. - * @returns A human-readable label (e.g. "Audit: Passed"). - */ -export function auditLabel(result: boolean | null | undefined): string { - return AUDIT_LABEL[auditKey(result)] ?? ''; -} - -/** - * Get the text fallback for an audit result icon. - * - * @param result - The audit result value. - * @returns The bracketed text label (e.g. "[YES]"). - */ -export function auditFallback(result: boolean | null | undefined): string { - return AUDIT_FALLBACK[auditKey(result)] ?? ''; -} - -// ─── Stale Audit Result Icons ─────────────────────────────────────────── - -const STALE_AUDIT_ICON: Record = { - yes: '\u{1F7E9}', // 🟩 Green square button -}; - -const STALE_AUDIT_FALLBACK: Record = { - yes: '[YES_STALE]', -}; - -const STALE_AUDIT_LABEL: Record = { - yes: 'Audit: Passed (stale)', -}; - -// ─── Stale Audit Result Public API ──────────────────────────────────── - -/** - * Get the icon string for a stale audit result. - * - * For stale-but-passed audits (result === true), returns the stale-passed - * icon (\u{1F7E9}). For all other results, falls back to the regular - * audit icon for backward compatibility. - * - * @param result - The audit result. - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function auditStaleIcon(result: boolean | null | undefined, opts?: IconOptions): string { - if (result === true) { - if (opts?.noIcons === true) { - return STALE_AUDIT_FALLBACK.yes; - } - return STALE_AUDIT_ICON.yes; - } - return auditIcon(result, opts); -} - -/** - * Get the accessible label for a stale audit result icon. - * - * @param result - The audit result value. - * @returns A human-readable label (e.g. "Audit: Passed (stale)"). - */ -export function auditStaleLabel(result: boolean | null | undefined): string { - if (result === true) { - return STALE_AUDIT_LABEL.yes; - } - return auditLabel(result); -} - -/** - * Get the text fallback for a stale audit result icon. - * - * @param result - The audit result value. - * @returns The bracketed text label (e.g. "[YES_STALE]"). - */ -export function auditStaleFallback(result: boolean | null | undefined): string { - if (result === true) { - return STALE_AUDIT_FALLBACK.yes; - } - return auditFallback(result); -} - -// ─── Producer Review Flag Icons ──────────────────────────────────────── - -/** - * Producer review key for icon lookup. - * true → 'needed', false → 'not_needed', null/undefined → 'not_needed' (default to ok) - */ -function producerReviewKey(needsProducerReview: boolean | null | undefined): string { - if (needsProducerReview === true) return 'needed'; - return 'not_needed'; -} - -const PRODUCER_REVIEW_ICON: Record = { - needed: '\u{274C}', // ❌ - not_needed: '\u{2705}', // ✅ -}; - -const PRODUCER_REVIEW_FALLBACK: Record = { - needed: '[NEEDS_PRODUCER]', - not_needed: '[PRODUCER_OK]', -}; - -const PRODUCER_REVIEW_LABEL: Record = { - needed: 'Needs producer review', - not_needed: 'Producer review complete', -}; - -// ─── Producer Review Public API ─────────────────────────────────────── - -/** - * Get the icon string (emoji or text fallback) for the needsProducerReview flag. - * - * @param needsProducerReview - Whether the work item needs producer review. - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function needsProducerReviewIcon(needsProducerReview: boolean | null | undefined, opts?: IconOptions): string { - const key = producerReviewKey(needsProducerReview); - if (opts?.noIcons === true) { - return PRODUCER_REVIEW_FALLBACK[key] ?? ''; - } - return PRODUCER_REVIEW_ICON[key] ?? ''; -} - -/** - * Get the accessible label for a producer review icon. - * - * @param needsProducerReview - Whether the work item needs producer review. - * @returns A human-readable label (e.g. "Needs producer review"). - */ -export function needsProducerReviewLabel(needsProducerReview: boolean | null | undefined): string { - return PRODUCER_REVIEW_LABEL[producerReviewKey(needsProducerReview)] ?? ''; -} - -/** - * Get the text fallback for a producer review icon. - * - * @param needsProducerReview - Whether the work item needs producer review. - * @returns The bracketed text label (e.g. "[NEEDS_PRODUCER]"). - */ -export function needsProducerReviewFallback(needsProducerReview: boolean | null | undefined): string { - return PRODUCER_REVIEW_FALLBACK[producerReviewKey(needsProducerReview)] ?? ''; -} - -// ─── Epic Public API ──────────────────────────────────────────────────── - -/** - * Get the icon string (emoji or text fallback) for an epic work item. - * - * Epic icon: 🏰 (castle) — represents a large feature with dependencies. - * Fallback: [EPIC] - * - * @param opts - Options controlling fallback behaviour. - * @returns The icon string (emoji or bracketed text). - */ -export function epicIcon(opts?: IconOptions): string { - if (opts?.noIcons === true) { - return EPIC_FALLBACK.epic; - } - return EPIC_ICON.epic; -} - -/** - * Get the accessible label for the epic icon. - * - * @returns A human-readable label ("Issue Type: Epic"). - */ -export function epicLabel(): string { - return EPIC_LABEL.epic; -} - -/** - * Get the text fallback for the epic icon. - * - * @returns The bracketed text label ("[EPIC]"). - */ -export function epicFallback(): string { - return EPIC_FALLBACK.epic; -} diff --git a/src/jsonl.ts b/src/jsonl.ts index 15972796a..d05c4af38 100644 --- a/src/jsonl.ts +++ b/src/jsonl.ts @@ -57,14 +57,62 @@ function mergeDependencyEdges(edges: DependencyEdge[]): DependencyEdge[] { return Array.from(merged.values()); } +/** + * Sync header metadata (incremental sync, WL-0MSAKUBKW006FN8Q). + * + * The first line of a sync JSONL identifies whether the rest of the file is a + * full snapshot or a delta (subset) of records. The header is a single JSON + * object so the parser can detect it before processing records, and so old + * parsers that only know the workitem/comment/audit formats never treat the + * header as a record (the `data`/`type` fields are absent). + */ +export interface SyncHeader { + version: number; + kind: 'full' | 'delta'; +} + +export const SYNC_HEADER_KEY = '__worklog_sync__'; +export const SYNC_HEADER_VERSION = 1; + +/** + * Build the header line for a given sync kind. Returns null when no header is + * requested (legacy callers keep producing plain files). + */ +export function buildSyncHeader(kind?: 'full' | 'delta'): string | null { + if (!kind) return null; + return stableStringify({ [SYNC_HEADER_KEY]: { version: SYNC_HEADER_VERSION, kind } }); +} + +/** + * Parse a sync header line. Returns null when the line is not a known header. + * + * @param line - A single JSONL line (first line of a sync file). + */ +export function parseSyncHeader(line: string): SyncHeader | null { + try { + const parsed = JSON.parse(line); + const h = parsed?.[SYNC_HEADER_KEY]; + if (!h || typeof h !== 'object') return null; + const kind = h.kind; + if (kind !== 'full' && kind !== 'delta') return null; + return { version: typeof h.version === 'number' ? h.version : SYNC_HEADER_VERSION, kind }; + } catch { + return null; + } +} + function buildJsonlContent( items: WorkItem[], comments: Comment[], dependencyEdges: DependencyEdge[] = [], - auditResults: AuditResult[] = [] + auditResults: AuditResult[] = [], + kind?: 'full' | 'delta' ): string { const lines: string[] = []; + const header = buildSyncHeader(kind); + if (header) lines.push(header); + const sortedItems = [...items].sort((a, b) => a.id.localeCompare(b.id)); const normalizedEdges = mergeDependencyEdges(dependencyEdges); const sortedComments = [...comments].sort((a, b) => { @@ -112,9 +160,10 @@ export function exportToJsonl( comments: Comment[], filepath: string, dependencyEdges: DependencyEdge[] = [], - auditResults: AuditResult[] = [] + auditResults: AuditResult[] = [], + kind?: 'full' | 'delta' ): number { - const content = buildJsonlContent(items, comments, dependencyEdges, auditResults); + const content = buildJsonlContent(items, comments, dependencyEdges, auditResults, kind); // Ensure directory exists const dir = path.dirname(filepath); @@ -153,6 +202,9 @@ export async function exportToJsonlAsync( // main event loop. If worker_threads are unavailable or worker construction // fails, fall back to the previous in-process async implementation. const onProgress = options?.onProgress; + const kind: 'full' | 'delta' | undefined = options?.kind; + // Build the header string ONCE (both for the worker path and the fallback). + const headerLine = buildSyncHeader(kind); // Inline worker code that performs stable JSONL serialization and reports // progress back to the parent via parentPort.postMessage(). Using an @@ -186,11 +238,12 @@ export async function exportToJsonlAsync( "function mergeDependencyEdges(edges) { const merged = new Map(); for (const edge of edges || []) { merged.set(edge.fromId + '::' + edge.toId, edge); } return Array.from(merged.values()); }", "function dependenciesFromEdges(edges, itemId) { return (edges || []).filter(function(e){ return e.fromId === itemId; }).map(function(e){ return { from: e.fromId, to: e.toId }; }).sort(function(a,b){ const d = a.from.localeCompare(b.from); return d !== 0 ? d : a.to.localeCompare(b.to); }); }", "try {", - " const { items, comments, dependencyEdges, auditResults, filepath } = workerData;", + " const { items, comments, dependencyEdges, auditResults, filepath, headerLine } = workerData;", " const dir = path.dirname(filepath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });", " const tempName = path.basename(filepath) + '.tmp-' + Math.random().toString(36).slice(2,10);", " const tempPath = path.join(dir, tempName);", " const out = fs.createWriteStream(tempPath, { encoding: 'utf8' });", + " if (headerLine) out.write(headerLine + '\\n');", " const sortedItems = (items || []).slice().sort(function(a,b){ return a.id.localeCompare(b.id); });", " const normalizedEdges = mergeDependencyEdges(dependencyEdges || []);", " const sortedComments = (comments || []).slice().sort(function(a,b){ const wi = a.workItemId.localeCompare(b.workItemId); if (wi !== 0) return wi; const ca = a.createdAt.localeCompare(b.createdAt); if (ca !== 0) return ca; return a.id.localeCompare(b.id); });", @@ -205,7 +258,7 @@ export async function exportToJsonlAsync( ].join('\n'); return new Promise((resolve, reject) => { - const worker = new Worker(workerCode, { eval: true, workerData: { items, comments, dependencyEdges, auditResults, filepath } }); + const worker = new Worker(workerCode, { eval: true, workerData: { items, comments, dependencyEdges, auditResults, filepath, headerLine } }); worker.on('message', (msg: any) => { if (!msg || typeof msg !== 'object') return; @@ -240,7 +293,7 @@ export async function exportToJsonlAsync( } catch (err) { // Worker-based export failed; fall back to previous in-process path. try { - const content = buildJsonlContent(items, comments, dependencyEdges, auditResults); + const content = buildJsonlContent(items, comments, dependencyEdges, auditResults, kind); const dir = path.dirname(filepath); const tempName = `${path.basename(filepath)}.tmp-${Math.random().toString(36).slice(2, 10)}`; const tempPath = path.join(dir, tempName); @@ -279,13 +332,26 @@ export function importFromJsonl(filepath: string): { items: WorkItem[], comments return importFromJsonlContent(content); } -export function importFromJsonlContent(content: string): { items: WorkItem[], comments: Comment[], dependencyEdges: DependencyEdge[], auditResults: AuditResult[] } { +export function importFromJsonlContent(content: string): { items: WorkItem[], comments: Comment[], dependencyEdges: DependencyEdge[], auditResults: AuditResult[], kind?: 'full' | 'delta' } { const lines = content.split('\n').filter(line => line.trim() !== ''); const items: WorkItem[] = []; const comments: Comment[] = []; const dependencyEdges: DependencyEdge[] = []; const auditResults: AuditResult[] = []; + + // Detect + consume a sync header on the FIRST line (incremental sync, + // WL-0MSAKUBKW006FN8Q). The header is emitted by exportToJsonlAsync with + // options.kind set. Files without a header are treated as `undefined` kind + // (legacy full snapshot), preserving existing behavior. + let kind: 'full' | 'delta' | undefined; + if (lines.length > 0) { + const header = parseSyncHeader(lines[0]); + if (header) { + kind = header.kind; + lines.shift(); + } + } for (const line of lines) { try { @@ -431,7 +497,7 @@ export function importFromJsonlContent(content: string): { items: WorkItem[], co } } - return { items, comments, dependencyEdges, auditResults }; + return { items, comments, dependencyEdges, auditResults, kind }; } /** diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 9f8cc52b3..5d1d79184 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -140,6 +140,22 @@ const MIGRATIONS: Array<{ id: string; description: string; safe: boolean; requir db.prepare('INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)').run('audit_column_dropped', '1'); } catch (_e) { /* best-effort */ } } + }, + { + id: '20260821-add-last-export-timestamps', + description: 'Add last_export_timestamps table for per-record-type delta sync watermarks', + safe: true, + requiredColumn: '__table:last_export_timestamps', + apply: (db: Database.Database) => { + // Idempotent table creation for existing databases (new DBs get it in + // initializeSchema). Row values are upserted at runtime by the store. + db.exec(` + CREATE TABLE IF NOT EXISTS last_export_timestamps ( + record_type TEXT PRIMARY KEY, + exported_at TEXT NOT NULL + ) + `); + } } ]; diff --git a/src/sync.ts b/src/sync.ts index d5be9679d..8ff9139f2 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -4,6 +4,7 @@ import { WorkItem, Comment, ConflictDetail, ConflictFieldDetail, DependencyEdge, AuditResult } from './types.js'; import { isDefaultValue, stableValueKey, stableItemKey, mergeTags } from './sync/merge-utils.js'; +import { importFromJsonlContent } from './jsonl.js'; import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; @@ -526,7 +527,26 @@ function mergeSameTimestampItems( // When one version has a close and the other has a different non-close status/stage, // prefer the close values. This prevents an unrelated field change on a different // client from silently reverting a close operation. + // + // DELETION PROPAGATION (WL-0MT2KZH0I005XUWE §4.3/AC2): at equal timestamps a + // `deleted` status wins deterministically over any non-deleted value (including a + // close) — deletion is terminal intent, and the same-timestamp strategy must not + // resurrect a soft-deleted record (AC3 remote/local convergence). if (field === 'status') { + if (localValue === 'deleted' || remoteValue === 'deleted') { + const chosenValue = localValue === 'deleted' ? localValue : remoteValue; + (merged as any)[field] = chosenValue; + mergedFields.push(`${field} (deleted preserved)`); + fieldDetails.push({ + field, + localValue, + remoteValue, + chosenValue, + chosenSource: chosenValue === localValue ? 'local' : 'remote', + reason: 'soft delete is terminal and wins at equal timestamps' + }); + continue; + } const localIsClose = localValue === 'completed' && (isTerminalStage(localItem.stage) || isTerminalStage(remoteItem.stage)); const remoteIsClose = remoteValue === 'completed' && (isTerminalStage(remoteItem.stage) || isTerminalStage(localItem.stage)); if (localIsClose && !remoteIsClose) { @@ -717,7 +737,39 @@ function mergeDifferentTimestampItems( // In mergeDifferentTimestampItems, close-preservation for the REMOTE side is // only applied when isRemoteNewer is true. If local is newer, the local intent // (e.g., reopening a closed item) is respected rather than the remote close. + // + // DELETION PROPAGATION (WL-0MT2KZH0I005XUWE §4.3/AC2): `deleted` is TERMINAL + // intent and must never be blocked by close-preservation — deleting a + // completed/in_review item (the delete command preserves the stage) would + // otherwise never converge to the remote. When the NEWER side is `deleted`, its + // status always wins; the newer timestamp already reflects the deletion time. if (field === 'status') { + if (remoteValue === 'deleted' && isRemoteNewer) { + (merged as any)[field] = remoteValue; + mergedFields.push(`${field} (deleted from remote)`); + fieldDetails.push({ + field, + localValue, + remoteValue, + chosenValue: remoteValue, + chosenSource: 'remote', + reason: 'remote records a soft delete (newer timestamp)' + }); + continue; + } + if (localValue === 'deleted' && !isRemoteNewer) { + (merged as any)[field] = localValue; + mergedFields.push(`${field} (deleted from local)`); + fieldDetails.push({ + field, + localValue, + remoteValue, + chosenValue: localValue, + chosenSource: 'local', + reason: 'local records a soft delete (newer timestamp)' + }); + continue; + } const localIsClose = localValue === 'completed' && (isTerminalStage(localItem.stage) || isTerminalStage(remoteItem.stage)); const remoteIsClose = remoteValue === 'completed' && (isTerminalStage(remoteItem.stage) || isTerminalStage(localItem.stage)); if (localIsClose && !remoteIsClose) { @@ -1132,6 +1184,109 @@ export async function getRemoteDataFileContent(dataFilePath: string, target: Git return (await getRemoteDataFileContentWithRef(dataFilePath, target)).content; } +/** + * Result of reconstructing a remote worklog stream from ref history. + */ +export interface DeltaReplayResult { + items: WorkItem[]; + comments: Comment[]; + dependencyEdges: DependencyEdge[]; + auditResults: AuditResult[]; +} + +/** + * Full-snapshot fallback for the pull side (WL-0MT2KZ5GJ000OJIP, design + * §6.2 trigger 1): a reader that has NO full snapshot base (brand-new clone, + * or a store corrupted/cleared since watermarks were lost) cannot apply a + * remote DELTA tip meaningfully — a delta only carries *changed* records, so + * merging it onto an empty local store would produce a fragmentary database + * missing every record untouched since the last full export. + * + * This function walks the remote tracking ref HISTORY (`git rev-list`) from + * oldest to newest and replays each commit's JSONL in order: + * + * - a commit whose file parses as a FULL snapshot (kind `full` or legacy + * headerless) resets the accumulated state to that commit's records; + * - each subsequent DELTA commit is merged by-ID onto the accumulated state + * (adds/updates only); + * + * The newest full snapshot found is therefore the base, and every delta + * published after it is applied on top — the exact chain a delta tip + * describes. The full-snapshot cadence (§5.3, default every 10 syncs) + * guarantees this replay is bounded. + * + * Returns null when the chain is unrepairable (no full snapshot anywhere in + * history, or a commit's file cannot be read) — callers must fail closed and + * NOT merge the partial delta / push an empty snapshot over a populated + * remote (AC5 no data loss). + * + * @param dataFilePath - path of the local JSONL data file (for the repo-relative path) + * @param remoteTrackingRef - materialized local tracking ref (e.g. + * refs/worklog/remotes/origin/worklog/data) whose history to walk + */ +export async function replayRemoteDeltaChain( + dataFilePath: string, + remoteTrackingRef: string +): Promise { + await assertDataFileInCwdRepo(dataFilePath); + await execAsync('git rev-parse --git-dir'); + + const repoRootPath = await getRepoRoot(); + const { relativePath } = getRepoRelativePath(repoRootPath, dataFilePath); + + // Oldest → newest so a full snapshot found later replaces earlier state and + // deltas are applied in publish order. Bounded by the cadence guarantee; cap + // defensively at 200 commits. + let revs: string[] = []; + try { + const { stdout } = await execAsync( + `git rev-list --reverse --max-count=200 ${escapeShellArg(remoteTrackingRef)}` + ); + revs = stdout.split('\n').map(s => s.trim()).filter(Boolean); + } catch { + return null; + } + if (revs.length === 0) return null; + + let accItems: WorkItem[] = []; + let accComments: Comment[] = []; + let accEdges: DependencyEdge[] = []; + let accAudits: AuditResult[] = []; + let foundFull = false; + + for (const rev of revs) { + let content: string; + try { + content = await execGitCaptureStdout(['show', `${rev}:${relativePath}`]); + } catch { + // Commit does not contain the data file (e.g. pre-sync init commits). + continue; + } + let parsed: ReturnType; + try { + parsed = importFromJsonlContent(content); + } catch { + return null; // unreadable stream in history — cannot repair + } + if (parsed.kind === 'delta') { + // Delta: merge onto accumulated state by-ID. + accItems = mergeWorkItems(accItems, parsed.items).merged; + accComments = mergeComments(accComments, parsed.comments).merged; + accEdges = mergeDependencyEdges(accEdges, parsed.dependencyEdges || []).merged; + accAudits = mergeAuditResults(accAudits, parsed.auditResults || []).merged; + } else { + // Full snapshot (or legacy headerless): becomes the new base. + accItems = parsed.items; + accComments = parsed.comments; + accEdges = parsed.dependencyEdges || []; + accAudits = parsed.auditResults || []; + foundFull = true; + } + } + + return foundFull ? { items: accItems, comments: accComments, dependencyEdges: accEdges, auditResults: accAudits } : null; +} + function removeWorktreeFiles(worktreePath: string): void { for (const name of fs.readdirSync(worktreePath)) { if (name === '.git') continue; @@ -1252,6 +1407,17 @@ async function withTempWorktree( } } +/** + * Push the serialized worklog data file to the dedicated remote ref. + * + * Delta-aware behavior (WL-0MSAKUBKW006FN8Q): the file passed in is the + * output of `exportForSync`, which may be either a FULL snapshot or a DELTA + * (a JSONL subset carrying the sync header). Both are committed to the remote + * ref tip as a new commit on the existing branch history — which is what + * allows `replayRemoteDeltaChain` to walk back to the newest full snapshot + * when a reader has no local base. This function itself is kind-agnostic: it + * pushes whatever JSONL it is given onto `refs/worklog/data`. + */ export async function gitPushDataFileToBranch( repoDataFilePath: string, commitMessage: string, @@ -1260,7 +1426,6 @@ export async function gitPushDataFileToBranch( // Cross-project safety guard: never push the data file to a different // repository's remote ref than the one owning the file. await assertDataFileInCwdRepo(repoDataFilePath); - // SAFETY GUARD: reject pushes to regular branches or tags. // Worklog data must only be stored on dedicated refs under refs/worklog/ // to prevent accidental corruption of the project working tree. diff --git a/src/theme.ts b/src/theme.ts index a8ef9ce7f..8d3fa276b 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,5 +1,7 @@ import chalk from 'chalk'; +// ─── Chalk Theme ─────────────────────────────────────────────────────── + export const theme = { text: { muted: chalk.gray, @@ -30,3 +32,609 @@ export const theme = { low: chalk.gray, }, } as const; + +// ─── Icon Options ────────────────────────────────────────────────────── + +/** + * Options for icon rendering. + */ +export interface IconOptions { + /** When true, use text fallback instead of emoji/icon glyph. */ + noIcons?: boolean; +} + +// ─── Priority Icons ──────────────────────────────────────────────────── +// More graphical icons that visually convey priority levels + +const PRIORITY_ICON: Record = { + critical: '\u{1F6A8}', // 🚨 Rotating light - urgent/danger + high: '\u{2B50}', // ⭐ Star - important + medium: '\u{1F4CB}', // 📋 Clipboard - standard task + low: '\u{1F422}', // 🐢 Turtle - slow/low priority +}; + +const PRIORITY_FALLBACK: Record = { + critical: '[CRIT]', + high: '[HIGH]', + medium: '[MED ]', + low: '[LOW ]', +}; + +const PRIORITY_LABEL: Record = { + critical: 'Critical priority', + high: 'High priority', + medium: 'Medium priority', + low: 'Low priority', +}; + +// ─── Status Icons ─────────────────────────────────────────────────────── + +const STATUS_ICON: Record = { + open: '\u{1F513}', // 🔓 Unlocked + 'in-progress': '\u{1F504}', // 🔄 Arrows (recycling) + completed: '\u{2714}\u{FE0F}', // ✔️ Heavy check mark + blocked: '\u{26D4}', // ⛔ No entry + deleted: '\u{1F5D1}\u{FE0F}', // 🗑️ Wastebasket + input_needed: '\u{1F4AC}', // 💬 Speech balloon +}; + +const STATUS_FALLBACK: Record = { + open: '[OPEN]', + 'in-progress': '[INPR]', + completed: '[DONE]', + blocked: '[BLKD]', + deleted: '[DEL ]', + input_needed: '[HELP]', +}; + +const STATUS_LABEL: Record = { + open: 'Status: Open', + 'in-progress': 'Status: In progress', + completed: 'Status: Completed', + blocked: 'Status: Blocked', + deleted: 'Status: Deleted', + input_needed: 'Status: Input needed', +}; + +// ─── Public API ───────────────────────────────────────────────────────── + +/** + * Check whether icons should be rendered. + * + * Icons are enabled by default when running in a TTY. They can be + * disabled via the `WL_NO_ICONS` environment variable or the + * `noIcons` option. + */ +export function iconsEnabled(opts?: { noIcons?: boolean }): boolean { + // Explicit opt-out via option (takes priority over everything). + if (opts?.noIcons === true) return false; + // Explicit opt-in via option overrides env var. + if (opts?.noIcons === false) return true; + // Global env var opt-out. + if (typeof process !== 'undefined' && process.env?.WL_NO_ICONS === '1') return false; + // Default to enabled; callers can further restrict based on TTY. + return true; +} + +/** + * Get the icon string (emoji or text fallback) for a work item priority. + * + * @param priority - The priority value (e.g. 'critical', 'high', 'medium', 'low'). + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function priorityIcon(priority: string, opts?: IconOptions): string { + const key = (priority || '').toLowerCase().trim(); + if (opts?.noIcons === true) { + return PRIORITY_FALLBACK[key] ?? ''; + } + return PRIORITY_ICON[key] ?? ''; +} + +/** + * Get the icon string (emoji or text fallback) for a work item status. + * + * @param status - The status value (e.g. 'open', 'in-progress', 'completed'). + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function statusIcon(status: string, opts?: IconOptions): string { + const key = (status || '').toLowerCase().trim(); + if (opts?.noIcons === true) { + return STATUS_FALLBACK[key] ?? ''; + } + return STATUS_ICON[key] ?? ''; +} + +/** + * Get the accessible label for a priority icon. + * + * @param priority - The priority value. + * @returns A human-readable label describing the priority (e.g. "High priority"). + */ +export function priorityLabel(priority: string): string { + return PRIORITY_LABEL[(priority || '').toLowerCase().trim()] ?? ''; +} + +/** + * Get the accessible label for a status icon. + * + * @param status - The status value. + * @returns A human-readable label describing the status (e.g. "Status: Open"). + */ +export function statusLabel(status: string): string { + return STATUS_LABEL[(status || '').toLowerCase().trim()] ?? ''; +} + +/** + * Get the text fallback for a priority icon. + * + * @param priority - The priority value. + * @returns The bracketed text label (e.g. "[CRIT]"). + */ +export function priorityFallback(priority: string): string { + return PRIORITY_FALLBACK[(priority || '').toLowerCase().trim()] ?? ''; +} + +/** + * Get the text fallback for a status icon. + * + * @param status - The status value. + * @returns The bracketed text label (e.g. "[OPEN]"). + */ +export function statusFallback(status: string): string { + return STATUS_FALLBACK[(status || '').toLowerCase().trim()] ?? ''; +} + +// ─── Risk Icons ───────────────────────────────────────────────────────── + +const RISK_ICON: Record = { + low: '\u{1F331}', // 🌱 Seedling + medium: '\u{26A0}\u{FE0F}', // ⚠️ Warning + high: '\u{1F525}', // 🔥 Fire + severe: '\u{1F6A8}', // 🚨 Rotating light +}; + +const RISK_FALLBACK: Record = { + low: '[LOW]', + medium: '[MED]', + high: '[HIGH]', + severe: '[SEV]', +}; + +const RISK_LABEL: Record = { + low: 'Risk: Low', + medium: 'Risk: Medium', + high: 'Risk: High', + severe: 'Risk: Severe', +}; + +// ─── Effort Icons ─────────────────────────────────────────────────────── + +const EFFORT_ICON: Record = { + xs: '\u{1F41C}', // 🐜 Ant + s: '\u{1F407}', // 🐇 Rabbit + m: '\u{1F415}', // 🐕 Dog + l: '\u{1F418}', // 🐘 Elephant + xl: '\u{1F40B}', // 🐋 Whale + // Full-text aliases (used by Worklog CLI and effort-and-risk skill) + 'extra small': '\u{1F41C}', // 🐜 Ant + small: '\u{1F407}', // 🐇 Rabbit + medium: '\u{1F415}', // 🐕 Dog + large: '\u{1F418}', // 🐘 Elephant + 'extra large': '\u{1F40B}', // 🐋 Whale + xlarge: '\u{1F40B}', // 🐋 Whale — variant spelling +}; + +const EFFORT_FALLBACK: Record = { + xs: '[XS]', + s: '[S]', + m: '[M]', + l: '[L]', + xl: '[XL]', + // Full-text aliases + 'extra small': '[XS]', + small: '[S]', + medium: '[M]', + large: '[L]', + 'extra large': '[XL]', + xlarge: '[XL]', +}; + +const EFFORT_LABEL: Record = { + xs: 'Effort: XS (extra small)', + s: 'Effort: S (small)', + m: 'Effort: M (medium)', + l: 'Effort: L (large)', + xl: 'Effort: XL (extra large)', + // Full-text aliases + 'extra small': 'Effort: XS (extra small)', + small: 'Effort: S (small)', + medium: 'Effort: M (medium)', + large: 'Effort: L (large)', + 'extra large': 'Effort: XL (extra large)', + xlarge: 'Effort: XL (extra large)', +}; + +// ─── Epic Icons ────────────────────────────────────────────────────────── + +const EPIC_ICON: Record = { + epic: '\u{1F3F0}', // 🏰 Castle - large feature with dependencies +}; + +const EPIC_FALLBACK: Record = { + epic: '[EPIC]', +}; + +const EPIC_LABEL: Record = { + epic: 'Issue Type: Epic', +}; + +// ─── Stage Icons ─────────────────────────────────────────────────────── + +const STAGE_ICON: Record = { + idea: '\u{1F4A1}', // 💡 + intake_complete: '\u{1F4E5}', // 📥 + plan_complete: '\u{1F4CB}', // 📋 + in_progress: '\u{1F6E0}\u{FE0F}', // 🛠️ + in_review: '\u{1F50D}', // 🔍 + done: '\u{1F3C1}', // 🏁 +}; + +const STAGE_FALLBACK: Record = { + idea: '[IDEA]', + intake_complete: '[INTAKE]', + plan_complete: '[PLAN]', + in_progress: '[PROG]', + in_review: '[REVIEW]', + done: '[DONE]', +}; + +const STAGE_LABEL: Record = { + idea: 'Stage: Idea', + intake_complete: 'Stage: Intake Complete', + plan_complete: 'Stage: Plan Complete', + in_progress: 'Stage: In Progress', + in_review: 'Stage: In Review', + done: 'Stage: Done', +}; + +// ─── Audit Result Icons ──────────────────────────────────────────────── + +/** + * Audit result key for icon lookup. + * true → 'yes', false → 'no', null/undefined → 'unknown' + */ +function auditKey(result: boolean | null | undefined): string { + if (result === true) return 'yes'; + if (result === false) return 'no'; + return 'unknown'; +} + +const AUDIT_ICON: Record = { + yes: '\u{2705}', // ✅ + no: '\u{274C}', // ❌ + unknown: '\u{2754}', // ❔ +}; + +const AUDIT_FALLBACK: Record = { + yes: '[YES]', + no: '[NO]', + unknown: '[UNKN]', +}; + +const AUDIT_LABEL: Record = { + yes: 'Audit: Passed', + no: 'Audit: Failed', + unknown: 'Audit: Not run', +}; + +// ─── Risk Public API ──────────────────────────────────────────────────── + +/** + * Get the icon string (emoji or text fallback) for a work item risk level. + * + * @param risk - The risk value (e.g. 'Low', 'Medium', 'High', 'Severe'). + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function riskIcon(risk: string | undefined | null, opts?: IconOptions): string { + const key = (risk || '').toLowerCase().trim(); + if (opts?.noIcons === true) { + return RISK_FALLBACK[key] ?? ''; + } + return RISK_ICON[key] ?? ''; +} + +/** + * Get the accessible label for a risk icon. + * + * @param risk - The risk value. + * @returns A human-readable label describing the risk (e.g. "Risk: Medium"). + */ +export function riskLabel(risk: string | undefined | null): string { + return RISK_LABEL[(risk || '').toLowerCase().trim()] ?? ''; +} + +/** + * Get the text fallback for a risk icon. + * + * @param risk - The risk value. + * @returns The bracketed text label (e.g. "[MED]"). + */ +export function riskFallback(risk: string | undefined | null): string { + return RISK_FALLBACK[(risk || '').toLowerCase().trim()] ?? ''; +} + +// ─── Effort Public API ────────────────────────────────────────────────── + +/** + * Get the icon string (emoji or text fallback) for a work item effort T-shirt size. + * + * @param effort - The effort value (e.g. 'XS', 'S', 'M', 'L', 'XL'). + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function effortIcon(effort: string | undefined | null, opts?: IconOptions): string { + const key = (effort || '').toLowerCase().trim(); + if (opts?.noIcons === true) { + return EFFORT_FALLBACK[key] ?? ''; + } + return EFFORT_ICON[key] ?? ''; +} + +/** + * Get the accessible label for an effort icon. + * + * @param effort - The effort value. + * @returns A human-readable label describing the effort (e.g. "Effort: M (medium)"). + */ +export function effortLabel(effort: string | undefined | null): string { + return EFFORT_LABEL[(effort || '').toLowerCase().trim()] ?? ''; +} + +/** + * Get the text fallback for an effort icon. + * + * @param effort - The effort value. + * @returns The bracketed text label (e.g. "[M]"). + */ +export function effortFallback(effort: string | undefined | null): string { + return EFFORT_FALLBACK[(effort || '').toLowerCase().trim()] ?? ''; +} + +// ─── Stage Public API ────────────────────────────────────────────────── + +/** + * Get the icon string (emoji or text fallback) for a work item stage. + * + * @param stage - The stage value (e.g. 'idea', 'in_progress', 'done'). + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function stageIcon(stage: string | undefined | null, opts?: IconOptions): string { + const key = (stage || '').toLowerCase().trim(); + if (opts?.noIcons === true) { + return STAGE_FALLBACK[key] ?? ''; + } + return STAGE_ICON[key] ?? ''; +} + +/** + * Get the accessible label for a stage icon. + * + * @param stage - The stage value. + * @returns A human-readable label describing the stage (e.g. "Stage: In Progress"). + */ +export function stageLabel(stage: string | undefined | null): string { + return STAGE_LABEL[(stage || '').toLowerCase().trim()] ?? ''; +} + +/** + * Get the text fallback for a stage icon. + * + * @param stage - The stage value. + * @returns The bracketed text label (e.g. "[PROG]"). + */ +export function stageFallback(stage: string | undefined | null): string { + return STAGE_FALLBACK[(stage || '').toLowerCase().trim()] ?? ''; +} + +// ─── Audit Result Public API ─────────────────────────────────────────── + +/** + * Get the icon string (emoji or text fallback) for an audit result. + * + * @param result - The audit result: true (yes/passed), false (no/failed), null/undefined (unknown). + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function auditIcon(result: boolean | null | undefined, opts?: IconOptions): string { + const key = auditKey(result); + if (opts?.noIcons === true) { + return AUDIT_FALLBACK[key] ?? ''; + } + return AUDIT_ICON[key] ?? ''; +} + +/** + * Get the accessible label for an audit result icon. + * + * @param result - The audit result value. + * @returns A human-readable label (e.g. "Audit: Passed"). + */ +export function auditLabel(result: boolean | null | undefined): string { + return AUDIT_LABEL[auditKey(result)] ?? ''; +} + +/** + * Get the text fallback for an audit result icon. + * + * @param result - The audit result value. + * @returns The bracketed text label (e.g. "[YES]"). + */ +export function auditFallback(result: boolean | null | undefined): string { + return AUDIT_FALLBACK[auditKey(result)] ?? ''; +} + +// ─── Stale Audit Result Icons ─────────────────────────────────────────── + +const STALE_AUDIT_ICON: Record = { + yes: '\u{1F7E9}', // 🟩 Green square button +}; + +const STALE_AUDIT_FALLBACK: Record = { + yes: '[YES_STALE]', +}; + +const STALE_AUDIT_LABEL: Record = { + yes: 'Audit: Passed (stale)', +}; + +// ─── Stale Audit Result Public API ──────────────────────────────────── + +/** + * Get the icon string for a stale audit result. + * + * For stale-but-passed audits (result === true), returns the stale-passed + * icon (\u{1F7E9}). For all other results, falls back to the regular + * audit icon for backward compatibility. + * + * @param result - The audit result. + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function auditStaleIcon(result: boolean | null | undefined, opts?: IconOptions): string { + if (result === true) { + if (opts?.noIcons === true) { + return STALE_AUDIT_FALLBACK.yes; + } + return STALE_AUDIT_ICON.yes; + } + return auditIcon(result, opts); +} + +/** + * Get the accessible label for a stale audit result icon. + * + * @param result - The audit result value. + * @returns A human-readable label (e.g. "Audit: Passed (stale)"). + */ +export function auditStaleLabel(result: boolean | null | undefined): string { + if (result === true) { + return STALE_AUDIT_LABEL.yes; + } + return auditLabel(result); +} + +/** + * Get the text fallback for a stale audit result icon. + * + * @param result - The audit result value. + * @returns The bracketed text label (e.g. "[YES_STALE]"). + */ +export function auditStaleFallback(result: boolean | null | undefined): string { + if (result === true) { + return STALE_AUDIT_FALLBACK.yes; + } + return auditFallback(result); +} + +// ─── Producer Review Flag Icons ──────────────────────────────────────── + +/** + * Producer review key for icon lookup. + * true → 'needed', false → 'not_needed', null/undefined → 'not_needed' (default to ok) + */ +function producerReviewKey(needsProducerReview: boolean | null | undefined): string { + if (needsProducerReview === true) return 'needed'; + return 'not_needed'; +} + +const PRODUCER_REVIEW_ICON: Record = { + needed: '\u{274C}', // ❌ + not_needed: '\u{2705}', // ✅ +}; + +const PRODUCER_REVIEW_FALLBACK: Record = { + needed: '[NEEDS_PRODUCER]', + not_needed: '[PRODUCER_OK]', +}; + +const PRODUCER_REVIEW_LABEL: Record = { + needed: 'Needs producer review', + not_needed: 'Producer review complete', +}; + +// ─── Producer Review Public API ─────────────────────────────────────── + +/** + * Get the icon string (emoji or text fallback) for the needsProducerReview flag. + * + * @param needsProducerReview - Whether the work item needs producer review. + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function needsProducerReviewIcon(needsProducerReview: boolean | null | undefined, opts?: IconOptions): string { + const key = producerReviewKey(needsProducerReview); + if (opts?.noIcons === true) { + return PRODUCER_REVIEW_FALLBACK[key] ?? ''; + } + return PRODUCER_REVIEW_ICON[key] ?? ''; +} + +/** + * Get the accessible label for a producer review icon. + * + * @param needsProducerReview - Whether the work item needs producer review. + * @returns A human-readable label (e.g. "Needs producer review"). + */ +export function needsProducerReviewLabel(needsProducerReview: boolean | null | undefined): string { + return PRODUCER_REVIEW_LABEL[producerReviewKey(needsProducerReview)] ?? ''; +} + +/** + * Get the text fallback for a producer review icon. + * + * @param needsProducerReview - Whether the work item needs producer review. + * @returns The bracketed text label (e.g. "[NEEDS_PRODUCER]"). + */ +export function needsProducerReviewFallback(needsProducerReview: boolean | null | undefined): string { + return PRODUCER_REVIEW_FALLBACK[producerReviewKey(needsProducerReview)] ?? ''; +} + +// ─── Epic Public API ──────────────────────────────────────────────────── + +/** + * Get the icon string (emoji or text fallback) for an epic work item. + * + * Epic icon: 🏰 (castle) — represents a large feature with dependencies. + * Fallback: [EPIC] + * + * @param opts - Options controlling fallback behaviour. + * @returns The icon string (emoji or bracketed text). + */ +export function epicIcon(opts?: IconOptions): string { + if (opts?.noIcons === true) { + return EPIC_FALLBACK.epic; + } + return EPIC_ICON.epic; +} + +/** + * Get the accessible label for the epic icon. + * + * @returns A human-readable label ("Issue Type: Epic"). + */ +export function epicLabel(): string { + return EPIC_LABEL.epic; +} + +/** + * Get the text fallback for the epic icon. + * + * @returns The bracketed text label ("[EPIC]"). + */ +export function epicFallback(): string { + return EPIC_FALLBACK.epic; +} diff --git a/src/types.ts b/src/types.ts index 4ed7dd5af..9bce04da7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,4 +25,5 @@ export { type NextWorkItemResult, type ShowJsonOutput, type DemotedParent, + type RevertedItem, } from '@worklog/shared/types'; diff --git a/src/version.ts b/src/version.ts index e659049e5..8d08d82db 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.10'; +export const WORKLOG_VERSION = '1.0.11'; diff --git a/templates/AGENTS.md b/templates/AGENTS.md index ad66d0b4e..61336ba8f 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -1,258 +1,7 @@ - +## Global agent guidance -## work-item Tracking with Worklog (wl) +Read the global agent instructions at `~/.pi/agent/AGENTS.md` — they define the core principles, the Worklog (wl) work-item workflow, and the coding disciplines that apply to every project. -IMPORTANT: This project uses Worklog (wl) for ALL work-item tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. +## Project-specific guidance -## CRITICAL RULES - -- Use Worklog (wl), described below, for ALL task tracking, do NOT use markdown TODOs, task lists, or other tracking methods -- When mentioning a work item always use its title followed by its ID in parentheses, e.g. "Fix login bug (WL-1234)" -- Always keep work items up to date with accurate status, priority, stage, and assignee -- Whenever you are provided with, or discover, a new work item create it in wl immediately -- Whenever you are provided with or discover important context (specifications, designs, user-stories) ensure the information is added to the description of the relevant work item(s) OR create a new work item if none exist -- Whenever you create a planning document (PRD, spec, design doc) add references to the document in the description of any work item that is directly related to the document -- Work items cannot be closed until all child items are closed, all blocking dependencies resolved and a Producer has reviewed and approved the work -- Never commit changes without associating them with a work item -- Never commit changes without ensuring all tests and quality checks pass -- Whenever a commit is made add a comment to impacted the work item(s) describing the changes, the files affected, and including the commit hash. -- If push fails, resolve and retry until it succeeds -- When using backticks in arguments to shell commands, escape them properly to avoid errors - -### Important Rules - -- Use wl as a primary source of truth, only the source code is more authoritative -- Always use `--json` flag for programmatic use -- When new work items are discovered or prompted while working on an existing item create a new work item with `wl create` - - If the item must be completed before the current work item can be completed add it as a child of the current item (`wl create --parent `) - - If the item is related to the current work item but not blocking its completion add a reference to the current item in the description (`discovered-from:`) -- Check `wl next` before asking "what should I work on?" and always offer the response as a next steps suggestion, with an explanation -- Run `wl --help` and `wl --help` to learn about the capabilities of WorkLog (wl) and discover available flags -- Use work items to track all significant work, including bugs, features, tasks, epics, chores -- Use clear, concise titles and detailed descriptions for all work items -- Use parent/child relationships to track dependencies and subtasks -- Use priorities to indicate the importance of work items -- Use stages to track workflow progress -- Do NOT clutter repo root with planning documents - -### work-item Types - -Track work-item types with `--issue-type`: - -- bug - Something broken -- docs - Documentation (guides, README, API docs, podcast scripts) -- feature - New functionality -- task - Work item (tests, refactoring) -- epic - Large feature with subtasks -- chore - Maintenance (dependencies, tooling) - -### Work Item Descriptions - -- Use clear, concise titles summarizing the work item. -- Do not escape special characters -- The description must provide sufficient context for understanding and implementing the work item. -- At a minimum include: - - A summary of the problem or feature. - - Example User Stories if applicable. - - Expected behaviour and outcomes. - - Steps to reproduce (for bugs). - - Suggested implementation approach if relevant. - - Links to related work items or documentation. - - Measurable and testable acceptance criteria. - -### Priorities - -Worklog uses named priorities: - -- critical - Security, data loss, broken builds -- high - Major features, important bugs -- medium - Default, nice-to-have -- low - Polish, optimization - -### Dependencies - -Use parent/child relationships to track blocking dependencies. - -- Child items must be completed before the parent can be closed. -- If a work item blocks another, make it a child of the blocked item. -- If a work item blocks multiple items, create the parent/child relationships with the highest priority item as the parent unless one of the items is in-progress, in which case that item should be the parent. - - If in doubt raise for product manager review. - -For non-hierarchical blocking relationships, prefer dependency edges over description-based conventions. Dependency edges are the recommended way to track blockers: - -```bash -wl dep add -wl dep list --json -wl dep rm -``` - -Description-based conventions (`discovered-from:`, `related-to:`, `blocked-by:`) remain supported for informal cross-references and planning notes, but dependency edges should be used for any relationship that affects scheduling or blocking. - -### Workflow management - -- Use the `--stage` flag to track workflow stages according to your particular process, - - e.g. `idea`, `prd_complete`, `milestones_defined`, `plan_complete`, `in_progress``done`. -- Use the `--assignee` flag to assign work items to agents. -- Use the `--tags` flag to add arbitrary tags for filtering and organization. Though avoid over-tagging. -- Use comments to document progress, decisions, and context. -- Use `risk` and `effort` fields to track complexity and potential issues. - - If available use the `effort_and_risk` agent skill to estimate these values. - -1. Check ready work: `wl next` -2. Claim your task: `wl update --status in-progress` -3. Work on it: implement, test, document -4. Discover new work? Create a linked issue: - -- `wl create "Found bug" --priority high --tags "discovered-from:"` - -5. Complete: `wl close --reason "PR #123 merged"` -6. Sync: run `wl sync` before ending the session - -### Work-Item Management - -```bash -# Create work items -wl create --help # Show help for creating work items -wl create --title "Bug title" --description "

" --priority high --issue-type bug --json -wl create --title "Feature title" --description "
" --priority medium --issue-type feature --json -wl create --title "Epic title" --description "
" --priority high --issue-type epic --json -wl create --title "Subtask" --parent --priority medium --json -wl create --title "Found bug" --priority high --tags "discovered-from:WL-123" --json - -# Update work items -wl update --help # Show help for updating work items -wl update --status in-progress --json -wl update --priority high --json - -# Comments -wl comment --help # Show help for comment commands -wl comment list --json -wl comment show -C1 --json -wl comment update -C1 --comment "Revised" --json -wl comment delete -C1 --json - -# Close or delete -# wl close: provide -r reason for closing; can close multiple ids -wl close --reason "PR #123 merged" --json -wl close --json - -# *Destructive command ask for confirmation before running* Dekete a work item permanently -wl delete --json - -# Dependencies -wl dep --help # Show help for dependency commands -wl dep add -wl dep list --json -wl dep rm -``` - -### Project Status - -```bash -# Show the next ready work items (JSON output) -# Display a recommendation for the next item to work on in JSON -wl next --json -# Display a recommendation for the next item assigned to `agent-name` to work on -wl next --assignee "agent-name" --json -# Display a recommendation for the next item to work on that matches a keyword (in title/description/comments) -wl next --search "keyword" --json - -# Show all items with status `in-progress` in JSON -wl in-progress --json -# Show in-progress items assigned to `agent-name` -wl in-progress --assignee "agent-name" --json - -# Show recently created or updated work items -wl recent --json -# Show the 10 most recently created or updated items -wl recent --number 10 --json -# Include child/subtask items when showing recent items -wl recent --children --json - -# List all work items except those in a completed state -wl list --json -# Limit list output -wl list -n 5 --json -# List items filtered by status (open, in-progress, closed, etc.) -wl list --status open --json -wl list --status open,in-progress --json # comma-separated: matches any listed status -# List items filtered by priority (critical, high, medium, low) -wl list --priority high --json -# List items filtered by comma-separated tags -wl list --tags "frontend,bug" --json -# List items filtered by assignee (short or full name) -wl list --assignee alice --json -# List items filtered by stage (e.g. triage, review, done) -wl list --stage review --json - -# Full-text search across all work items (title, description, comments, tags) -wl search --json -# Search with status filter -wl search --status open --json -# Search by work item ID (exact, unprefixed, or partial >= 8 chars) -wl search --json -wl search --json - -# Show details for a specific work item -wl show --comments --json -# Show details including child/subtask items -wl show --children --json -``` - -#### Team - -```bash - # Sync local worklog data with the remote (shares changes) - wl sync - # Import issues from GitHub into the worklog (GitHub -> worklog) - wl github import - # Push worklog changes to GitHub issues (worklog -> GitHub) - wl github push -``` - -#### Plugins - -Depending on your setup, you may have additional wl plugins installed. Check available plugins with `wl --help` (See plugins section) to view more information about the features provided by each plugin run `wl --help` - -#### Help - -Run `wl --help` to see general help text and available commands. -Run `wl --help` to see help text and all available flags for any command. - - - -## Architecture Notes for Agents - -### Data Storage Architecture - -Worklog uses **SQLite as the runtime source of truth** with an **ephemeral JSONL pattern** for Git sync: - -- **SQLite** (`.worklog/worklog.db`): All runtime reads/writes happen here -- **JSONL** (`.worklog/worklog-data.jsonl`): Only exists transiently during sync operations -- **Git**: Persistent storage for collaboration - -### Important Rules for Agents - -1. **Work with SQLite, not JSONL** - - Never manually edit JSONL files - - Use the database API for all data operations - - JSONL is only for Git transport, not for data manipulation - -2. **Migration Complete** - - The old `autoExport` feature has been removed - - No automatic JSONL exports after database writes - - TUI is now responsive regardless of data size - -3. **Sync Behavior** - - `wl sync` exports SQLite → JSONL → pushes to Git → deletes local JSONL - - JSONL only exists during the sync window (seconds) - - Working directory should not have persistent JSONL files - -4. **Legacy JSONL Files** - - If you encounter a persistent JSONL file, it may be from an older version - - Use `wl doctor migrate` to import it into SQLite - - Use `wl doctor migrate --delete` to import and remove the file - -### For More Information - -See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for detailed architecture documentation. +(project-specific rules are added by the project owner here, never by copying the global file) diff --git a/templates/scheduled-prompts.json b/templates/scheduled-prompts.json new file mode 100644 index 000000000..9b3ac0793 --- /dev/null +++ b/templates/scheduled-prompts.json @@ -0,0 +1,10 @@ +{ + "entries": [ + { + "id": "refactor", + "prompt": "/skill:refactor", + "intervalDays": 3, + "lastTriggeredAt": null + } + ] +} \ No newline at end of file diff --git a/test/tmp_mig/worklog.db b/test/tmp_mig/worklog.db index bdf59722b..ea008a5a2 100644 Binary files a/test/tmp_mig/worklog.db and b/test/tmp_mig/worklog.db differ diff --git a/tests/api-audit-revert.test.ts b/tests/api-audit-revert.test.ts new file mode 100644 index 000000000..3a7d34cf1 --- /dev/null +++ b/tests/api-audit-revert.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for the API-level audit write path reversion. + * + * The REST API (src/api.ts) routes an `audit` field in PUT /items/:id (and + * the prefixed variant) to the audit_results table. For consistency with the + * CLI audit write paths, a "not ready to close" verdict on an in_review + * (completed) item reverts it to open/plan_complete and the response carries + * a `reverted` field. + * + * Work item: WL-0MSKHYI5U0069FVV (child WL-0MT0T1EQJ009DHO8) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { WorklogDatabase } from '../src/database.js'; +import { createAPI } from '../src/api.js'; +import { createTempDir, cleanupTempDir, createTempDbPath, createTempJsonlPath } from './test-utils.js'; + +describe('API audit write path reversion', () => { + let tempDir: string; + let dbPath: string; + let jsonlPath: string; + let db: WorklogDatabase; + + beforeEach(() => { + tempDir = createTempDir(); + dbPath = createTempDbPath(tempDir); + jsonlPath = createTempJsonlPath(tempDir); + if (fs.existsSync(jsonlPath)) fs.unlinkSync(jsonlPath); + db = new WorklogDatabase('APIT', dbPath, jsonlPath, true, true); + }); + + afterEach(() => { + db.close(); + cleanupTempDir(tempDir); + }); + + async function putAudit(id: string, auditText: string, route = `/items/${id}`): Promise { + const app = createAPI(db); + const server = app.listen(0); + const port = (server.address() as any).port; + try { + const res = await globalThis.fetch(`http://127.0.0.1:${port}${route}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ audit: auditText }), + }); + return { status: res.status, body: await res.json() }; + } finally { + server.close(); + } + } + + it('reverts an in_review/completed item on a not-ready-to-close audit and reports it', async () => { + const item = db.create({ title: 'API item', status: 'completed', stage: 'in_review' }); + + const { status, body } = await putAudit(item.id, 'Ready to close: No\nStill needs work'); + + expect(status).toBe(200); + expect(body.reverted).toBeDefined(); + expect(body.reverted.item.id).toBe(item.id); + expect(body.reverted.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(body.reverted.to).toEqual({ status: 'open', stage: 'plan_complete' }); + expect(body.status).toBe('open'); + expect(body.stage).toBe('plan_complete'); + + const refreshed = db.get(item.id); + expect(refreshed?.status).toBe('open'); + expect(refreshed?.stage).toBe('plan_complete'); + }); + + it('preserves priority on API reversion', async () => { + const item = db.create({ title: 'API item', status: 'completed', stage: 'in_review', priority: 'high' }); + + const { body } = await putAudit(item.id, 'Ready to close: No\nNope'); + + expect(body.reverted).toBeDefined(); + expect(db.get(item.id)?.priority).toBe('high'); + }); + + it('does not revert when the verdict is ready-to-close yes', async () => { + const item = db.create({ title: 'API item', status: 'completed', stage: 'in_review' }); + + const { status, body } = await putAudit(item.id, 'Ready to close: Yes\nAll good'); + + expect(status).toBe(200); + expect(body.reverted).toBeUndefined(); + const refreshed = db.get(item.id); + expect(refreshed?.status).toBe('completed'); + expect(refreshed?.stage).toBe('in_review'); + }); + + it('does not revert done items', async () => { + const item = db.create({ title: 'API item', status: 'completed', stage: 'done' }); + + const { body } = await putAudit(item.id, 'Ready to close: No\nNope'); + + expect(body.reverted).toBeUndefined(); + expect(db.get(item.id)?.stage).toBe('done'); + }); + + it('reverts via the prefixed route for consistency', async () => { + const item = db.create({ title: 'API item', status: 'completed', stage: 'in_review' }); + + const { status, body } = await putAudit( + item.id, + 'Ready to close: No\nNope', + `/projects/APIT/items/${item.id}` + ); + + expect(status).toBe(200); + expect(body.reverted).toBeDefined(); + expect(body.reverted.to).toEqual({ status: 'open', stage: 'plan_complete' }); + }); +}); diff --git a/tests/cli/close-recursive.test.ts b/tests/cli/close-recursive.test.ts index 55376fbd2..5f1ee6d7a 100644 --- a/tests/cli/close-recursive.test.ts +++ b/tests/cli/close-recursive.test.ts @@ -733,8 +733,12 @@ describe('close command recursive close', () => { it('warning includes reason: audit result is not ready to close', async () => { const { parentId, childIds } = await createParentWithChildren(2, true); - // Set audit result with readyToClose=false + // Mark the parent not ready to close. Since WL-0MSKHYI5U0069FVV, this + // verdict reverts an in_review item to open/plan_complete, so move the + // parent back to in_review afterwards — the not-ready audit result is + // then what blocks the close. await runJson(`update ${parentId} --audit-text "Ready to close: No\nNot ready yet"`); + await runJson(`update ${parentId} --status completed --stage in_review`); const { stderr } = await runRaw(`close ${parentId} -r "done"`); diff --git a/tests/cli/init.test.ts b/tests/cli/init.test.ts index b5ad81b8f..16e5e84f8 100644 --- a/tests/cli/init.test.ts +++ b/tests/cli/init.test.ts @@ -18,7 +18,30 @@ import { initRepo, initBareRepo } from './git-helpers.js'; import { cleanupTempDir, createTempDir } from '../test-utils.js'; describe('CLI Init Tests', () => { - it('should insert the AGENTS.md pointer line when an existing file is present', async () => { + const CANONICAL_GLOBAL_REFERENCE = '## Global agent guidance'; + const CANONICAL_REFERENCE_LINE = 'Read the global agent instructions at `~/.pi/agent/AGENTS.md`'; + const CANONICAL_PROJECT_SECTION = '## Project-specific guidance'; + + it('should install the canonical global-reference structure in a fresh project', async () => { + const tempState = enterTempDir(); + try { + await execAsync( + `tsx ${cliPath} init --project-name "Test Project" --prefix TEST --auto-export yes --auto-sync no --workflow-inline no --agents-template skip --stats-plugin-overwrite no` + ); + + const updated = fs.readFileSync('AGENTS.md', 'utf-8'); + expect(updated).toContain(CANONICAL_GLOBAL_REFERENCE); + expect(updated).toContain(CANONICAL_REFERENCE_LINE); + expect(updated).toContain(CANONICAL_PROJECT_SECTION); + // The template is a reference, not a copy of the global instruction set. + expect(updated).not.toContain('CRITICAL RULES'); + expect(updated).not.toContain('Follow the global AGENTS.md in addition to the rules below'); + } finally { + leaveTempDir(tempState); + } + }, 45000); + + it('should insert the AGENTS.md global-reference template when an existing file is present', async () => { const tempState = enterTempDir(); try { const existing = '## Local Rules\n\n- Do the local thing\n'; @@ -29,20 +52,22 @@ describe('CLI Init Tests', () => { ); const updated = fs.readFileSync('AGENTS.md', 'utf-8'); - const pointer = 'Follow the global AGENTS.md in addition to the rules below. The local rules below take priority in the event of a conflict.'; const lines = updated.split(/\r?\n/).filter(line => line.trim().length > 0); - expect(lines[0]).toBe(pointer); + // The canonical global-reference section is emitted first ... + expect(lines[0]).toBe(CANONICAL_GLOBAL_REFERENCE); + expect(updated).toContain(CANONICAL_REFERENCE_LINE); + // ... and existing local rules are preserved below the reference. + expect(updated.indexOf(CANONICAL_GLOBAL_REFERENCE)).toBeLessThan(updated.indexOf(existing.trim())); expect(updated).toContain(existing.trim()); } finally { leaveTempDir(tempState); } }, 45000); - it('should not duplicate the AGENTS.md pointer line on re-run', async () => { + it('should not duplicate the AGENTS.md global-reference on re-run', async () => { const tempState = enterTempDir(); try { - const pointer = 'Follow the global AGENTS.md in addition to the rules below. The local rules below take priority in the event of a conflict.'; - const existing = `${pointer}\n\n## Local Rules\n\n- Keep it\n`; + const existing = `## Global agent guidance\n\n${CANONICAL_REFERENCE_LINE}\n\n## Project-specific guidance\n\n- Keep it\n`; fs.writeFileSync('AGENTS.md', existing, 'utf-8'); await execAsync( @@ -50,9 +75,9 @@ describe('CLI Init Tests', () => { ); const updated = fs.readFileSync('AGENTS.md', 'utf-8'); - const pointerMatches = updated.split(/\r?\n/).filter(line => line.trim() === pointer).length; - expect(pointerMatches).toBe(1); - expect(updated).toContain('## Local Rules'); + const referenceMatches = updated.split(/\r?\n/).filter(line => line.trim() === CANONICAL_GLOBAL_REFERENCE).length; + expect(referenceMatches).toBe(1); + expect(updated).toContain('- Keep it'); } finally { leaveTempDir(tempState); } @@ -341,4 +366,69 @@ describe('CLI Init Tests', () => { leaveTempDir(tempState); } }, 45000); + + // --------------------------------------------------------------------- + // Scheduled-prompts provisioning (WL-0MSS1Q5ER007QDKX AC1): `wl init` + // copies templates/scheduled-prompts.json into + // .worklog/scheduled-prompts.json on first init (create-if-absent) and + // never clobbers an existing file on re-run — user edits and + // lastTriggeredAt state are preserved. + // --------------------------------------------------------------------- + it('should provision .worklog/scheduled-prompts.json with the base set on first init', async () => { + const tempState = enterTempDir(); + try { + await execAsync( + `tsx ${cliPath} init --project-name "Test Project" --prefix TEST --auto-export yes --auto-sync no --workflow-inline no --agents-template skip --stats-plugin-overwrite no` + ); + + const configPath = path.join('.worklog', 'scheduled-prompts.json'); + expect(fs.existsSync(configPath)).toBe(true); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + expect(Array.isArray(config.entries)).toBe(true); + // The base set starts with a single /skill:refactor entry + // (intervalDays 3, lastTriggeredAt null). + expect(config.entries).toEqual([ + { + id: 'refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: null, + }, + ]); + } finally { + leaveTempDir(tempState); + } + }, 45000); + + it('should never clobber an existing scheduled-prompts.json on re-init (create-if-absent)', async () => { + const tempState = enterTempDir(); + try { + const cmd = `tsx ${cliPath} init --project-name "Test Project" --prefix TEST --auto-export yes --auto-sync no --workflow-inline no --agents-template skip --stats-plugin-overwrite no`; + await execAsync(cmd); + + // Simulate operator edits + runtime state: a new entry and a set + // lastTriggeredAt on the base entry. + const configPath = path.join('.worklog', 'scheduled-prompts.json'); + const edited = { + entries: [ + { + id: 'refactor', + prompt: '/skill:refactor', + intervalDays: 3, + lastTriggeredAt: '2026-08-17T12:00:00.000Z', + }, + { id: 'custom', prompt: '/skill:code-review', intervalDays: 1, lastTriggeredAt: null }, + ], + }; + fs.writeFileSync(configPath, JSON.stringify(edited, null, 2), 'utf-8'); + + // Re-running init must NOT overwrite the edited file. + await execAsync(cmd); + + const after = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + expect(after).toEqual(edited); + } finally { + leaveTempDir(tempState); + } + }, 45000); }); diff --git a/tests/cli/list-root-only.test.ts b/tests/cli/list-root-only.test.ts index 7f406983d..85c545d32 100644 --- a/tests/cli/list-root-only.test.ts +++ b/tests/cli/list-root-only.test.ts @@ -76,6 +76,33 @@ describe('wl list --root-only', () => { expect(ids).toEqual(['TEST-1']); }); + it('excludes children from the audit-tier selection command (completed/in_review, WL-0MSTLFW14000KPEC)', async () => { + // AC3 (WL-0MSTLFW14000KPEC): the herdr downtime worker's audit tier + // runs `wl list --status completed --stage in_review --root-only --json` + // (packages/herdr/src/index.ts getNextAuditCandidate) so child items in + // completed/in_review are never returned as audit candidates — only + // parent (root) items are dispatched for /skill:audit. + seedWorkItems(state.tempDir, [ + { id: 'TEST-1', title: 'Root epic', status: 'completed', stage: 'in_review' }, + { id: 'TEST-2', title: 'Child of epic', parentId: 'TEST-1', status: 'completed', stage: 'in_review' }, + { id: 'TEST-3', title: 'Standalone root', status: 'completed', stage: 'in_review' }, + ]); + + const { stdout } = await execAsync( + `tsx ${cliPath} list --status completed --stage in_review --root-only --json` + ); + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + const ids = result.workItems.map((wi: any) => wi.id); + expect(ids).toContain('TEST-1'); + expect(ids).toContain('TEST-3'); + expect(ids).not.toContain('TEST-2'); + // Every returned item is a root item (no parent). + for (const wi of result.workItems) { + expect(wi.parentId).toBeNull(); + } + }); + it('rejects combining --parent with --root-only with a clear error', async () => { seedWorkItems(state.tempDir, [ { id: 'TEST-1', title: 'Root epic' }, diff --git a/tests/cli/process-lifecycle.test.ts b/tests/cli/process-lifecycle.test.ts index f77411b2e..2afd01f78 100644 --- a/tests/cli/process-lifecycle.test.ts +++ b/tests/cli/process-lifecycle.test.ts @@ -16,13 +16,20 @@ async function isAlive(pid: number): Promise { /** * Return the PID of the first process whose command line EXACTLY matches the - * pattern. The pattern is anchored (^...$) so shell wrappers whose cmdline - * merely CONTAINS the pattern (e.g. `sh -c 'sleep 0.2 && sleep 30'`) never - * match — only the real target process does (WL-0MSCA645J005YM1E). + * pattern AND belongs to the given process group (pgid). Scoping the lookup + * to our own spawned process group makes the assertion deterministic even + * when unrelated processes on the host run the same command line (e.g. a + * background `while ...; do sleep 30; done` watcher): those live in a + * different process group, are never touched by our group-kill, and must + * not be the subject of the assertion (WL-0MSCA645J005YM1E, + * WL-0MSKSMRFP001Q3XU). + * + * The pattern is anchored (^...$) so shell wrappers whose cmdline merely + * CONTAINS the pattern (e.g. `sh -c 'sleep 0.2 && sleep 30'`) never match. */ -async function findPidByPattern(pattern: string): Promise { +async function findPidByPatternInGroup(pattern: string, pgid: number): Promise { try { - const { stdout } = await exec(`pgrep -f "^${pattern}$" | head -1`) + const { stdout } = await exec(`pgrep -g ${pgid} -f "^${pattern}$" | head -1`) const pid = Number(stdout.trim()) return Number.isInteger(pid) && pid > 0 ? pid : null } catch { @@ -158,10 +165,10 @@ describe('tracking set edge cases', () => { describe('orphaned process prevention (WL-0MSB447TJ000R3N8)', () => { beforeEach(async () => { - // Kill any stale `sleep 30` left behind by a previous failed run. Without - // this, findPidByPattern('sleep 30') -> `pgrep -f ... | head -1` returns - // the OLDEST match (lowest PID), so the test asserts on a process it did - // not spawn and the suite fails spuriously (flaky, WL-0MSCA645J005YM1E). + // Kill any stale `sleep 30` left behind by a previous failed run. The + // per-test process-group scoping in findPidByPatternInGroup makes the + // assertion immune to foreign `sleep 30` processes regardless, but this + // sweep keeps the host clean across runs (WL-0MSCA645J005YM1E). await killAllMatching('sleep 30') }) @@ -184,12 +191,17 @@ describe('orphaned process prevention (WL-0MSB447TJ000R3N8)', () => { // Give it time to spawn the sleep grandchild await new Promise((r) => setTimeout(r, 800)) - const sleepPid = await findPidByPattern('sleep 30') + // Scope the lookup to OUR process group: execAsync spawns detached, so + // the tracked shell PID is its own process-group leader and the sleep + // grandchild shares that group. Never consult the global process table — + // a foreign `sleep 30` (e.g. from a background queue watcher) would be + // picked by head -1 and survive our group-kill, failing spuriously. + const tracked = [...pidTrackingSet] + expect(tracked.length).toBeGreaterThan(0) + const sleepPid = await findPidByPatternInGroup('sleep 30', tracked[0]) expect(sleepPid).not.toBeNull() // Kill the tracked shell PID only (old behaviour would leave sleep alive) - const tracked = [...pidTrackingSet] - expect(tracked.length).toBeGreaterThan(0) killTrackedProcesses() // The sleep grandchild must also be dead now (process-tree kill) @@ -215,7 +227,11 @@ describe('orphaned process prevention (WL-0MSB447TJ000R3N8)', () => { promise.catch(() => {}) await new Promise((r) => setTimeout(r, 800)) - const sleepPid = await findPidByPattern('sleep 30') + // Scope to our own process group (see the sibling test above): the + // tracked shell PID is our detached spawn's group leader. + const tracked = [...pidTrackingSet] + expect(tracked.length).toBeGreaterThan(0) + const sleepPid = await findPidByPatternInGroup('sleep 30', tracked[0]) expect(sleepPid).not.toBeNull() await expect(promise).rejects.toThrow() diff --git a/tests/cli/search-fts-freshness.test.ts b/tests/cli/search-fts-freshness.test.ts new file mode 100644 index 000000000..6105cf4a4 --- /dev/null +++ b/tests/cli/search-fts-freshness.test.ts @@ -0,0 +1,136 @@ +/** + * CLI e2e tests for FTS index freshness + query-error surfacing + * (WL-0MSLW8UCP001771K). + * + * - `wl search` finds freshly imported / created items without a manual + * `--rebuild-index` (upstream: import()/upsertItems() now upsert FTS rows). + * - Punctuated unquoted queries (e.g. `v0.1.11`) are auto-quoted as a + * phrase and surface a visible warning in both human and JSON output + * (never a silent empty result list). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir, resolveTsxBin } from '../test-utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'src', 'cli.ts'); +const tsxBin = resolveTsxBin(__dirname); + +function runCli( + args: string[], + cwd: string, +): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(process.execPath, [tsxBin, cliPath, ...args], { + cwd, + encoding: 'utf-8', + timeout: 60000, + maxBuffer: 20 * 1024 * 1024, + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + status: result.status, + }; +} + +function setup(): string { + const tempDir = createTempDir(); + writeConfig(tempDir, 'Search FTS Freshness', 'SFF'); + writeInitSemaphore(tempDir); + return tempDir; +} + +describe('wl search — FTS freshness & query-error surfacing', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = setup(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('finds a freshly created punctate item without rebuilding the index', () => { + // Create an item whose title contains a version string + const create = runCli(['create', '-t', 'Release v0.1.11'], tempDir); + expect(create.status).toBe(0); + + // Searching the unquoted punctuated term must NOT be a silent no-match: + // the item is found (fresh FTS index) and a warning is issued. + const search = runCli(['search', 'v0.1.11'], tempDir); + expect(search.status).toBe(0); + expect(search.stdout).toContain('Release v0.1.11'); + expect(search.stdout.toLowerCase()).toContain('auto-quoted'); + }); + + it('surfaces the auto-quote warning in JSON output', () => { + const create = runCli(['create', '-t', 'Release v0.1.11'], tempDir); + expect(create.status).toBe(0); + + const search = runCli(['search', 'v0.1.11', '--json'], tempDir); + expect(search.status).toBe(0); + const parsed = JSON.parse(search.stdout); + expect(parsed.success).toBe(true); + expect(parsed.count).toBeGreaterThanOrEqual(1); + expect(parsed.warning).toBeDefined(); + expect(String(parsed.warning).toLowerCase()).toContain('auto-quoted'); + }); + + it('does not warn for valid quoted-phrase searches', () => { + const create = runCli(['create', '-t', 'Release v0.1.11'], tempDir); + expect(create.status).toBe(0); + + const search = runCli(['search', '"v0.1.11"', '--json'], tempDir); + expect(search.status).toBe(0); + const parsed = JSON.parse(search.stdout); + expect(parsed.count).toBeGreaterThanOrEqual(1); + expect(parsed.warning).toBeUndefined(); + }); + + it('returns genuine empty results without a warning', () => { + const create = runCli(['create', '-t', 'Some work item'], tempDir); + expect(create.status).toBe(0); + + const search = runCli(['search', 'zzzznonexistentkeyword', '--json'], tempDir); + expect(search.status).toBe(0); + const parsed = JSON.parse(search.stdout); + expect(parsed.count).toBe(0); + expect(parsed.warning).toBeUndefined(); + }); + + it('finds items imported via JSONL without a manual index rebuild', () => { + // Build a minimal JSONL file and `wl sync-import`/import it. + const itemId = 'SFF-0000000000000001'; + const item = { + id: itemId, + title: 'Imported fresh item', + description: 'brought in via import', + status: 'open', + priority: 'medium', + sortIndex: 1, + tags: ['fresh'], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + const jsonl = tempDir + '/import.jsonl'; + fs.writeFileSync(jsonl, JSON.stringify(item) + '\n'); + + const imp = runCli(['import', '--file', jsonl], tempDir); + expect(imp.status).toBe(0); + + const search = runCli(['search', 'imported fresh', '--json'], tempDir); + expect(search.status).toBe(0); + const parsed = JSON.parse(search.stdout); + expect(parsed.count).toBeGreaterThanOrEqual(1); + const ids = parsed.workItems.map((r: { id: string }) => r.id); + expect(ids).toContain(itemId); + }); +}); \ No newline at end of file diff --git a/tests/cli/show-tolerant-id-resolution.test.ts b/tests/cli/show-tolerant-id-resolution.test.ts new file mode 100644 index 000000000..e115d659c --- /dev/null +++ b/tests/cli/show-tolerant-id-resolution.test.ts @@ -0,0 +1,329 @@ +/** + * Tolerant work-item ID resolution tests for `wl show`. + * + * Validates the exact-first, unique-substring-fallback, and ambiguity + * handling introduced in WL-0MSOMI4GQ009WL8E. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execAsync, enterTempDir, leaveTempDir, writeConfig, writeInitSemaphore, cliPath } from './cli-helpers.js'; + +describe('tolerant work-item ID resolution', () => { + let state: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + state = enterTempDir(); + writeConfig(state.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(state.tempDir); + }); + + afterEach(() => { + leaveTempDir(state); + }); + + // ── AC1: exact match ──────────────────────────────────────────────────── + describe('AC1 exact match', () => { + it('returns the work item when id matches exactly (normalized)', async () => { + // Create an item — ID is auto-generated, parse from output + const { stdout: created } = await execAsync( + `tsx ${cliPath} --json create -t "Exact match test" --allow-duplicate` + ); + const createdRes = JSON.parse(created); + expect(createdRes.success).toBe(true); + const id = createdRes.workItem.id; + + // Exact match via full ID + const { stdout: shown } = await execAsync( + `tsx ${cliPath} --json show ${id}` + ); + const shownRes = JSON.parse(shown); + expect(shownRes.success).toBe(true); + expect(shownRes.workItem.id).toBe(id); + expect(shownRes.workItem.title).toBe('Exact match test'); + }); + }); + + // ── AC2: unique substring fallback ────────────────────────────────────── + describe('AC2 unique substring match', () => { + it('resolves via unique case-insensitive substring match on id', async () => { + const { stdout: created } = await execAsync( + `tsx ${cliPath} --json create -t "Substring test" --allow-duplicate` + ); + const createdRes = JSON.parse(created); + const id = createdRes.workItem.id; + + // The ID contains TEST-XXXXX; extract the hash portion for substring search + // IDs look like TEST-ABCDEF1234 — use the full ID's hash part + // We need a unique substring. Extract from the ID. + // Since IDs are TEST-, the hash is always unique. + // But we can't easily predict it, so let's use the full ID first, then partial. + // Actually, the hash is 12 chars. Let's take the first 8 chars of hash. + const hashPart = id.replace('TEST-', ''); + const partialId = hashPart.substring(0, Math.ceil(hashPart.length / 2)); + + const { stdout: shown } = await execAsync( + `tsx ${cliPath} --json show ${partialId}` + ); + const shownRes = JSON.parse(shown); + expect(shownRes.success).toBe(true); + expect(shownRes.workItem.id).toBe(id); + expect(shownRes.workItem.title).toBe('Substring test'); + }); + + it('handles case-insensitive substring matching', async () => { + const { stdout: created } = await execAsync( + `tsx ${cliPath} --json create -t "Case test" --allow-duplicate` + ); + const createdRes = JSON.parse(created); + const id = createdRes.workItem.id; + + // Use lowercase version of partial id + const hashPart = id.replace('TEST-', ''); + const partialId = hashPart.substring(0, Math.ceil(hashPart.length / 2)).toLowerCase(); + + const { stdout: shown } = await execAsync( + `tsx ${cliPath} --json show ${partialId}` + ); + const shownRes = JSON.parse(shown); + expect(shownRes.success).toBe(true); + expect(shownRes.workItem.id).toBe(id); + }); + + it('resolves via substring and includes comments from the resolved item', async () => { + const { stdout: created } = await execAsync( + `tsx ${cliPath} --json create -t "Commented substring item" --allow-duplicate` + ); + const id = JSON.parse(created).workItem.id; + + // Add a comment to the item + const { stdout: commented } = await execAsync( + `tsx ${cliPath} --json comment add ${id} --comment "A test comment" --author tester` + ); + expect(JSON.parse(commented).success).toBe(true); + + // Resolve via a partial ID; comments must still attach (follow-up + // lookups use the resolved item.id, not the partial reference) + const hashPart = id.replace('TEST-', ''); + const partialId = hashPart.substring(0, Math.ceil(hashPart.length / 2)); + const { stdout: shown } = await execAsync( + `tsx ${cliPath} --json show ${partialId}` + ); + const shownRes = JSON.parse(shown); + expect(shownRes.success).toBe(true); + expect(shownRes.workItem.id).toBe(id); + expect(Array.isArray(shownRes.comments)).toBe(true); + expect(shownRes.comments.some((c: any) => c.comment === 'A test comment')).toBe(true); + }); + + it('prefers exact match over substring match when both exist', async () => { + const { stdout: created1 } = await execAsync( + `tsx ${cliPath} --json create -t "Exact item" --allow-duplicate` + ); + const { stdout: created2 } = await execAsync( + `tsx ${cliPath} --json create -t "Substring item" --allow-duplicate` + ); + const id1 = JSON.parse(created1).workItem.id; + const id2 = JSON.parse(created2).workItem.id; + + // Search for something that would substring-match id2 but exactly matches a + // hypothetical third item. Actually, since IDs are unique hashes, there + // can't be an exact substring match that is also a full ID. Let's test + // with a case where an exact match (full ID) exists. + const { stdout: shown } = await execAsync( + `tsx ${cliPath} --json show ${id1}` + ); + const shownRes = JSON.parse(shown); + expect(shownRes.success).toBe(true); + expect(shownRes.workItem.id).toBe(id1); + }); + }); + + // ── AC3: ambiguous match ──────────────────────────────────────────────── + describe('AC3 ambiguous match', () => { + it('exits non-zero with structured error in JSON mode', async () => { + const { stdout: created1 } = await execAsync( + `tsx ${cliPath} --json create -t "Item A" --allow-duplicate` + ); + const { stdout: created2 } = await execAsync( + `tsx ${cliPath} --json create -t "Item B" --allow-duplicate` + ); + const id1 = JSON.parse(created1).workItem.id; + const id2 = JSON.parse(created2).workItem.id; + + // Both IDs share the prefix "TEST-". Use a common prefix that matches both. + // IDs are TEST-<12-char hex>, so "TEST-" alone would match all items. + // But "TEST-" is the prefix, so normalizeCliId might add it back. + // Actually, normalizeCliId adds prefix if missing. So "TEST-ABC..." would + // stay as is. The substring search looks at all items' ids. + // "TEST-" would match everything — let's use a short prefix. + // Actually we need a shared substring. Let's check if IDs share any pattern. + // Since they are random hex hashes, they won't share a non-trivial prefix. + // We need to create items with IDs we control. Let's use the database directly. + + // Alternative: use the seedWorkItems approach via direct database write. + // Or: just verify that searching for "TEST-" when there are items is ambiguous. + + let error: any; + try { + await execAsync(`tsx ${cliPath} --json show TEST-`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + + const stderr = error?.stderr ?? ''; + const jsonErr = JSON.parse(stderr); + expect(jsonErr.success).toBe(false); + expect(jsonErr.error).toBe('ambiguous-match'); + expect(Array.isArray(jsonErr.candidates)).toBe(true); + expect(jsonErr.candidates.length).toBeGreaterThanOrEqual(2); + }); + + it('exits non-zero with human-readable disambiguation in human mode', async () => { + await execAsync(`tsx ${cliPath} --json create -t "Human A" --allow-duplicate`); + await execAsync(`tsx ${cliPath} --json create -t "Human B" --allow-duplicate`); + + let error: any; + try { + await execAsync(`tsx ${cliPath} show TEST-`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + + const stderr = error?.stderr ?? ''; + expect(stderr).toContain('Ambiguous'); + }); + }); + + // ── AC4: not found ────────────────────────────────────────────────────── + describe('AC4 not found', () => { + it('keeps existing "Work item not found" behavior', async () => { + let error: any; + try { + await execAsync(`tsx ${cliPath} --json show NONEXISTENT-999`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + + const stderr = error?.stderr ?? ''; + const jsonErr = JSON.parse(stderr); + expect(jsonErr.success).toBe(false); + expect(jsonErr.error).toBe('Work item not found: NONEXISTENT-999'); + }); + + it('exits non-zero when substring finds nothing', async () => { + let error: any; + try { + await execAsync(`tsx ${cliPath} --json show ZZZZZZZZZZ9999999999`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + + const stderr = error?.stderr ?? ''; + const jsonErr = JSON.parse(stderr); + expect(jsonErr.success).toBe(false); + expect(jsonErr.error).toContain('Work item not found'); + }); + }); + + // ── AC5: --json output is machine-parseable ──────────────────────────── + describe('AC5 JSON output parseable in all paths', () => { + it('found path produces valid JSON with success: true', async () => { + const { stdout: created } = await execAsync( + `tsx ${cliPath} --json create -t "JSON test" --allow-duplicate` + ); + const id = JSON.parse(created).workItem.id; + + const { stdout } = await execAsync( + `tsx ${cliPath} --json show ${id}` + ); + const parsed = JSON.parse(stdout); + expect(parsed.success).toBe(true); + expect(parsed.workItem).toBeDefined(); + expect(parsed.workItem.id).toBe(id); + }); + + it('ambiguous path produces valid JSON with success: false and error', async () => { + await execAsync(`tsx ${cliPath} --json create -t "Amb1" --allow-duplicate`); + await execAsync(`tsx ${cliPath} --json create -t "Amb2" --allow-duplicate`); + + let error: any; + try { + await execAsync(`tsx ${cliPath} --json show TEST-`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + // stderr should be valid JSON + const stderr = error?.stderr ?? ''; + const jsonErr = JSON.parse(stderr); + expect(jsonErr.success).toBe(false); + expect(jsonErr.error).toBe('ambiguous-match'); + }); + + it('not-found path produces valid JSON with success: false and error', async () => { + let error: any; + try { + await execAsync(`tsx ${cliPath} --json show TOTALLY-NEW-999`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + const stderr = error?.stderr ?? ''; + const jsonErr = JSON.parse(stderr); + expect(jsonErr.success).toBe(false); + expect(typeof jsonErr.error).toBe('string'); + }); + }); + + // ── --exact flag ───────────────────────────────────────────────────────── + describe('--exact flag', () => { + it('forces strict exact-match, skipping substring fallback', async () => { + const { stdout: created } = await execAsync( + `tsx ${cliPath} --json create -t "Exact flag item" --allow-duplicate` + ); + const id = JSON.parse(created).workItem.id; + + // Extract hash portion and use a partial version + const hashPart = id.replace('TEST-', ''); + const partialId = hashPart.substring(0, Math.ceil(hashPart.length / 2)); + + // Without --exact, partial should work + const { stdout: withFallback } = await execAsync( + `tsx ${cliPath} --json show ${partialId}` + ); + const res1 = JSON.parse(withFallback); + expect(res1.success).toBe(true); + + // With --exact, partial should fail + let error: any; + try { + await execAsync(`tsx ${cliPath} --json show --exact ${partialId}`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + const stderr = error?.stderr ?? ''; + const jsonErr = JSON.parse(stderr); + expect(jsonErr.success).toBe(false); + expect(jsonErr.error).toContain('Work item not found'); + }); + + it('--exact still works for exact matches', async () => { + const { stdout: created } = await execAsync( + `tsx ${cliPath} --json create -t "Exact still works" --allow-duplicate` + ); + const id = JSON.parse(created).workItem.id; + + const { stdout } = await execAsync( + `tsx ${cliPath} --json show --exact ${id}` + ); + const res = JSON.parse(stdout); + expect(res.success).toBe(true); + expect(res.workItem.id).toBe(id); + }); + }); +}); diff --git a/tests/cli/sync-delta-delete.test.ts b/tests/cli/sync-delta-delete.test.ts new file mode 100644 index 000000000..960d390a5 --- /dev/null +++ b/tests/cli/sync-delta-delete.test.ts @@ -0,0 +1,216 @@ +/** + * E2E tests for deletion propagation through delta sync (WL-0MT2KZH0I005XUWE, + * parent WL-0MSAKUBKW006FN8Q §4.3). + * + * Uses REAL git + a real bare remote (mock-bin stripped from PATH) against + * the compiled CLI (dist/cli.js). + * + * AC1 — a soft-deleted record (status 'deleted', updatedAt = deletion time) is + * included in the delta export and pushed. + * AC2 — the pull side merges the delta: the deleted record converges into the + * remote SQLite even when the local copy was completed/in_review + * (the close-preservation gap fixed in the status merge). + * AC3 — after syncs, local and remote SQLite match for deleted items. + * AC5 — no regression for full syncs: a full snapshot also carries deleted + * records and converges (legacy behavior preserved). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'cli.js'); +const mockBinDir = path.join(projectRoot, 'tests', 'cli', 'mock-bin'); + +/** PATH without the test mock-bin so subprocesses run the real git binary. */ +function realGitEnv(): Record { + const pathVal = (process.env.PATH || '') + .split(path.delimiter) + .filter(p => path.resolve(p) !== path.resolve(mockBinDir)) + .join(path.delimiter); + return { ...process.env as Record, PATH: pathVal }; +} + +function git(cwd: string, ...args: string[]): string { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env: realGitEnv() }); + if (res.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${res.stderr || res.stdout}`); + } + return res.stdout.trim(); +} + +function runCli(cwd: string, ...args: string[]): { stdout: string; stderr: string; status: number } { + const res = spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8', env: realGitEnv() }); + return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 }; +} + +interface Setup { + root: string; + remote: string; + local: string; +} + +function setupProject(): Setup { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-delta-delete-')); + const remote = path.join(root, 'remote.git'); + const local = path.join(root, 'local'); + git(root, 'init', '--bare', '-q', remote); + git(root, 'init', '-q', local); + git(local, 'config', 'user.email', 'test@example.com'); + git(local, 'config', 'user.name', 'Test User'); + git(local, 'remote', 'add', 'origin', remote); + writeConfig(local, 'Delta Delete Test', 'DDT'); + writeInitSemaphore(local); + fs.writeFileSync(path.join(local, 'README.md'), '# delta delete\n', 'utf8'); + git(local, 'add', '-A'); + git(local, 'commit', '-q', '-m', 'init'); + return { root, remote, local }; +} + +/** Create a work item via the CLI and return its id. */ +function createItem(local: string, title: string): string { + const res = runCli(local, '--json', 'create', '-t', title); + if (res.status !== 0) { + throw new Error(`wl create failed: ${res.stderr}\n${res.stdout}`); + } + const parsed = JSON.parse(res.stdout); + const id: string = parsed.workItem?.id ?? parsed.id; + expect(id).toBeTruthy(); + return id; +} + +/** Delete a work item via the CLI (auto-sync on) and return the raw JSON. */ +function deleteItem(local: string, id: string) { + const res = runCli(local, '--json', 'delete', id); + if (res.status !== 0) { + throw new Error(`wl delete failed: ${res.stderr}\n${res.stdout}`); + } + return JSON.parse(res.stdout); +} + +/** Set an item to completed/in_review via the CLI so the close-preservation path is exercised. */ +function completeItem(local: string, id: string) { + const res = runCli(local, '--json', 'update', id, '-s', 'completed', '--stage', 'in_review'); + expect(res.status, res.stderr).toBe(0); +} + +/** Fetch the remote data ref and report kind + payload. */ +function remoteTip(local: string): { kind: string | undefined; payload: string } { + git(local, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const content = git(local, 'show', 'refs/worklog/remotes/origin/worklog/data:.worklog/worklog-data.jsonl'); + let kind: string | undefined; + let payload = content; + try { + const first = JSON.parse(content.trim().split('\n')[0]); + if (first && first.__worklog_sync__) { + kind = first.__worklog_sync__.kind; + payload = content.trim().split('\n').slice(1).join('\n'); + } + } catch { + /* headerless legacy */ + } + return { kind, payload }; +} + +/** List items INCLUDING deleted ones via `wl list --deleted` JSON. */ +function listAll(local: string): Array<{ id: string; title: string; status: string }> { + const res = runCli(local, '--json', 'list', '--deleted'); + expect(res.status, res.stderr).toBe(0); + const parsed = JSON.parse(res.stdout); + return parsed.workItems ?? parsed; +} + +describe('deletion propagation via delta sync (real git)', () => { + let s: Setup; + + beforeEach(() => { + s = setupProject(); + }); + + afterEach(() => { + fs.rmSync(s.root, { recursive: true, force: true }); + }); + + it('AC1: deleting an item pushes a delta containing the deleted record', async () => { + const a = createItem(s.local, 'Keep me'); + const victim = createItem(s.local, 'Delete me'); + expect(a).toBeTruthy(); + const sync1 = runCli(s.local, '--json', 'sync'); + expect(sync1.status, sync1.stderr).toBe(0); + expect(remoteTip(s.local).kind).toBe('full'); + // Manually delete (auto-sync happens inside `wl delete`). + const delRes = deleteItem(s.local, victim); + expect(delRes.success).toBe(true); + + // Fetch the remote: the delete auto-synced and the tip must be a delta + // carrying the deleted record. + const tip = remoteTip(s.local); + expect(tip.kind).toBe('delta'); + expect(tip.payload).toContain('"deleted"'); + expect(tip.payload).toContain(victim); + }, 120000); + + it('AC2/AC3: a completed item deleted on one side converges to deleted on the pull side', async () => { + // Client A: create, complete (close-preservation path), first full sync. + const victim = createItem(s.local, 'Victim'); + createItem(s.local, 'Survivor'); + completeItem(s.local, victim); + const sync1 = runCli(s.local, '--json', 'sync'); + expect(sync1.status, sync1.stderr).toBe(0); + // Sanity: baseline remote is a full snapshot. + expect(remoteTip(s.local).kind).toBe('full'); + + // Client A deletes the completed victim (auto-sync → delta push). + const delRes = deleteItem(s.local, victim); + expect(delRes.success).toBe(true); + expect(remoteTip(s.local).kind).toBe('delta'); + + // Client B: second clone of the same project pulls the delta. + const clone = path.join(s.root, 'clone-b'); + git(s.root, 'clone', '-q', s.remote, clone); + git(clone, 'config', 'user.email', 'test@example.com'); + git(clone, 'config', 'user.name', 'Test User'); + writeConfig(clone, 'Delta Delete Test', 'DDT'); + writeInitSemaphore(clone); + const syncB = runCli(clone, '--json', 'sync'); + expect(syncB.status, syncB.stderr).toBe(0); + + // AC2/AC3: B's local SQLite has the victim as deleted (converged), not + // resurrected to completed by the close-preservation path. + const items = listAll(clone); + const victimB = items.find(i => i.id === victim); + expect(victimB).toBeDefined(); + expect(victimB?.status).toBe('deleted'); + // Survivor untouched. + expect(items.find(i => i.title === 'Survivor')?.status).toBe('open'); + }, 120000); + + it('AC5 (no regression): a full snapshot still carries and converges deleted records', async () => { + const a = createItem(s.local, 'Keep full'); + const victim = createItem(s.local, 'Delete full'); + runCli(s.local, '--json', 'sync'); + deleteItem(s.local, victim); + + // Force a full snapshot: wipe delta metadata so the next push is full. + // Simplest deterministic route: syncFullSnapshotEveryN: 1 → first delta + // after the delete triggers a full on the NEXT sync (cadence). Instead, + // we assert the full-snapshot path directly: run push with + // WL_DELTA_EXPORT_DISABLED=1 → exportForSync falls back to full. + const res = spawnSync('node', [cliPath, '--json', 'sync'], { + cwd: s.local, encoding: 'utf-8', + env: { ...realGitEnv(), WL_DELTA_EXPORT_DISABLED: '1' }, + }); + expect(res.status).toBe(0); + + const tip = remoteTip(s.local); + expect(tip.kind).toBe('full'); + expect(tip.payload).toContain(victim); + expect(tip.payload).toContain('"deleted"'); + expect(tip.payload).toContain(a); + }, 120000); +}); \ No newline at end of file diff --git a/tests/cli/sync-delta-fallback.test.ts b/tests/cli/sync-delta-fallback.test.ts new file mode 100644 index 000000000..27d35b52d --- /dev/null +++ b/tests/cli/sync-delta-fallback.test.ts @@ -0,0 +1,265 @@ +/** + * E2E tests for the full-snapshot fallback on the PULL side + * (WL-0MT2KZ5GJ000OJIP, parent WL-0MSAKUBKW006FN8Q §6.2). + * + * Uses REAL git + a real bare remote (mock-bin stripped from PATH) against + * the compiled CLI (dist/cli.js), mirroring tests/cli/sync-delta-pull.test.ts. + * + * AC1 — a pull whose remote tip is a DELTA but whose local store has NO + * records (brand-new clone / store recovered from corruption) triggers + * the missing-base fallback instead of merging the partial delta. + * AC2 — the fallback reconstructs the full chain from the remote ref HISTORY + * (newest full snapshot + every delta after it) and re-anchors the + * remote with a full snapshot push (§5.1 no baseline → full). + * AC5 — no data loss: the reconstructed store contains the base records AND + * the delta changes; an unrepairable chain (no full anywhere in + * history) fails closed — nothing is merged, nothing is pushed, the + * local store and remote ref are both left untouched. + * AC7 — covered by the end-to-end scenarios below plus the shared merge tests. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; +import { exportToJsonl } from '../../src/jsonl.js'; +import type { WorkItem } from '../../src/types.js'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'cli.js'); +const mockBinDir = path.join(projectRoot, 'tests', 'cli', 'mock-bin'); + +/** PATH without the test mock-bin so subprocesses run the real git binary. */ +function realGitEnv(): Record { + const pathVal = (process.env.PATH || '') + .split(path.delimiter) + .filter(p => path.resolve(p) !== path.resolve(mockBinDir)) + .join(path.delimiter); + return { ...process.env as Record, PATH: pathVal }; +} + +function git(cwd: string, ...args: string[]): string { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env: realGitEnv() }); + if (res.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${res.stderr || res.stdout}`); + } + return res.stdout.trim(); +} + +function runCli(cwd: string, ...args: string[]): { stdout: string; stderr: string; status: number } { + const res = spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8', env: realGitEnv() }); + return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 }; +} + +interface Setup { + root: string; + remote: string; + local: string; +} + +/** Full project setup: bare remote + initialized local repo (identity test@example.com). */ +function setupProject(): Setup { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-delta-fallback-')); + const remote = path.join(root, 'remote.git'); + const local = path.join(root, 'local'); + git(root, 'init', '--bare', '-q', remote); + git(root, 'init', '-q', local); + git(local, 'config', 'user.email', 'test@example.com'); + git(local, 'config', 'user.name', 'Test User'); + git(local, 'remote', 'add', 'origin', remote); + writeConfig(local, 'Delta Fallback Test', 'DFT'); + writeInitSemaphore(local); + fs.writeFileSync(path.join(local, 'README.md'), '# delta fallback\n', 'utf8'); + git(local, 'add', '-A'); + git(local, 'commit', '-q', '-m', 'init'); + return { root, remote, local }; +} + +/** Fresh checkout of the same project with an EMPTY local worklog store. */ +function makeFreshClone(s: Setup, name: string): string { + const dir = path.join(s.root, name); + git(s.root, 'clone', '-q', s.remote, dir); + git(dir, 'config', 'user.email', 'test@example.com'); + git(dir, 'config', 'user.name', 'Test User'); + writeConfig(dir, 'Delta Fallback Test', 'DFT'); + writeInitSemaphore(dir); + return dir; +} + +/** Create a work item via the CLI and return its id. */ +function createItem(local: string, title: string): string { + const res = runCli(local, '--json', 'create', '-t', title); + if (res.status !== 0) { + throw new Error(`wl create failed: ${res.stderr}\n${res.stdout}`); + } + const parsed = JSON.parse(res.stdout); + const id: string = parsed.workItem?.id ?? parsed.id; + expect(id).toBeTruthy(); + return id; +} + +/** Build a WorkItem record shaped like a JSONL `workitem` line data. */ +function makeItemRecord(id: string, title: string, updatedAt: string): WorkItem { + return { + id, + title, + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: updatedAt, + updatedAt, + tags: [], + assignee: '', + stage: 'idea', + issueType: '', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + } as WorkItem; +} + +/** + * Replace the remote ref tip with an ORPHAN commit carrying ONLY a delta + * JSONL (no full snapshot in history) — simulates an unrepairable chain. + */ +function publishOrphanDelta(s: Setup, items: WorkItem[]): void { + const dataPath = path.join(s.local, '.worklog', 'worklog-data.jsonl'); + fs.mkdirSync(path.dirname(dataPath), { recursive: true }); + exportToJsonl(items, [], dataPath, [], [], 'delta'); + const originalBranch = git(s.local, 'rev-parse', '--abbrev-ref', 'HEAD'); + git(s.local, 'checkout', '-q', '--orphan', 'worklog-fallback-data'); + git(s.local, 'add', '-f', '.worklog/worklog-data.jsonl'); + git(s.local, 'commit', '-q', '-m', 'orphan delta'); + git(s.local, 'push', '-f', '-q', 'origin', 'HEAD:refs/worklog/data'); + git(s.local, 'checkout', '-q', originalBranch); + git(s.local, 'branch', '-D', '-q', 'worklog-fallback-data'); +} + +/** Fetch the remote data ref and report kind + payload lines. */ +function remoteTip(local: string): { kind: string | undefined; lines: string[] } { + git(local, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const content = git(local, 'show', 'refs/worklog/remotes/origin/worklog/data:.worklog/worklog-data.jsonl'); + const lines = content.trim().split('\n'); + let kind: string | undefined; + let payload = lines; + try { + const first = JSON.parse(lines[0]); + if (first && first.__worklog_sync__) { + kind = first.__worklog_sync__.kind; + payload = lines.slice(1); + } + } catch { + /* headerless legacy file */ + } + return { kind, lines: payload }; +} + +/** Local DB contents via `wl list --json`. */ +function listItems(local: string): Array<{ id: string; title: string }> { + const res = runCli(local, '--json', 'list', '--status', 'open'); + expect(res.status, res.stderr).toBe(0); + const parsed = JSON.parse(res.stdout); + return parsed.workItems ?? parsed; +} + +describe('delta sync full-snapshot fallback (real git)', () => { + let s: Setup; + + beforeEach(() => { + s = setupProject(); + }); + + afterEach(() => { + fs.rmSync(s.root, { recursive: true, force: true }); + }); + + it('AC1/AC2/AC5: a fresh clone with no local base replays full+delta history and re-anchors with a full push', async () => { + // Client A builds a chain: full (3 items), then delta (1 new item). + const alpha = createItem(s.local, 'Alpha'); + const beta = createItem(s.local, 'Beta'); + const gamma = createItem(s.local, 'Gamma'); + const firstSync = runCli(s.local, '--json', 'sync'); + expect(firstSync.status, firstSync.stderr).toBe(0); + expect(remoteTip(s.local).kind).toBe('full'); + + const fourth = createItem(s.local, 'Fourth'); + const secondSync = runCli(s.local, '--json', 'sync'); + expect(secondSync.status, secondSync.stderr).toBe(0); + expect(remoteTip(s.local).kind).toBe('delta'); + + // History now holds: full commit + delta commit. + git(s.local, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const historyCount = git(s.local, 'rev-list', '--count', 'refs/worklog/remotes/origin/worklog/data'); + expect(Number(historyCount)).toBeGreaterThanOrEqual(2); + + // Client B: a fresh clone with an EMPTY local store. Pulling the delta + // tip alone would yield only 1 record; the fallback must replay history. + const clone = makeFreshClone(s, 'clone-b'); + const thirdSync = runCli(clone, '--json', 'sync'); + expect(thirdSync.status, thirdSync.stderr).toBe(0); + + const items = listItems(clone); + const ids = items.map(i => i.id); + // AC5 — reconstructed store has the base records AND the delta change. + expect(ids).toContain(alpha); + expect(ids).toContain(beta); + expect(ids).toContain(gamma); + expect(ids).toContain(fourth); + expect(new Set(ids).size).toBe(ids.length); + // AC2 — no baseline → the clone's own push was a FULL snapshot, + // re-anchoring the remote for future readers. + expect(remoteTip(clone).kind).toBe('full'); + }, 120000); + + it('AC5: an unrepairable chain (delta-only history) fails closed — no merge, no push, no data loss', async () => { + const ts = new Date(Date.now() + 10 * 1000).toISOString(); + publishOrphanDelta(s, [makeItemRecord('DFT-9000', 'Lone Delta', ts)]); + expect(remoteTip(s.local).kind).toBe('delta'); + + // Fresh clone with empty local store: replay finds no full snapshot → + // must fail closed with an explicit error, leaving BOTH sides untouched. + const clone = makeFreshClone(s, 'clone-broken'); + git(clone, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const remoteBefore = git(clone, 'rev-parse', 'refs/worklog/remotes/origin/worklog/data'); + + const res = runCli(clone, '--json', 'sync'); + expect(res.status, res.stdout).not.toBe(0); + expect(res.stdout).toContain('Unrepairable remote delta chain'); + + // AC5 — nothing merged, nothing pushed, local store stays empty. + const items = listItems(clone); + expect(items).toHaveLength(0); + + git(clone, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const remoteAfter = git(clone, 'rev-parse', 'refs/worklog/remotes/origin/worklog/data'); + expect(remoteAfter).toBe(remoteBefore); + }, 120000); + + it('AC1: a delta tip with an existing local base keeps the normal (child-5) delta pull — no fallback', async () => { + // Client A: full then delta. + const alpha = createItem(s.local, 'Alpha'); + const beta = createItem(s.local, 'Beta'); + expect(runCli(s.local, '--json', 'sync').status).toBe(0); + const newId = createItem(s.local, 'Only Delta'); + expect(runCli(s.local, '--json', 'sync').status).toBe(0); + expect(remoteTip(s.local).kind).toBe('delta'); + + // SAME local store (has records) pulls the delta — must NOT trigger the + // fallback; existing by-ID delta merge (child 5) applies. + const pullSync = runCli(s.local, '--json', 'sync'); + expect(pullSync.status, pullSync.stderr).toBe(0); + + const ids = listItems(s.local).map(i => i.id); + expect(ids).toContain(alpha); + expect(ids).toContain(beta); + expect(ids).toContain(newId); + expect(new Set(ids).size).toBe(ids.length); + }, 120000); +}); \ No newline at end of file diff --git a/tests/cli/sync-delta-pull.test.ts b/tests/cli/sync-delta-pull.test.ts new file mode 100644 index 000000000..d78eb043f --- /dev/null +++ b/tests/cli/sync-delta-pull.test.ts @@ -0,0 +1,262 @@ +/** + * E2E tests for the delta (incremental) sync PULL path (WL-0MT2KYCNB000CYWV, + * parent WL-0MSAKUBKW006FN8Q §6.1–§6.2). + * + * Uses REAL git + a real bare remote (mock-bin stripped from PATH) against + * the compiled CLI (dist/cli.js), mirroring tests/cli/sync-delta-push.test.ts. + * + * AC1 — the pull path detects whether the remote JSONL is a delta (header + * `{"__worklog_sync__":{"version":1,"kind":"delta"}}`) or a full + * snapshot (kind `full`) / legacy headerless file (kind undefined). + * AC2 — a delta remote is merged ONTO the local base using the existing by-ID + * merge logic; local records absent from the delta are preserved + * (non-destructive upsert persist — no missing records, no duplicates). + * AC3 — a full-snapshot remote keeps the existing replace behavior. + * AC6 — legacy headerless remotes fall back to the full path. + * AC7 — delta merge behavior is covered by unit tests (merge-utils + database + * upsertComments) plus this end-to-end flow. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; +import { exportToJsonl } from '../../src/jsonl.js'; +import type { WorkItem } from '../../src/types.js'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'cli.js'); +const mockBinDir = path.join(projectRoot, 'tests', 'cli', 'mock-bin'); + +/** PATH without the test mock-bin so subprocesses run the real git binary. */ +function realGitEnv(): Record { + const pathVal = (process.env.PATH || '') + .split(path.delimiter) + .filter(p => path.resolve(p) !== path.resolve(mockBinDir)) + .join(path.delimiter); + return { ...process.env as Record, PATH: pathVal }; +} + +function git(cwd: string, ...args: string[]): string { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env: realGitEnv() }); + if (res.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${res.stderr || res.stdout}`); + } + return res.stdout.trim(); +} + +function runCli(cwd: string, ...args: string[]): { stdout: string; stderr: string; status: number } { + const res = spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8', env: realGitEnv() }); + return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 }; +} + +interface Setup { + root: string; + remote: string; + local: string; +} + +function setupProject(branch = 'sync-delta-pull'): Setup { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `wl-${branch}-`)); + const remote = path.join(root, 'remote.git'); + const local = path.join(root, 'local'); + git(root, 'init', '--bare', '-q', remote); + git(root, 'init', '-q', local); + git(local, 'config', 'user.email', 'test@example.com'); + git(local, 'config', 'user.name', 'Test User'); + git(local, 'remote', 'add', 'origin', remote); + writeConfig(local, 'Delta Pull Test', 'DPT'); + writeInitSemaphore(local); + fs.writeFileSync(path.join(local, 'README.md'), `# ${branch}\n`, 'utf8'); + git(local, 'add', '-A'); + git(local, 'commit', '-q', '-m', 'init'); + git(local, 'checkout', '-q', '-b', branch); + return { root, remote, local }; +} + +/** Create a work item via the CLI and return its id. */ +function createItem(local: string, title: string): string { + const res = runCli(local, '--json', 'create', '-t', title); + if (res.status !== 0) { + throw new Error(`wl create failed: ${res.stderr}\n${res.stdout}`); + } + const parsed = JSON.parse(res.stdout); + const id: string = parsed.workItem?.id ?? parsed.id; + expect(id).toBeTruthy(); + return id; +} + +/** Build a WorkItem record shaped like a JSONL `workitem` line data. */ +function makeItemRecord(id: string, title: string, updatedAt: string): WorkItem { + return { + id, + title, + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: updatedAt, + updatedAt, + tags: [], + assignee: '', + stage: 'idea', + issueType: '', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + } as WorkItem; +} + +/** + * Replace the remote ref tip with a JSONL file built from the given records. + * A kind of 'delta' writes the sync header so the pull path takes the delta + * branch; 'full' writes the header with kind full; undefined writes a legacy + * headerless file. + */ +function publishRemote( + s: Setup, + items: WorkItem[], + comments: any[], + kind: 'full' | 'delta' | undefined +): void { + const dataPath = path.join(s.local, '.worklog', 'worklog-data.jsonl'); + fs.mkdirSync(path.dirname(dataPath), { recursive: true }); + exportToJsonl(items, comments, dataPath, [], [], kind); + + git(s.local, 'checkout', '-q', '--orphan', 'worklog-pull-data'); + git(s.local, 'add', '-f', '.worklog/worklog-data.jsonl'); + git(s.local, 'commit', '-q', '-m', `publish ${kind ?? 'legacy'} data`); + git(s.local, 'push', '-f', '-q', 'origin', 'HEAD:refs/worklog/data'); + git(s.local, 'checkout', '-q', 'sync-delta-pull'); + git(s.local, 'branch', '-D', '-q', 'worklog-pull-data'); +} + +/** Fetch the remote data ref and report its kind (header parse) + payload lines. */ +function remoteTip(local: string): { kind: string | undefined; lines: string[] } { + git(local, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const content = git(local, 'show', 'refs/worklog/remotes/origin/worklog/data:.worklog/worklog-data.jsonl'); + const lines = content.trim().split('\n'); + let kind: string | undefined; + let payload = lines; + try { + const first = JSON.parse(lines[0]); + if (first && first.__worklog_sync__) { + kind = first.__worklog_sync__.kind; + payload = lines.slice(1); + } + } catch { + /* headerless legacy file */ + } + return { kind, lines: payload }; +} + +/** Pull the local DB contents via `wl list --json`. */ +function listItems(local: string): Array<{ id: string; title: string; description: string }> { + const res = runCli(local, '--json', 'list', '--status', 'open'); + expect(res.status, res.stderr).toBe(0); + const parsed = JSON.parse(res.stdout); + return parsed.workItems ?? parsed; +} + +describe('delta sync pull path (real git)', () => { + let s: Setup; + + beforeEach(() => { + s = setupProject(); + }); + + afterEach(() => { + fs.rmSync(s.root, { recursive: true, force: true }); + }); + + it('AC1/AC2/AC5: a delta remote is merged onto the local base without loss or duplicates', async () => { + // Local base: three items, full snapshot pushed. + const alpha = createItem(s.local, 'Alpha'); + const beta = createItem(s.local, 'Beta'); + const gamma = createItem(s.local, 'Gamma'); + const firstSync = runCli(s.local, '--json', 'sync'); + expect(firstSync.status, firstSync.stderr).toBe(0); + expect(remoteTip(s.local).kind).toBe('full'); + + // Peer pushes a DELTA: one new item + an updated copy of Alpha, with + // timestamps newer than the local full push so the delta is clearly a + // change set, not a snapshot. + const deltaTs = new Date(Date.now() + 10 * 1000).toISOString(); + const deltaItems: WorkItem[] = [ + makeItemRecord('DPT-1000', 'Delta Arrival', deltaTs), + { ...makeItemRecord(alpha, 'Alpha', deltaTs), description: 'updated by peer' }, + ]; + publishRemote(s, deltaItems, [], 'delta'); + expect(remoteTip(s.local).kind).toBe('delta'); + + // Local sync pulls the delta and merges it onto the local base. + const pullSync = runCli(s.local, '--json', 'sync'); + expect(pullSync.status, pullSync.stderr).toBe(0); + + const items = listItems(s.local); + const ids = items.map(i => i.id); + // AC5 — no missing records: base items survive even though they are + // absent from the delta payload (non-destructive merge onto local base). + expect(ids).toContain(alpha); + expect(ids).toContain(beta); + expect(ids).toContain(gamma); + // AC2 — delta records merged in. + expect(ids).toContain('DPT-1000'); + // AC5 — no duplicates: every id appears exactly once. + expect(new Set(ids).size).toBe(ids.length); + // AC2 — the updated record reflects the peer delta record's content. + const alphaNow = items.find(i => i.id === alpha); + expect(alphaNow?.title).toBe('Alpha'); + expect(alphaNow?.description).toBe('updated by peer'); + }, 120000); + + it('AC3: a full-snapshot remote replaces local state and is not mistaken for a delta', async () => { + createItem(s.local, 'Local Only'); + const firstSync = runCli(s.local, '--json', 'sync'); + expect(firstSync.status, firstSync.stderr).toBe(0); + expect(remoteTip(s.local).kind).toBe('full'); + + // Peer publishes a full snapshot (header kind=full) containing a new item + // AND a copy of the local item — the full path (replace) applies. + const ts = new Date(Date.now() + 10 * 1000).toISOString(); + publishRemote(s, [makeItemRecord('DPT-2000', 'Snapshot Item', ts)], [], 'full'); + expect(remoteTip(s.local).kind).toBe('full'); + + const pullSync = runCli(s.local, '--json', 'sync'); + expect(pullSync.status, pullSync.stderr).toBe(0); + + const items = listItems(s.local); + const ids = items.map(i => i.id); + expect(ids).toContain('DPT-2000'); + // Local-only records survive the full merge too (merge seeds from local). + expect(ids.some(id => id !== 'DPT-2000' && id.startsWith('DPT-'))).toBe(true); + expect(new Set(ids).size).toBe(ids.length); + }, 120000); + + it('AC6: legacy headerless remote files fall back to the full pull path', async () => { + createItem(s.local, 'Legacy Base'); + const firstSync = runCli(s.local, '--json', 'sync'); + expect(firstSync.status, firstSync.stderr).toBe(0); + + // Headerless file (legacy writer) — must import as a full push (kind + // undefined → full replace path), not crash on the header parse. + const ts = new Date(Date.now() + 10 * 1000).toISOString(); + publishRemote(s, [makeItemRecord('DPT-3000', 'Legacy Arrival', ts)], [], undefined); + const tip = remoteTip(s.local); + expect(tip.kind).toBeUndefined(); + + const pullSync = runCli(s.local, '--json', 'sync'); + expect(pullSync.status, pullSync.stderr).toBe(0); + + const items = listItems(s.local); + const ids = items.map(i => i.id); + expect(ids).toContain('DPT-3000'); + expect(new Set(ids).size).toBe(ids.length); + }, 120000); +}); \ No newline at end of file diff --git a/tests/cli/sync-delta-push.test.ts b/tests/cli/sync-delta-push.test.ts new file mode 100644 index 000000000..ed8854f71 --- /dev/null +++ b/tests/cli/sync-delta-push.test.ts @@ -0,0 +1,222 @@ +/** + * E2E tests for the delta (incremental) sync PUSH path (WL-0MT2KY0RQ008F50Q, + * parent WL-0MSAKUBKW006FN8Q §5.1–§5.4). + * + * Uses REAL git + a real bare remote (mock-bin stripped from PATH) against + * the compiled CLI (dist/cli.js), mirroring tests/cli/sync-concurrent.test.ts + * and tests/cli/doctor-foreign-items-push.test.ts. + * + * AC1 — the first sync (no baseline) publishes a FULL snapshot; after a + * baseline exists, subsequent syncs publish DELTA increments. + * AC2 — the delta JSONL written/pushed carries the sync header with + * `{"__worklog_sync__":{"version":1,"kind":"delta"}}`. + * AC3 — the remote ref tip after a delta push contains ONLY the changed + * records (not the full store). + * AC4 — push payload is proportional to the changed records (delta file is + * smaller than the full file when only one item changed). + * AC5 — watermarks are advanced only after a successful push: a delta push + * makes the NEXT sync see zero dirty records and skip export+push + * (§5.4 zero-change fast path). + * AC6/AC7 — full sync flow and concurrency protections are preserved (the + * first sync still pushes a full snapshot; --if-idle lock behavior is + * untouched — exercised implicitly via withFileLock at the caller). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'cli.js'); +const mockBinDir = path.join(projectRoot, 'tests', 'cli', 'mock-bin'); + +/** PATH without the test mock-bin so subprocesses run the real git binary. */ +function realGitEnv(): Record { + const pathVal = (process.env.PATH || '') + .split(path.delimiter) + .filter(p => path.resolve(p) !== path.resolve(mockBinDir)) + .join(path.delimiter); + return { ...process.env as Record, PATH: pathVal }; +} + +function git(cwd: string, ...args: string[]): string { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env: realGitEnv() }); + if (res.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${res.stderr || res.stdout}`); + } + return res.stdout.trim(); +} + +function runCli(cwd: string, ...args: string[]): { stdout: string; stderr: string; status: number } { + const res = spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8', env: realGitEnv() }); + return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 }; +} + +interface Setup { + root: string; + remote: string; + local: string; +} + +function setupProject(): Setup { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-delta-push-')); + const remote = path.join(root, 'remote.git'); + const local = path.join(root, 'local'); + git(root, 'init', '--bare', '-q', remote); + git(root, 'init', '-q', local); + git(local, 'config', 'user.email', 'test@example.com'); + git(local, 'config', 'user.name', 'Test User'); + git(local, 'remote', 'add', 'origin', remote); + writeConfig(local, 'Delta Push Test', 'DPT'); + writeInitSemaphore(local); + // Seed a commit so the local repo has a non-empty main branch (sync pushes + // onto a dedicated refs/worklog/data branch, independent of main). + fs.writeFileSync(path.join(local, 'README.md'), '# delta push\n', 'utf8'); + git(local, 'add', '-A'); + git(local, 'commit', '-q', '-m', 'init'); + return { root, remote, local }; +} + +/** Create a work item via the CLI and return its id. */ +function createItem(local: string, title: string): string { + const res = runCli(local, '--json', 'create', '-t', title); + if (res.status !== 0) { + throw new Error(`wl create failed: ${res.stderr}\n${res.stdout}`); + } + const parsed = JSON.parse(res.stdout); + const id: string = parsed.workItem?.id ?? parsed.id; + expect(id).toBeTruthy(); + return id; +} + +/** Fetch the remote data ref and return `{ content, kind }` parsing the header. */ +function remoteTip(local: string): { content: string; kind: string | undefined; lines: string[] } { + git(local, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const content = git(local, 'show', 'refs/worklog/remotes/origin/worklog/data:.worklog/worklog-data.jsonl'); + const lines = content.trim().split('\n'); + let kind: string | undefined; + let payload = lines; + try { + const first = JSON.parse(lines[0]); + if (first && first.__worklog_sync__) { + kind = first.__worklog_sync__.kind; + payload = lines.slice(1); + } + } catch { + /* headerless legacy file */ + } + return { content, kind, lines: payload }; +} + +describe('delta sync push path (real git)', () => { + let s: Setup; + + beforeEach(() => { + s = setupProject(); + }); + + afterEach(() => { + fs.rmSync(s.root, { recursive: true, force: true }); + }); + + it('AC1/AC2/AC3/AC4: first sync is full; second sync is a delta containing only the new item', async () => { + // Seed three items BEFORE the first sync so the full snapshot is clearly + // larger than the subsequent single-item delta (AC4 payload proportionality). + const a = createItem(s.local, 'Alpha'); + const b = createItem(s.local, 'Beta'); + const c = createItem(s.local, 'Gamma'); + + // ── First sync: no baseline → FULL snapshot ── + const firstSync = runCli(s.local, '--json', 'sync'); + expect(firstSync.status, firstSync.stderr).toBe(0); + const firstOut = JSON.parse(firstSync.stdout); + expect(firstOut.success).toBe(true); + + const firstRemote = remoteTip(s.local); + expect(firstRemote.kind).toBe('full'); + const firstPayload = firstRemote.lines.join('\n'); + expect(firstPayload).toContain(a); + expect(firstPayload).toContain(b); + expect(firstPayload).toContain(c); + + // ── Second sync: baseline exists → DELTA ── + const secondId = createItem(s.local, 'Delta Only'); + const secondSync = runCli(s.local, '--json', 'sync'); + expect(secondSync.status, secondSync.stderr).toBe(0); + const secondOut = JSON.parse(secondSync.stdout); + expect(secondOut.success).toBe(true); + + const secondRemote = remoteTip(s.local); + // Delta header on the pushed file (AC2). + expect(secondRemote.kind).toBe('delta'); + // Only the changed item is in the delta payload (AC3). + const secondPayload = secondRemote.lines.join('\n'); + expect(secondPayload).toContain(secondId); + expect(secondPayload).not.toContain(a); + expect(secondPayload).not.toContain(b); + expect(secondPayload).not.toContain(c); + // Payload proportional to changes: 1-item delta < 3-item full (AC4). + expect(secondRemote.lines.length).toBe(1); + expect(secondRemote.content.length).toBeLessThan(firstRemote.content.length); + }, 120000); + + it('AC5: after a delta push, a no-change sync skips export+push (zero-change fast path)', async () => { + createItem(s.local, 'Only item'); + const firstSync = runCli(s.local, '--json', 'sync'); + expect(firstSync.status, firstSync.stderr).toBe(0); + // Bring the local tracking ref up to date so rev-parse is meaningful. + git(s.local, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const remoteBefore = git(s.local, 'rev-parse', 'refs/worklog/remotes/origin/worklog/data'); + + // No local changes → delta mode sees zero dirty records → skip export+push. + const secondSync = runCli(s.local, '--json', 'sync'); + expect(secondSync.status, secondSync.stderr).toBe(0); + const stdout = secondSync.stdout; + expect(stdout).toContain('"success": true'); + + // No push happened: remote tip ref unchanged AND no local JSONL file + // (ephemeral pattern — file deleted after the first sync's push). + const dataFile = path.join(s.local, '.worklog', 'worklog-data.jsonl'); + expect(fs.existsSync(dataFile)).toBe(false); + git(s.local, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const remoteAfter = git(s.local, 'rev-parse', 'refs/worklog/remotes/origin/worklog/data'); + expect(remoteAfter).toBe(remoteBefore); + }, 120000); + + it('cadence forcing (§5.3): a delta-push counter reaching syncFullSnapshotEveryN forces a full snapshot', async () => { + // Configure a strict cadence: 1 delta, then force full. (writeConfig + // emits config.yaml without a trailing newline — prepend one so the key + // lands on its own line and stays valid YAML.) + const configPath = path.join(s.local, '.worklog', 'config.yaml'); + fs.writeFileSync( + configPath, + fs.readFileSync(configPath, 'utf-8') + '\nsyncFullSnapshotEveryN: 1\n', + 'utf-8' + ); + + createItem(s.local, 'One'); + const firstSync = runCli(s.local, '--json', 'sync'); + expect(firstSync.status, firstSync.stderr).toBe(0); + expect(remoteTip(s.local).kind).toBe('full'); + + // Delta push increments the counter to 1. + createItem(s.local, 'Two'); + const secondSync = runCli(s.local, '--json', 'sync'); + expect(secondSync.status, secondSync.stderr).toBe(0); + expect(remoteTip(s.local).kind).toBe('delta'); + + // Counter (1) >= everyN (1) → next sync must be a FULL snapshot again. + createItem(s.local, 'Three'); + const thirdSync = runCli(s.local, '--json', 'sync'); + expect(thirdSync.status, thirdSync.stderr).toBe(0); + const thirdRemote = remoteTip(s.local); + expect(thirdRemote.kind).toBe('full'); + // The full snapshot contains everything (all three items). + expect(thirdRemote.lines.join('\n')).toContain('"One"'); + expect(thirdRemote.lines.join('\n')).toContain('"Three"'); + }, 120000); +}); \ No newline at end of file diff --git a/tests/cli/sync-integration-convergence.test.ts b/tests/cli/sync-integration-convergence.test.ts new file mode 100644 index 000000000..72e6a06ce --- /dev/null +++ b/tests/cli/sync-integration-convergence.test.ts @@ -0,0 +1,234 @@ +/** + * Integration tests for delta sync convergence (WL-0MT2KZVXX0025BFH, parent + * WL-0MSAKUBKW006FN8Q §8 / AC3). + * + * Two clients (A, B) share one bare remote and interleave FULL and DELTA + * pushes over multiple rounds — each client edits its own records, syncs, + * pulls the other's increment, and eventually both local SQLite stores must + * converge to identical state (design's "convergence (interleaved full+delta)" + * test matrix, AC-parent correctness/convergence). + * + * AC7 — interleaved incremental and full syncs: local and remote converge. + * AC8 — backward compatibility: a LEGACY headerless (old-format) remote ref + * is pulled and merged like a full snapshot by the current wl. + * AC9 — exercised by the canonical suite run (tests/cli + tests/unit) via + * the test-skill runner. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { spawnSync } from 'child_process'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; +import { exportToJsonl } from '../../src/jsonl.js'; +import type { WorkItem } from '../../src/types.js'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'cli.js'); +const mockBinDir = path.join(projectRoot, 'tests', 'cli', 'mock-bin'); + +/** PATH without the test mock-bin so subprocesses run the real git binary. */ +function realGitEnv(): Record { + const pathVal = (process.env.PATH || '') + .split(path.delimiter) + .filter(p => path.resolve(p) !== path.resolve(mockBinDir)) + .join(path.delimiter); + return { ...process.env as Record, PATH: pathVal }; +} + +function git(cwd: string, ...args: string[]): string { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env: realGitEnv() }); + if (res.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${res.stderr || res.stdout}`); + } + return res.stdout.trim(); +} + +function runCli(cwd: string, ...args: string[]): { stdout: string; stderr: string; status: number } { + const res = spawnSync('node', [cliPath, ...args], { cwd, encoding: 'utf-8', env: realGitEnv() }); + return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 }; +} + +interface Setup { + root: string; + remote: string; + local: string; +} + +function setupProject(): Setup { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-integration-')); + const remote = path.join(root, 'remote.git'); + const local = path.join(root, 'local'); + git(root, 'init', '--bare', '-q', remote); + git(root, 'init', '-q', local); + git(local, 'config', 'user.email', 'test@example.com'); + git(local, 'config', 'user.name', 'Test User'); + git(local, 'remote', 'add', 'origin', remote); + writeConfig(local, 'Integration Test', 'INT'); + writeInitSemaphore(local); + fs.writeFileSync(path.join(local, 'README.md'), '# integration\n', 'utf8'); + git(local, 'add', '-A'); + git(local, 'commit', '-q', '-m', 'init'); + return { root, remote, local }; +} + +/** Second client: a fresh clone of the same project, empty local store. */ +function addClient(s: Setup, name: string, prefix: string): string { + const dir = path.join(s.root, name); + git(s.root, 'clone', '-q', s.remote, dir); + git(dir, 'config', 'user.email', 'test@example.com'); + git(dir, 'config', 'user.name', 'Test User'); + writeConfig(dir, 'Integration Test', prefix); + writeInitSemaphore(dir); + return dir; +} + +function createItem(local: string, title: string): string { + const res = runCli(local, '--json', 'create', '-t', title); + if (res.status !== 0) throw new Error(`wl create failed: ${res.stderr}\n${res.stdout}`); + const parsed = JSON.parse(res.stdout); + const id: string = parsed.workItem?.id ?? parsed.id; + expect(id).toBeTruthy(); + return id; +} + +/** Update a work item's description via the CLI. */ +function updateItem(local: string, id: string, description: string) { + const res = runCli(local, '--json', 'update', id, '-d', description); + expect(res.status, res.stderr).toBe(0); +} + +function sync(local: string) { + const res = runCli(local, '--json', 'sync'); + expect(res.status, res.stderr).toBe(0); +} + +/** Remote tip kind with the sync header parsed. */ +function remoteKind(local: string): string | undefined { + git(local, 'fetch', '-q', 'origin', '+refs/worklog/data:refs/worklog/remotes/origin/worklog/data'); + const content = git(local, 'show', 'refs/worklog/remotes/origin/worklog/data:.worklog/worklog-data.jsonl'); + try { + const first = JSON.parse(content.trim().split('\n')[0]); + if (first && first.__worklog_sync__) return first.__worklog_sync__.kind; + } catch { + /* headerless */ + } + return undefined; +} + +/** All local records keyed by id (title + status + description). */ +function localState(local: string): Map { + const res = runCli(local, '--json', 'list', '--deleted'); + expect(res.status, res.stderr).toBe(0); + const parsed = JSON.parse(res.stdout); + const items: Array<{ id: string; title: string; status: string; description: string }> = + parsed.workItems ?? parsed; + return new Map(items.map(i => [i.id, { title: i.title, status: i.status, description: i.description }])); +} + +function makeItemRecord(id: string, title: string, updatedAt: string, description = ''): WorkItem { + return { + id, title, description, status: 'open', priority: 'medium', sortIndex: 0, parentId: null, + createdAt: updatedAt, updatedAt, tags: [], assignee: '', stage: 'idea', issueType: '', + createdBy: '', deletedBy: '', deleteReason: '', risk: '', effort: '', + } as WorkItem; +} + +/** Publish a legacy (headerless) full snapshot to the remote ref — old-format writer. */ +function publishLegacyFull(s: Setup, items: WorkItem[]): void { + const dataPath = path.join(s.local, '.worklog', 'worklog-data.jsonl'); + fs.mkdirSync(path.dirname(dataPath), { recursive: true }); + exportToJsonl(items, [], dataPath, [], [], undefined); + const originalBranch = git(s.local, 'rev-parse', '--abbrev-ref', 'HEAD'); + git(s.local, 'checkout', '-q', '--orphan', 'worklog-legacy-data'); + git(s.local, 'add', '-f', '.worklog/worklog-data.jsonl'); + git(s.local, 'commit', '-q', '-m', 'legacy full'); + git(s.local, 'push', '-f', '-q', 'origin', 'HEAD:refs/worklog/data'); + git(s.local, 'checkout', '-q', originalBranch); + git(s.local, 'branch', '-D', '-q', 'worklog-legacy-data'); +} + +describe('delta sync integration: interleaved convergence (real git)', () => { + let s: Setup; + + beforeEach(() => { + s = setupProject(); + }); + + afterEach(() => { + fs.rmSync(s.root, { recursive: true, force: true }); + }); + + it('AC7: A and B interleave full+delta syncs over 4 rounds and converge', async () => { + const b = addClient(s, 'client-b', 'INT'); + + // Round 1: A seeds 3 items and pushes a FULL snapshot; B pulls it. + const a1 = createItem(s.local, 'A one'); + const a2 = createItem(s.local, 'A two'); + const a3 = createItem(s.local, 'A three'); + sync(s.local); + expect(remoteKind(s.local)).toBe('full'); + sync(b); // B pulls A's full + + // Round 2: B adds its own item (delta push), A pulls it. + const b1 = createItem(b, 'B one'); + sync(b); // B: no baseline? B pulled A's full → watermarks? B never pushed… + // B has not pushed yet in round 2 — its own push must be a full or delta + // depending on baseline; either way remote must keep growing. + const kindAfterB = remoteKind(b); + expect(['full', 'delta']).toContain(kindAfterB); + sync(s.local); // A pulls B's increment + + // Round 3: A edits A one (delta), B edits B one (delta), then both pull. + updateItem(s.local, a1, 'A one edited'); + updateItem(b, b1, 'B one edited'); + sync(s.local); + expect(remoteKind(s.local)).toBe('delta'); + sync(b); // B pulls A's delta + // B pushes its own edit on top (could be full/delta — converged anyway). + sync(b); + sync(s.local); // A pulls B's delta + + // Round 4: A creates item 4 and B creates item 5; final converge pass. + const a4 = createItem(s.local, 'A four'); + const b2 = createItem(b, 'B two'); + sync(s.local); + expect(remoteKind(s.local)).toBe('delta'); + sync(b); // B pulls A's delta and has B's own dirty records → pushes + sync(b); + sync(s.local); // final pull + + // Both clients must see identical state for all shared records. + const stateA = localState(s.local); + const stateB = localState(b); + expect(stateB.size).toBeGreaterThanOrEqual(5); + for (const [id, recA] of stateA) { + const recB = stateB.get(id); + expect(recB, `record ${id} missing on B`).toBeDefined(); + expect(recB?.description, `description drift on ${id}`).toBe(recA.description); + expect(recB?.status, `status drift on ${id}`).toBe(recA.status); + } + // Everything B knows that A knows too. + for (const id of stateB.keys()) { + expect(stateA.has(id), `record ${id} missing on A`).toBe(true); + } + }, 180000); + + it('AC8: a legacy headerless remote ref is pulled and merged like a full snapshot', async () => { + const ts = new Date(Date.now() + 10 * 1000).toISOString(); + publishLegacyFull(s, [ + makeItemRecord('INT-8001', 'Legacy Alpha', ts), + makeItemRecord('INT-8002', 'Legacy Beta', ts), + ]); + expect(remoteKind(s.local)).toBeUndefined(); // headerless → legacy full + + const fresh = addClient(s, 'legacy-reader', 'INT'); + sync(fresh); + + const items = localState(fresh); + expect(items.get('INT-8001')?.title).toBe('Legacy Alpha'); + expect(items.get('INT-8002')?.title).toBe('Legacy Beta'); + }, 120000); +}); \ No newline at end of file diff --git a/tests/cli/update-audit-revert.test.ts b/tests/cli/update-audit-revert.test.ts new file mode 100644 index 000000000..e98adf496 --- /dev/null +++ b/tests/cli/update-audit-revert.test.ts @@ -0,0 +1,282 @@ +/** + * Tests for auto-reversion of in_review items marked "not ready to close". + * + * When `wl update --audit-text "Ready to close: No"` (or + * `wl audit-set --ready-to-close no`) runs on an item in `in_review` + * (status `completed`), the item is automatically reverted to `open` / + * `plan_complete` so it drops out of the ready-to-close queue. The + * reversion is reported in JSON output (`reverted`) and as a human summary + * line. Priority is preserved. + * + * Work item: WL-0MSKHYI5U0069FVV (children WL-0MT0T1D8L009B5P3, + * WL-0MT0T1DQY0045Y5P) + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + cliPath, + execAsync, + enterTempDir, + leaveTempDir, + writeConfig, + writeInitSemaphore, +} from './cli-helpers.js'; + +describe('auto-revert on not-ready-to-close audit verdict', () => { + let tempState: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + tempState = enterTempDir(); + writeConfig(tempState.tempDir, 'Test Project', 'REVT'); + writeInitSemaphore(tempState.tempDir); + }); + + afterEach(() => { + leaveTempDir(tempState); + }); + + async function createItem(flags = '', title = 'Item'): Promise { + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "${title}" ${flags}` + ); + return JSON.parse(stdout).workItem.id; + } + + async function showItem(id: string): Promise { + const { stdout } = await execAsync(`tsx ${cliPath} --json show ${id}`); + return JSON.parse(stdout).workItem; + } + + // ======================================================================= + // wl update --audit-text "Ready to close: No" + // ======================================================================= + describe('wl update --audit-text', () => { + it('reverts an in_review/completed item and reports it in JSON output', async () => { + const id = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --audit-text "Ready to close: No\nStill needs work"` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.reverted).toBeDefined(); + expect(result.reverted.item.id).toBe(id); + expect(result.reverted.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(result.reverted.to).toEqual({ status: 'open', stage: 'plan_complete' }); + + // Persisted in the DB + const item = await showItem(id); + expect(item.status).toBe('open'); + expect(item.stage).toBe('plan_complete'); + }); + + it('preserves the item priority on reversion', async () => { + const id = await createItem('--status completed --stage in_review --priority high'); + + await execAsync( + `tsx ${cliPath} --json update ${id} --audit-text "Ready to close: No\nNeeds more work"` + ); + + const item = await showItem(id); + expect(item.status).toBe('open'); + expect(item.stage).toBe('plan_complete'); + expect(item.priority).toBe('high'); + }); + + it('does not revert a done item', async () => { + const id = await createItem('--status completed --stage done'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --audit-text "Ready to close: No\nNot relevant"` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.reverted).toBeUndefined(); + + const item = await showItem(id); + expect(item.status).toBe('completed'); + expect(item.stage).toBe('done'); + }); + + it('does not revert items that are not in_review/completed', async () => { + // Distinct titles so the create dedup guard (WL-0MSTNG2QF0049B97) + // does not reuse the first item for the second create. + const openId = await createItem('', 'Open item'); + const inProgressId = await createItem('', 'In-progress item'); + await execAsync(`tsx ${cliPath} --json update ${openId} --status open --stage plan_complete`); + await execAsync(`tsx ${cliPath} --json update ${inProgressId} --status in-progress --stage in_progress`); + + for (const id of [openId, inProgressId]) { + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --audit-text "Ready to close: No\nNope"` + ); + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.reverted).toBeUndefined(); + } + + expect((await showItem(openId)).stage).toBe('plan_complete'); + expect((await showItem(inProgressId)).status).toBe('in-progress'); + }); + + it('does not revert when the verdict is "Ready to close: Yes"', async () => { + const id = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${id} --audit-text "Ready to close: Yes\nAll good"` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.reverted).toBeUndefined(); + + const item = await showItem(id); + expect(item.status).toBe('completed'); + expect(item.stage).toBe('in_review'); + }); + + it('prints a human-readable summary line on reversion', async () => { + const id = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} update ${id} --audit-text "Ready to close: No\nMore work needed"` + ); + + expect(stdout).toContain( + `[${id} reverted from completed/in_review to open/plan_complete]` + ); + }); + + it('does not print a summary line when no reversion occurs', async () => { + const id = await createItem('--status completed --stage done'); + + const { stdout } = await execAsync( + `tsx ${cliPath} update ${id} --audit-text "Ready to close: No\nNope"` + ); + + expect(stdout).not.toContain('reverted from'); + }); + + it('reverts only qualifying items in a batch update', async () => { + // Distinct titles so the create dedup guard (WL-0MSTNG2QF0049B97) + // does not reuse the first item for the second create. + const inReviewId = await createItem('', 'In-review item'); + const doneId = await createItem('', 'Done item'); + await execAsync(`tsx ${cliPath} --json update ${inReviewId} --status completed --stage in_review`); + await execAsync(`tsx ${cliPath} --json update ${doneId} --status completed --stage done`); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${inReviewId} ${doneId} --audit-text "Ready to close: No\nBatch"` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + const inReviewResult = result.results.find((r: any) => r.id === inReviewId); + const doneResult = result.results.find((r: any) => r.id === doneId); + expect(inReviewResult.reverted).toBeDefined(); + expect(inReviewResult.reverted.to).toEqual({ status: 'open', stage: 'plan_complete' }); + expect(doneResult.reverted).toBeUndefined(); + + expect((await showItem(inReviewId)).status).toBe('open'); + expect((await showItem(doneId)).status).toBe('completed'); + expect((await showItem(doneId)).stage).toBe('done'); + }); + }); + + // ======================================================================= + // wl audit-set --ready-to-close no + // ======================================================================= + describe('wl audit-set', () => { + it('reverts an in_review/completed item and reports it in JSON output', async () => { + const id = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json audit-set ${id} --ready-to-close no --summary "Still needs work"` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.reverted).toBeDefined(); + expect(result.reverted.item.id).toBe(id); + expect(result.reverted.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(result.reverted.to).toEqual({ status: 'open', stage: 'plan_complete' }); + + const item = await showItem(id); + expect(item.status).toBe('open'); + expect(item.stage).toBe('plan_complete'); + }); + + it('preserves the item priority on reversion', async () => { + const id = await createItem('--status completed --stage in_review --priority critical'); + + await execAsync( + `tsx ${cliPath} --json audit-set ${id} --ready-to-close no --summary "Not done"` + ); + + const item = await showItem(id); + expect(item.status).toBe('open'); + expect(item.stage).toBe('plan_complete'); + expect(item.priority).toBe('critical'); + }); + + it('does not revert when ready-to-close is yes', async () => { + const id = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json audit-set ${id} --ready-to-close yes --summary "Done"` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.reverted).toBeUndefined(); + + const item = await showItem(id); + expect(item.status).toBe('completed'); + expect(item.stage).toBe('in_review'); + }); + + it('does not revert items that are not in_review/completed', async () => { + const ideaId = await createItem('', 'Idea item'); + await execAsync(`tsx ${cliPath} --json update ${ideaId} --status open --stage idea`); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json audit-set ${ideaId} --ready-to-close no --summary "Nope"` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.reverted).toBeUndefined(); + expect((await showItem(ideaId)).stage).toBe('idea'); + }); + + it('does not revert a done item', async () => { + const id = await createItem('--status completed --stage done'); + + const { stdout } = await execAsync( + `tsx ${cliPath} --json audit-set ${id} --ready-to-close no --summary "Nope"` + ); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.reverted).toBeUndefined(); + + const item = await showItem(id); + expect(item.status).toBe('completed'); + expect(item.stage).toBe('done'); + }); + + it('prints a human-readable summary line on reversion', async () => { + const id = await createItem('--status completed --stage in_review'); + + const { stdout } = await execAsync( + `tsx ${cliPath} audit-set ${id} --ready-to-close no --summary "More work"` + ); + + expect(stdout).toContain( + `[${id} reverted from completed/in_review to open/plan_complete]` + ); + }); + }); +}); diff --git a/tests/database.test.ts b/tests/database.test.ts index 212441937..39cdb0bc8 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -602,6 +602,69 @@ describe('WorklogDatabase', () => { }); }); + describe('revertToPlanComplete', () => { + it('should revert a completed/in_review item to open/plan_complete', () => { + const item = db.create({ title: 'Item', status: 'completed', stage: 'in_review' }); + + const reverted = db.revertToPlanComplete(item.id); + + expect(reverted).not.toBeNull(); + expect(reverted!.item.id).toBe(item.id); + expect(reverted!.from).toEqual({ status: 'completed', stage: 'in_review' }); + expect(reverted!.to).toEqual({ status: 'open', stage: 'plan_complete' }); + // Persisted in the DB + const refreshed = db.get(item.id); + expect(refreshed?.status).toBe('open'); + expect(refreshed?.stage).toBe('plan_complete'); + }); + + it('should preserve the item priority on reversion', () => { + const item = db.create({ title: 'Item', status: 'completed', stage: 'in_review', priority: 'high' }); + + const reverted = db.revertToPlanComplete(item.id); + + expect(reverted).not.toBeNull(); + expect(reverted!.item.priority).toBe('high'); + expect(db.get(item.id)?.priority).toBe('high'); + }); + + it('should be a no-op for done items', () => { + const doneItem = db.create({ title: 'Done item', status: 'completed', stage: 'done' }); + + expect(db.revertToPlanComplete(doneItem.id)).toBeNull(); + expect(db.get(doneItem.id)?.status).toBe('completed'); + expect(db.get(doneItem.id)?.stage).toBe('done'); + }); + + it('should be a no-op for items that are not completed/in_review', () => { + const openItem = db.create({ title: 'Open item', status: 'open', stage: 'plan_complete' }); + const inProgressItem = db.create({ title: 'In-progress item', status: 'in-progress', stage: 'in_progress' }); + const ideaItem = db.create({ title: 'Idea item', status: 'open', stage: 'idea' }); + + expect(db.revertToPlanComplete(openItem.id)).toBeNull(); + expect(db.revertToPlanComplete(inProgressItem.id)).toBeNull(); + expect(db.revertToPlanComplete(ideaItem.id)).toBeNull(); + // Untouched + expect(db.get(openItem.id)?.status).toBe('open'); + expect(db.get(openItem.id)?.stage).toBe('plan_complete'); + expect(db.get(inProgressItem.id)?.status).toBe('in-progress'); + expect(db.get(ideaItem.id)?.stage).toBe('idea'); + }); + + it('should return null for a missing item', () => { + expect(db.revertToPlanComplete('TEST-NONEXISTENT')).toBeNull(); + }); + + it('should be idempotent: reverting an already-open item is a no-op', () => { + const item = db.create({ title: 'Item', status: 'completed', stage: 'in_review' }); + + db.revertToPlanComplete(item.id); + expect(db.revertToPlanComplete(item.id)).toBeNull(); + expect(db.get(item.id)?.status).toBe('open'); + expect(db.get(item.id)?.stage).toBe('plan_complete'); + }); + }); + describe('comments', () => { let workItemId: string; diff --git a/tests/doctor-hook-upgrade.test.ts b/tests/doctor-hook-upgrade.test.ts index 14fb79ed9..85857c845 100644 --- a/tests/doctor-hook-upgrade.test.ts +++ b/tests/doctor-hook-upgrade.test.ts @@ -503,7 +503,7 @@ describe('hook-upgrade module', () => { it('returns content for pre-push hook', () => { const content = generateCanonicalHookContent('pre-push'); expect(content).not.toBeNull(); - expect(content!).toContain('worklog:pre-push-hook:v1'); + expect(content!).toContain('worklog:pre-push-hook:v2'); expect(content!).toContain('--git-branch refs/worklog/data'); }); diff --git a/tests/fts-freshness.test.ts b/tests/fts-freshness.test.ts new file mode 100644 index 000000000..333dc6d6c --- /dev/null +++ b/tests/fts-freshness.test.ts @@ -0,0 +1,324 @@ +/** + * Tests for FTS index freshness and query-error surfacing. + * + * Covers the gaps identified in WL-0MSLW8UCP001771K: + * - import()/upsertItems() do not update the FTS index + * - reconcile paths do not update the FTS index + * - FTS5 syntax errors are silently swallowed + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { WorklogDatabase } from '../src/database.js'; +import { createTempDir, cleanupTempDir, createTempDbPath, createTempJsonlPath } from './test-utils.js'; + +describe('FTS index freshness', () => { + let tempDir: string; + let dbPath: string; + let jsonlPath: string; + let db: WorklogDatabase; + + beforeEach(() => { + tempDir = createTempDir(); + dbPath = createTempDbPath(tempDir); + jsonlPath = createTempJsonlPath(tempDir); + db = new WorklogDatabase('TEST', dbPath, jsonlPath, true, true); + }); + + afterEach(() => { + db.close(); + cleanupTempDir(tempDir); + }); + + describe('import() freshness', () => { + it('should find imported items in search without rebuilding the index', () => { + const now = new Date().toISOString(); + db.import([ + { + id: 'TEST-0000000000000001', + title: 'Imported bug fix', + description: 'This item was imported via sync', + status: 'open', + priority: 'high', + sortIndex: 1, + tags: ['bug'], + createdAt: now, + updatedAt: now, + }, + ]); + + // No rebuild — search should find the imported item + const { results } = db.search('imported bug'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].itemId).toBe('TEST-0000000000000001'); + }); + + it('should find imported comments in search without rebuilding the index', () => { + const now = new Date().toISOString(); + const item = { + id: 'TEST-0000000000000002', + title: 'Imported with comments', + description: 'Has searchable comments', + status: 'open', + priority: 'medium', + sortIndex: 1, + tags: [], + createdAt: now, + updatedAt: now, + }; + + // Import the item first (without comments — comments are synced separately) + db.import([item]); + + // Then import the comment via the comment-specific import path + db.importComments([ + { + id: 'TEST-CMT-0000000000000001', + workItemId: item.id, + author: 'sync-agent', + comment: 'shipped in v0.1.11', + createdAt: now, + references: [], + }, + ]); + + // Search should find the comment text immediately + const { results } = db.search('shipped in'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].itemId).toBe(item.id); + }); + + it('should not find deleted-imported items (FTS stale entries removed by re-index)', () => { + const now = new Date().toISOString(); + // First, create and then import with the same item removed + db.create({ title: 'Old item to be removed' }); + db.import([]); // Import empty list — clears everything + + const { results } = db.search('old item'); + expect(results.length).toBe(0); + }); + + it('should handle repeated imports idempotently (no duplicate FTS rows)', () => { + const now = new Date().toISOString(); + const item = { + id: 'TEST-0000000000000003', + title: 'Repeated import test', + description: 'Imported multiple times', + status: 'open', + priority: 'low', + sortIndex: 1, + tags: [], + createdAt: now, + updatedAt: now, + }; + + db.import([item]); + db.import([item]); + db.import([item]); + + // Should still find exactly one match + const { results } = db.search('repeated import'); + const matching = results.filter(r => r.itemId === item.id); + expect(matching.length).toBe(1); + }); + }); + + describe('upsertItems() freshness', () => { + it('should find newly upserted items in search without rebuilding the index', () => { + const now = new Date().toISOString(); + // Create initial items + db.create({ title: 'Existing item' }); + + // Upsert new items + db.upsertItems([ + { + id: 'TEST-0000000000000004', + title: 'Newly upserted item', + description: 'This item was upserted', + status: 'open', + priority: 'medium', + sortIndex: 1, + tags: ['delta'], + createdAt: now, + updatedAt: now, + }, + ]); + + // Should be searchable without rebuild + const { results } = db.search('newly upserted'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].itemId).toBe('TEST-0000000000000004'); + }); + + it('should find updated items with changed text in search without rebuilding', () => { + const item = db.create({ title: 'Original title', description: 'Original desc' }); + + // Update the item via upsertItems + db.upsertItems([ + { + ...item, + title: 'Updated title with keyword', + description: 'Completely new description content here', + updatedAt: new Date().toISOString(), + }, + ]); + + // The new text should be searchable + const { results: newResults } = db.search('updated title keyword'); + expect(newResults.length).toBeGreaterThanOrEqual(1); + expect(newResults[0].itemId).toBe(item.id); + }); + }); + + describe('reconcile status freshness', () => { + it('should reflect status change from reconcile in status-filtered search', () => { + // Create an open item + const item = db.create({ title: 'Reconcile test item', status: 'open' }); + + // Create a blocker + const blocker = db.create({ title: 'Blocker item' }); + db.addDependencyEdge(item.id, blocker.id); + + // Block the item (simulating what the dependency system does) + db.update(item.id, { status: 'blocked' }); + + // Status-filtered search should reflect blocked status + const blockedResults = db.search('reconcile', { status: 'blocked' }); + expect(blockedResults.results.length).toBeGreaterThanOrEqual(1); + + // Now complete the blocker + db.update(blocker.id, { status: 'completed' }); + + // Reconcile should unblock the dependent + db.reconcileDependentStatus(item.id); + + // Now status-filtered search should show it as open, not blocked + const openResults = db.search('reconcile', { status: 'open' }); + expect(openResults.results.length).toBeGreaterThanOrEqual(1); + const openFound = openResults.results.find(r => r.itemId === item.id); + expect(openFound).toBeDefined(); + + const blockedResultsAfter = db.search('reconcile', { status: 'blocked' }); + const blockedFound = blockedResultsAfter.results.find(r => r.itemId === item.id); + expect(blockedFound).toBeUndefined(); + }); + }); + + describe('create + comment freshness (AC1)', () => { + it('finds a freshly created item and its comment without rebuilding (AC1)', () => { + const item = db.create({ title: 'Create-fresh item', description: 'description body' }); + db.createComment({ + workItemId: item.id, + author: 'tester', + comment: 'shipped in v1.0.0', + }); + + // Title searchable immediately (no rebuild) + const titleResults = db.search('create-fresh'); + expect(titleResults.results.length).toBeGreaterThanOrEqual(1); + + // Comment text searchable immediately (no rebuild) + const commentResults = db.search('shipped in'); + expect(commentResults.results.length).toBeGreaterThanOrEqual(1); + expect(commentResults.results[0].itemId).toBe(item.id); + }); + }); + + describe('delete freshness (AC3)', () => { + it('removes a deleted item from search results (AC3)', () => { + const item = db.create({ title: 'Doomed item', description: 'this will be deleted' }); + + const before = db.search('doomed'); + expect(before.results.length).toBeGreaterThanOrEqual(1); + + db.delete(item.id); + + // Deleted (soft) items are hidden from search by default + const after = db.search('doomed'); + expect(after.results.find(r => r.itemId === item.id)).toBeUndefined(); + }); + + it('removes a deleted comment from search results (AC3)', () => { + const item = db.create({ title: 'Comment host item' }); + const comment = db.createComment({ + workItemId: item.id, + author: 'tester', + comment: 'ephemeral comment text', + }); + + const before = db.search('ephemeral comment'); + expect(before.results.find(r => r.itemId === item.id)).toBeDefined(); + + db.deleteComment(comment.id); + + const after = db.search('ephemeral comment'); + expect(after.results.find(r => r.itemId === item.id)).toBeUndefined(); + }); + }); +}); + +describe('FTS query-error surfacing', () => { + let tempDir: string; + let dbPath: string; + let jsonlPath: string; + let db: WorklogDatabase; + + beforeEach(() => { + tempDir = createTempDir(); + dbPath = createTempDbPath(tempDir); + jsonlPath = createTempJsonlPath(tempDir); + db = new WorklogDatabase('TEST', dbPath, jsonlPath, true, true); + }); + + afterEach(() => { + db.close(); + cleanupTempDir(tempDir); + }); + + it('should auto-quote punctuated terms and issue a warning (never silent)', () => { + db.create({ title: 'Release v0.1.11' }); + + // A punctuated term like v0.1.11 trips the FTS5 parser. + // Per producer Q2 decision: auto-quote as a phrase + visible warning. + const { results, warning } = db.search('v0.1.11'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(warning).toBeDefined(); + expect(warning).toContain('auto-quoted'); + }); + + it('should auto-quote file-path style queries with a warning', () => { + db.create({ title: 'Fix indentation in src/lib/util.ts' }); + + const { results, warning } = db.search('src/lib/util.ts'); + // The phrase "src/lib/util.ts" matches the title if indexed; at a + // minimum the query must not throw or return silently without warning. + expect(warning).toBeDefined(); + expect(warning).toContain('auto-quoted'); + }); + + it('should surface a hard error when auto-quoting also fails', () => { + db.create({ title: 'Some work item' }); + + // A null byte is invalid even inside a quoted FTS5 phrase, so the + // auto-quote retry also fails and the error must be surfaced (never + // a silent empty result). + expect(() => db.search('\u0000foo')).toThrow(/invalid query syntax/i); + }); + + it('should still find quoted phrase matches without a warning', () => { + db.create({ title: 'Release v0.1.11' }); + + // Quoted phrase is already valid FTS5 — no auto-quote, no warning + const { results, warning } = db.search('"v0.1.11"'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(warning).toBeUndefined(); + }); + + it('should return empty results for genuinely non-matching queries', () => { + db.create({ title: 'Some work item' }); + + // A valid query that simply has no matches should return empty with no warning + const { results, warning } = db.search('xyznonexistentkeyword12345'); + expect(results.length).toBe(0); + expect(warning).toBeUndefined(); + }); +}); diff --git a/tests/herdr/detail-view.test.ts b/tests/herdr/detail-view.test.ts index e6200325d..a35ccba62 100644 --- a/tests/herdr/detail-view.test.ts +++ b/tests/herdr/detail-view.test.ts @@ -219,11 +219,11 @@ describe('formatDetailView (scrollable)', () => { expect(result).toContain('Test Work Item'); }); - it('scrolls content by offset', () => { + it('scrolls content by offset — header stays pinned (WL-0MSI28AP80002F5S)', () => { const item = makeItem({ description: 'line\n'.repeat(100) }); - // At scroll offset 50, the content should be shifted (header is gone) + // At scroll offset 50, the body is shifted but the header stays pinned const result = formatDetailView(item, 80, 50, 20); - expect(result).not.toContain('WL-TEST001'); + expect(result).toContain('WL-TEST001'); // header always visible expect(result).toContain('scroll'); // The viewport shows description lines starting from line 50 const lines = result.split('\n'); diff --git a/tests/herdr/fetcher.test.ts b/tests/herdr/fetcher.test.ts index 546576dff..f49b028a7 100644 --- a/tests/herdr/fetcher.test.ts +++ b/tests/herdr/fetcher.test.ts @@ -8,6 +8,7 @@ import { fetchNextItems, fetchItemDetails, fetchItemsByStage, + fetchItemsByPriority, fetchChildrenForItem, fetchActionableCount, checkWlAvailable, @@ -305,6 +306,74 @@ describe('fetchItemsByStage', () => { }); }); +describe('fetchItemsByPriority', () => { + beforeEach(() => { + resetExecFileAsync(); + }); + + it('filters items by priority', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + workItems: [ + { id: 'WL-TEST001', title: 'Critical item', priority: 'critical' }, + { id: 'WL-TEST002', title: 'High item', priority: 'high' }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const items = await fetchItemsByPriority('critical'); + expect(items).toHaveLength(2); + }); + + it('returns empty array when priority filter yields no results', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ workItems: [] }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const items = await fetchItemsByPriority('low'); + expect(items).toEqual([]); + }); + + it('passes --status open --priority

--root-only to wl list (WL-0MSKC8T46006999S)', async () => { + const mockFn = vi.fn().mockImplementation((_bin: string, args: string[]) => { + const stdout = JSON.stringify({ workItems: [] }); + return Promise.resolve({ stdout, stderr: '' }); + }); + setExecFileAsync(mockFn as any); + + await fetchItemsByPriority('critical'); + const calls = mockFn.mock.calls.map((c: any) => c[1]); + expect(calls).toHaveLength(1); + // runWl appends --json automatically. + // Open root items only — mirrors the stage-filter semantics + // (WL-0MSDT8X1V003206G): every open root item at that priority, no cap. + expect(calls[0]).toEqual(['list', '--status', 'open', '--priority', 'critical', '--root-only', '--json']); + }); + + it('regroups results priority-first before display (WL-0MSOPHLD1000EWNN)', async () => { + const mockFn = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ + workItems: [ + { id: 'WL-PRIO-LOW', title: 'Low plan item', stage: 'plan_complete', priority: 'low' }, + { id: 'WL-PRIO-CRIT', title: 'Critical plan item', stage: 'plan_complete', priority: 'critical' }, + ], + }), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const items = await fetchItemsByPriority('critical'); + expect(items.map((i) => i.id)).toEqual(['WL-PRIO-CRIT', 'WL-PRIO-LOW']); + expect(items[0].groupLabel).toBe('Critical Group 1'); + expect(items[1].groupLabel).toBe('Group 1'); + expect(items[0].group).toBeLessThan(items[1].group!); + }); +}); + describe('fetchChildrenForItem', () => { beforeEach(() => { resetExecFileAsync(); @@ -329,11 +398,13 @@ describe('fetchChildrenForItem', () => { expect(children[1].id).toBe('WL-001-C2'); expect(children[1].depth).toBe(1); - // Verify correct CLI args — runWl adds --json automatically + // Verify correct CLI args — runWl adds --json automatically and always + // passes a bounded timeout (DEFAULT_WL_TIMEOUT_MS) so a hung wl child + // cannot wedge the refresh path (WL-0MSJNJXX2001NMHS). expect(mockFn).toHaveBeenCalledWith( expect.any(String), ['list', '--parent', 'WL-001', '--json'], - { maxBuffer: 5242880 }, + { maxBuffer: 5242880, timeout: 60_000 }, ); }); diff --git a/tests/herdr/icons.test.ts b/tests/herdr/icons.test.ts index 76672e71f..e715c308b 100644 --- a/tests/herdr/icons.test.ts +++ b/tests/herdr/icons.test.ts @@ -18,7 +18,7 @@ import { getIconPrefix, stageColor, stringDisplayWidth, -} from '../../packages/herdr/src/icons.js'; +} from '@worklog/shared/icons'; import type { WorkItem } from '../../packages/herdr/src/fetcher.js'; diff --git a/tests/herdr/index.test.ts b/tests/herdr/index.test.ts index 0336f34b4..121c72e00 100644 --- a/tests/herdr/index.test.ts +++ b/tests/herdr/index.test.ts @@ -22,6 +22,7 @@ import { buildSendToPiArgs, routeCommand, stripAgentPromptPrefix, stripCommandPr describe('buildSendToPiArgs', () => { it('forwards --cwd and the command when no model is provided', () => { expect(buildSendToPiArgs('/skill:audit WL-123', '/tmp/proj')).toEqual([ + '--no-focus', '--cwd', '/tmp/proj', '/skill:audit WL-123', @@ -30,6 +31,7 @@ describe('buildSendToPiArgs', () => { it('appends --model after --cwd when a model is provided', () => { expect(buildSendToPiArgs('/skill:implement WL-123', '/tmp/proj', 'code')).toEqual([ + '--no-focus', '--cwd', '/tmp/proj', '--model', @@ -50,6 +52,7 @@ describe('buildSendToPiArgs', () => { it('strips the /prompt: routing prefix so pi receives the bare prompt', () => { expect(buildSendToPiArgs('/prompt:Review the current work item', '/tmp/proj', 'author')).toEqual([ + '--no-focus', '--cwd', '/tmp/proj', '--model', diff --git a/tests/herdr/settings.test.ts b/tests/herdr/settings.test.ts index e43cbeeae..716ba81cd 100644 --- a/tests/herdr/settings.test.ts +++ b/tests/herdr/settings.test.ts @@ -61,16 +61,18 @@ describe('defaultSettings', () => { expect(defaultSettings.downtimeEnabled).toBe(true); }); - it('has a 4-minute idle threshold by default', () => { - expect(defaultSettings.downtimeIdleThresholdMs).toBe(240000); + it('has a 60s idle threshold by default', () => { + expect(defaultSettings.downtimeIdleThresholdMs).toBe(60000); }); - it('has downtimeRequiredFreeSlots 0 (all slots) by default', () => { - expect(defaultSettings.downtimeRequiredFreeSlots).toBe(0); + it('has downtimeRequiredFreeSlots 2 (spare-capacity default) by default', () => { + // Default N=2 of 3 slots (parent WL-0MT32F90V008UAD2): spare-capacity + // dispatch works out-of-the-box with one slot reserved for the operator. + expect(defaultSettings.downtimeRequiredFreeSlots).toBe(2); }); - it('has a 30s poll interval by default', () => { - expect(defaultSettings.downtimePollIntervalMs).toBe(30000); + it('has a 10s poll interval by default', () => { + expect(defaultSettings.downtimePollIntervalMs).toBe(10000); }); it('has the llama-proxy URL and plan model by default', () => { @@ -117,13 +119,13 @@ describe('loadSettings', () => { writeFileSync(settingsPath, JSON.stringify({ downtimePollIntervalMs: 5000, // below the 10s floor downtimeIdleThresholdMs: -1, // negative → default - downtimeRequiredFreeSlots: -2, // negative → 0 (all slots) + downtimeRequiredFreeSlots: -2, // negative → default N=2 (spare-capacity) downtimeModel: '', // empty → default }), 'utf-8'); const settings = loadSettings(settingsPath); expect(settings.downtimePollIntervalMs).toBe(10000); - expect(settings.downtimeIdleThresholdMs).toBe(240000); - expect(settings.downtimeRequiredFreeSlots).toBe(0); + expect(settings.downtimeIdleThresholdMs).toBe(60000); + expect(settings.downtimeRequiredFreeSlots).toBe(2); expect(settings.downtimeModel).toBe('plan'); }); @@ -135,9 +137,9 @@ describe('loadSettings', () => { const settings = loadSettings(settingsPath); expect(settings.downtimeEnabled).toBe(false); expect(settings.downtimeProxyUrl).toBe('http://10.0.0.5:8000'); - expect(settings.downtimeIdleThresholdMs).toBe(240000); // from defaults - expect(settings.downtimePollIntervalMs).toBe(30000); // from defaults - expect(settings.downtimeRequiredFreeSlots).toBe(0); // from defaults + expect(settings.downtimeIdleThresholdMs).toBe(60000); // from defaults + expect(settings.downtimePollIntervalMs).toBe(10000); // from defaults + expect(settings.downtimeRequiredFreeSlots).toBe(2); // default N=2 (spare-capacity) }); it('loads showIcons: false from the config file', () => { @@ -210,9 +212,9 @@ describe('saveSettings', () => { browseItemCount: 25, showHelpText: false, downtimeEnabled: true, - downtimeIdleThresholdMs: 240000, + downtimeIdleThresholdMs: 60000, downtimeRequiredFreeSlots: 0, - downtimePollIntervalMs: 30000, + downtimePollIntervalMs: 10000, downtimeProxyUrl: 'http://192.168.0.199:8000', downtimeModel: 'plan', }; diff --git a/tests/herdr/shortcuts.test.ts b/tests/herdr/shortcuts.test.ts index 70a5fded7..360b39c38 100644 --- a/tests/herdr/shortcuts.test.ts +++ b/tests/herdr/shortcuts.test.ts @@ -105,7 +105,7 @@ describe('ShortcutRegistry', () => { { chord: ['x', 'd'], command: '!!wl delete ', view: 'both', label: 'close deleted' }, { chord: ['a', 'a'], command: '/skill:audit ', view: 'both', label: 'audit automatic', stages: ['in_review'] }, { chord: ['a', 'y'], command: "!!wl reviewed false && wl audit-set --ready-to-close yes --summary 'Approved by manual review'", view: 'both', label: 'audit approve', stages: ['in_review'] }, - { chord: ['a', 'r'], command: "!!wl reviewed false && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", view: 'both', label: 'audit reject', stages: ['in_review'] }, + { chord: ['a', 'r'], command: "!!wl reviewed false && wl update --status open --stage plan_complete --priority medium && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", view: 'both', label: 'audit reject', stages: ['in_review'] }, ]; }); @@ -704,7 +704,7 @@ describe('dispatchChordCommand', () => { const items = [makeWorkItem('WL-001')]; const templates = [ ["!!wl reviewed false && wl audit-set --ready-to-close yes --summary 'Approved by manual review'", "!!wl reviewed WL-001 false && wl audit-set WL-001 --ready-to-close yes --summary 'Approved by manual review'"], - ["!!wl reviewed false && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", "!!wl reviewed WL-001 false && wl audit-set WL-001 --ready-to-close no --summary 'Rejected by manual review. '"], + ["!!wl reviewed false && wl update --status open --stage plan_complete --priority medium && wl audit-set --ready-to-close no --summary 'Rejected by manual review. '", "!!wl reviewed WL-001 false && wl update WL-001 --status open --stage plan_complete --priority medium && wl audit-set WL-001 --ready-to-close no --summary 'Rejected by manual review. '"], ] as const; for (const [template, expected] of templates) { const commands: string[] = []; diff --git a/tests/herdr/worklist.test.ts b/tests/herdr/worklist.test.ts index 585129f62..0ff54b7e6 100644 --- a/tests/herdr/worklist.test.ts +++ b/tests/herdr/worklist.test.ts @@ -557,13 +557,26 @@ describe('createListRenderer', () => { }); it('renders group separators when items have groups', () => { - const groupedItems = [ + const items = [ makeItem({ id: 'T1', title: 'Item 1', group: 0, groupLabel: 'Priority' }), makeItem({ id: 'T2', title: 'Item 2', group: 0 }), makeItem({ id: 'T3', title: 'Item 3', group: 1, groupLabel: 'Backlog' }), ]; + // Build DisplayRow[] with heading rows, matching getDisplayRows() output + // (WL-0MSL5MPSZ003TG94) — the renderer expects first-class heading rows, + // not auto-generated separators from WorkItem group transitions. + const displayRows: Array< + | { kind: 'heading'; group: number; groupLabel: string; count: number; collapsed: boolean } + | WorkItem + > = []; + displayRows.push({ kind: 'heading', group: 0, groupLabel: 'Priority', count: 2, collapsed: false }); + displayRows.push(items[0]); + displayRows.push(items[1]); + displayRows.push({ kind: 'heading', group: 1, groupLabel: 'Backlog', count: 1, collapsed: false }); + displayRows.push(items[2]); + const renderer = createListRenderer(); - const output = renderer(groupedItems, 0, 0, DEFAULT_TERM_SIZE, null, 'list', null); + const output = renderer(displayRows, 0, 0, DEFAULT_TERM_SIZE, null, 'list', null); expect(output).toContain('Priority'); expect(output).toContain('Backlog'); }); diff --git a/tests/jsonl.test.ts b/tests/jsonl.test.ts index 8ed63c2e2..46d287f20 100644 --- a/tests/jsonl.test.ts +++ b/tests/jsonl.test.ts @@ -500,4 +500,91 @@ describe('JSONL Import/Export', () => { expect(result.dependencyEdges).toHaveLength(0); }); }); + + describe('sync header (incremental sync, WL-0MT2KXPOQ009G026)', () => { + it('emits a full header line as the first line when kind is full', () => { + const items: WorkItem[] = [ + { + id: 'WI-001', + title: 'Task 1', + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + tags: [], + assignee: '', + stage: '', + }, + ]; + + exportToJsonl(items, [], testFilePath, [], [], 'full'); + + const lines = fs.readFileSync(testFilePath, 'utf-8').trim().split('\n'); + const header = JSON.parse(lines[0]); + expect(header.__worklog_sync__.kind).toBe('full'); + expect(header.__worklog_sync__.version).toBe(1); + // Records follow the header (unaffected record format) + expect(lines[1]).toContain('"type":"workitem"'); + }); + + it('emits a delta header and imports it as kind delta with records intact', () => { + const items: WorkItem[] = [ + { + id: 'WI-001', + title: 'Task 1', + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + tags: [], + assignee: '', + stage: '', + }, + ]; + + exportToJsonl(items, [], testFilePath, [], [], 'delta'); + + const result = importFromJsonl(testFilePath); + expect(result.kind).toBe('delta'); + expect(result.items).toHaveLength(1); + expect(result.items[0].id).toBe('WI-001'); + }); + + it('imports full header files with kind full', () => { + exportToJsonl([], [], testFilePath, [], [], 'full'); + const result = importFromJsonl(testFilePath); + expect(result.kind).toBe('full'); + expect(result.items).toHaveLength(0); + }); + + it('legacy files without a header import with undefined kind', () => { + const content = JSON.stringify({ type: 'workitem', data: { id: 'WI-001', title: 'T', description: '', status: 'open', priority: 'medium', sortIndex: 0, parentId: null, createdAt: '2024-01-01T00:00:00.000Z', updatedAt: '2024-01-01T00:00:00.000Z', tags: [], assignee: '', stage: '' } }) + '\n'; + fs.writeFileSync(testFilePath, content, 'utf-8'); + const result = importFromJsonl(testFilePath); + expect(result.kind).toBeUndefined(); + expect(result.items).toHaveLength(1); + }); + + it('exportToJsonlAsync emits the header in delta mode', async () => { + await exportToJsonlAsync([], [], testFilePath, [], [], { kind: 'delta' }); + const lines = fs.readFileSync(testFilePath, 'utf-8').trim().split('\n'); + expect(lines[0]).toContain('"kind":"delta"'); + const result = importFromJsonl(testFilePath); + expect(result.kind).toBe('delta'); + }); + + it('exportToJsonlAsync without kind emits a headerless (legacy) file', async () => { + await exportToJsonlAsync([], [], testFilePath, [], []); + const lines = fs.readFileSync(testFilePath, 'utf-8').trim().split('\n'); + expect(lines[0]).not.toContain('__worklog_sync__'); + const result = importFromJsonl(testFilePath); + expect(result.kind).toBeUndefined(); + }); + }); }); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts index 1d680a5d2..7568dc522 100644 --- a/tests/migrations.test.ts +++ b/tests/migrations.test.ts @@ -93,3 +93,46 @@ describe('migrations: add audit field', () => { } }); }); + +describe('migrations: add last_export_timestamps table', () => { + it('lists the migration as pending for a legacy db without the table', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + createLegacyDbWithoutAudit(dbPath); + + const pending = listPendingMigrations(dbPath); + const ids = pending.map(p => p.id); + expect(ids).toContain('20260821-add-last-export-timestamps'); + } finally { + cleanupTempDir(tempDir); + } + }); + + it('creates the last_export_timestamps table and is idempotent', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + createLegacyDbWithoutAudit(dbPath); + + const applied = runMigrations({ confirm: true }, dbPath); + expect(applied.applied.map(a => a.id)).toContain('20260821-add-last-export-timestamps'); + + const db = new Database(dbPath, { readonly: true }); + try { + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='last_export_timestamps'").all() as any[]; + expect(tables.length).toBe(1); + // The table starts empty (rows are upserted at runtime by the store) + const rows = db.prepare('SELECT * FROM last_export_timestamps').all(); + expect(rows.length).toBe(0); + } finally { + db.close(); + } + + const secondRun = runMigrations({ confirm: true }, dbPath); + expect(secondRun.applied.map(a => a.id)).not.toContain('20260821-add-last-export-timestamps'); + } finally { + cleanupTempDir(tempDir); + } + }); +}); diff --git a/tests/scripts/install-herdr.test.ts b/tests/scripts/install-herdr.test.ts index 8225e237e..268b2e416 100644 --- a/tests/scripts/install-herdr.test.ts +++ b/tests/scripts/install-herdr.test.ts @@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process'; const KEYBINDING_BLOCK = `[[keys.command]] key = "prefix+l" command = "herdr plugin action invoke worklog-selection-list.open-podcast-editor-tab" -description = "Open the Podcast Editing tab (Worklog work item selection pane)." +description = "Open the Worklog tab (Worklog work item selection pane)." `; // Marker the script uses for its existence check. diff --git a/tests/scripts/package-scripts.test.ts b/tests/scripts/package-scripts.test.ts new file mode 100644 index 000000000..8c9f79652 --- /dev/null +++ b/tests/scripts/package-scripts.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Read the root package.json and return its scripts object. + */ +function getPackageScripts(): Record { + const repoRoot = path.resolve(__dirname, '..', '..'); + const pkgPath = path.join(repoRoot, 'package.json'); + const data = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + expect(data.scripts).toBeDefined(); + expect(typeof data.scripts).toBe('object'); + return data.scripts as Record; +} + +describe('npm install script wiring', () => { + it('defines an install script that chains install:plugin then install:pi-extension', () => { + const scripts = getPackageScripts(); + expect(scripts.install).toBeDefined(); + // The install script must chain both install rules in the correct order. + expect(scripts.install).toContain('install:plugin'); + expect(scripts.install).toContain('install:pi-extension'); + // install:plugin must appear before install:pi-extension in the chain. + const pluginIdx = scripts.install.indexOf('install:plugin'); + const piExtIdx = scripts.install.indexOf('install:pi-extension'); + expect(pluginIdx).toBeLessThan(piExtIdx); + }); + + it('defines install:plugin that invokes scripts/install-herdr.sh', () => { + const scripts = getPackageScripts(); + expect(scripts['install:plugin']).toBeDefined(); + expect(scripts['install:plugin']).toContain('install-herdr.sh'); + }); + + it('does not remove or alter the postbuild hook', () => { + const scripts = getPackageScripts(); + expect(scripts.postbuild).toContain('install-herdr.sh'); + expect(scripts.postbuild).toBe('bash ./scripts/install-herdr.sh'); + }); + + it('does not remove or alter the existing install:pi-extension rule', () => { + const scripts = getPackageScripts(); + expect(scripts['install:pi-extension']).toBeDefined(); + expect(scripts['install:pi-extension']).toBe('bash ./scripts/install-pi-extension.sh'); + }); + + it('install:plugin targets scripts/install-herdr.sh (matching the postbuild convention)', () => { + const scripts = getPackageScripts(); + expect(scripts['install:plugin']).toBe(scripts.postbuild); + }); + + it('install script uses npm run (not bash) for chained rules', () => { + const scripts = getPackageScripts(); + // The install script should invoke sub-rules via npm run, not bash directly. + expect(scripts.install).toMatch(/npm\s+run/); + // Neither sub-rule should use bash directly in the install script. + expect(scripts.install).not.toMatch(/bash/); + }); + + it('install:plugin script is executable (the target shell script exists)', () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const scriptPath = path.join(repoRoot, 'scripts', 'install-herdr.sh'); + expect(fs.existsSync(scriptPath)).toBe(true); + }); + + it('install:pi-extension script is executable (the target shell script exists)', () => { + const repoRoot = path.resolve(__dirname, '..', '..'); + const scriptPath = path.join(repoRoot, 'scripts', 'install-pi-extension.sh'); + expect(fs.existsSync(scriptPath)).toBe(true); + }); +}); diff --git a/tests/sync.test.ts b/tests/sync.test.ts index 09077a5d1..c86d3d5db 100644 --- a/tests/sync.test.ts +++ b/tests/sync.test.ts @@ -1244,6 +1244,162 @@ describe('Sync Operations', () => { }); }); + // ── WL-0MT2KYCNB000CYWV: delta pull merge semantics (AC7) ── + // A delta remote contains only *changed* records. The by-ID merge seeds + // from the local base, so merging a delta must produce the full local base + // ∪ changed records — no missing records, no duplicates. + describe('delta merge logic (WL-0MT2KYCNB000CYWV)', () => { + const dItem = (id: string, overrides: Partial = {}): WorkItem => ({ + id, + title: `Item ${id}`, + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + tags: [], + assignee: '', + stage: 'idea', + issueType: '', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '' as const, + effort: '' as const, + ...overrides, + }); + + it('merges a delta onto the local base: base records not in the delta survive', () => { + const localBase = [ + dItem('WI-B1'), + dItem('WI-B2', { updatedAt: '2024-02-01T00:00:00.000Z' }), + dItem('WI-B3', { updatedAt: '2024-03-01T00:00:00.000Z' }), + ]; + // Delta: ONE record only (a brand-new item) — the other two local base + // records are absent from the delta. + const delta = [dItem('WI-D1', { updatedAt: '2024-04-01T00:00:00.000Z' })]; + + const result = mergeWorkItems(localBase, delta); + + expect(result.merged).toHaveLength(4); + const ids = result.merged.map(i => i.id); + // No missing records: every local base id survives. + expect(ids).toContain('WI-B1'); + expect(ids).toContain('WI-B2'); + expect(ids).toContain('WI-B3'); + // Delta record added. + expect(ids).toContain('WI-D1'); + // No duplicates. + expect(new Set(ids).size).toBe(4); + }); + + it('applies an updated record from the delta when the delta copy is newer', () => { + const localBase = [dItem('WI-B1', { status: 'open', updatedAt: '2024-01-02T00:00:00.000Z' })]; + const delta = [dItem('WI-B1', { status: 'completed', updatedAt: '2024-05-01T00:00:00.000Z' })]; + + const result = mergeWorkItems(localBase, delta); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].id).toBe('WI-B1'); + expect(result.merged[0].status).toBe('completed'); + }); + + it('keeps the local base value when the delta record is a stale duplicate (local newer)', () => { + const localBase = [dItem('WI-B1', { status: 'completed', updatedAt: '2024-05-01T00:00:00.000Z' })]; + const delta = [dItem('WI-B1', { status: 'open', updatedAt: '2024-01-02T00:00:00.000Z' })]; + + const result = mergeWorkItems(localBase, delta); + + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('completed'); + }); + + it('a full-snapshot remote (same by-ID merge) also never drops base records', () => { + const localBase = [dItem('WI-B1'), dItem('WI-B2')]; + // Full snapshot arrives missing WI-B2 (e.g. authored before it existed). + const snapshot = [dItem('WI-B1', { updatedAt: '2024-06-01T00:00:00.000Z' })]; + + const result = mergeWorkItems(localBase, snapshot); + + const ids = result.merged.map(i => i.id); + expect(ids).toContain('WI-B1'); + expect(ids).toContain('WI-B2'); + expect(new Set(ids).size).toBe(2); + }); + }); + + // ── WL-0MT2KZH0I005XUWE: deletion propagation through the delta merge (AC2/AC3) ── + // A soft-deleted record (`status: 'deleted'`) is terminal intent: a NEWER delete + // must win even over a local/remote CLOSE (completed + terminal stage), because the + // delete command preserves the stage — without this precedence a deletion of a + // completed/in_review item would never converge to the remote. + describe('deletion propagation via delta merge (WL-0MT2KZH0I005XUWE)', () => { + const dItem2 = (id: string, overrides: Partial = {}): WorkItem => ({ + id, + title: `Item ${id}`, + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + tags: [], + assignee: '', + stage: 'idea', + issueType: '', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '' as const, + effort: '' as const, + ...overrides, + }); + + it('a newer remote delete beats a local close (same id, different timestamps)', () => { + const local = [dItem2('WI-D1', { status: 'completed', stage: 'in_review', updatedAt: '2024-01-02T00:00:00.000Z' })]; + const delta = [dItem2('WI-D1', { status: 'deleted', stage: 'in_review', updatedAt: '2024-01-03T00:00:00.000Z' })]; + const result = mergeWorkItems(local, delta); + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('deleted'); + }); + + it('a newer local delete beats an older remote close (push-side propagation)', () => { + const local = [dItem2('WI-D1', { status: 'deleted', stage: 'in_review', updatedAt: '2024-01-03T00:00:00.000Z' })]; + const delta = [dItem2('WI-D1', { status: 'completed', stage: 'in_review', updatedAt: '2024-01-02T00:00:00.000Z' })]; + const result = mergeWorkItems(local, delta); + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('deleted'); + }); + + it('a delete wins deterministically at equal timestamps (same-timestamp strategy)', () => { + const local = [dItem2('WI-D1', { status: 'completed', stage: 'in_review', updatedAt: '2024-01-03T00:00:00.000Z' })]; + const delta = [dItem2('WI-D1', { status: 'deleted', stage: 'in_review', updatedAt: '2024-01-03T00:00:00.000Z' })]; + const result = mergeWorkItems(local, delta); + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('deleted'); + }); + + it('a plain open → deleted delta record still merges to deleted', () => { + const local = [dItem2('WI-D1', { status: 'open', updatedAt: '2024-01-02T00:00:00.000Z' })]; + const delta = [dItem2('WI-D1', { status: 'deleted', updatedAt: '2024-01-03T00:00:00.000Z' })]; + const result = mergeWorkItems(local, delta); + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('deleted'); + expect(result.merged[0].updatedAt).toBe('2024-01-03T00:00:00.000Z'); + }); + + it('no regression: close-preservation still applies when neither side is deleted', () => { + const local = [dItem2('WI-D1', { status: 'open', stage: 'in_review', updatedAt: '2024-01-02T00:00:00.000Z' })]; + const delta = [dItem2('WI-D1', { status: 'completed', stage: 'in_review', updatedAt: '2024-01-03T00:00:00.000Z' })]; + const result = mergeWorkItems(local, delta); + expect(result.merged).toHaveLength(1); + expect(result.merged[0].status).toBe('completed'); + }); + }); + describe('merge utils', () => { it('treats empty values as defaults unless configured otherwise', () => { expect(isDefaultValue('', 'title')).toBe(true); diff --git a/tests/unit/delta-sync-performance.test.ts b/tests/unit/delta-sync-performance.test.ts new file mode 100644 index 000000000..5ac9f7fab --- /dev/null +++ b/tests/unit/delta-sync-performance.test.ts @@ -0,0 +1,117 @@ +/** + * Performance test for incremental delta sync (WL-0MT2KZVXX0025BFH AC10, + * parent WL-0MSAKUBKW006FN8Q §10): "typical incremental sync completes in + * ≤ 3 s on the 2133-item dataset." + * + * The full community dataset (2133 items / 7494 comments, ~11.6 MB) is not + * checked into the repo, so this test generates a comparable-scale dataset + * (2000 items + comments) and measures the DELTA export (the new code path's + * hot loop: watermark filter + JSONL build of only the changed records). The + * bound is deliberately generous (2500 ms for the delta export of a handful + * of changed records) so the test catches order-of-magnitude regressions + * (e.g. accidentally exporting the full store in delta mode) without being + * flaky on slow CI machines. A full export of the whole generated dataset is + * asserted separately to stay well under the same budget. + * + * The delta export path measured here is exactly what `performSync` calls via + * WorklogDatabase.exportForSync({ mode: 'delta' }) on the push side. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { WorklogDatabase } from '../../src/database.js'; +import { WorkItem, Comment } from '../../src/types.js'; + +describe('incremental sync performance (WL-0MT2KZVXX0025BFH AC10)', () => { + let tempDir: string; + let db: WorklogDatabase; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'wl-perf-')); + db = new WorklogDatabase('PRF', join(tempDir, 'worklog.db'), join(tempDir, 'data.jsonl'), true); + }); + + afterEach(() => { + db.close(); + rmSync(tempDir, { recursive: true, force: true }); + }); + + /** Seed a dataset of `count` items + `count` comments with fixed timestamps. */ + function seed(count: number, ts: string): void { + const items: WorkItem[] = []; + for (let i = 0; i < count; i++) { + items.push({ + id: `PRF-${String(i).padStart(6, '0')}`, + title: `Perf item ${i}`, + description: `Description for perf item ${i} — some realistic-length body text with enough words to mirror the real dataset.`, + status: 'open', + priority: 'medium', + sortIndex: i, + parentId: null, + createdAt: ts, + updatedAt: ts, + tags: i % 3 === 0 ? ['perf', 'delta'] : [], + assignee: '', + stage: 'idea', + issueType: i % 2 === 0 ? 'task' : 'docs', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + }); + } + db.import(items); + const comments: Comment[] = []; + for (let i = 0; i < count; i++) { + comments.push({ + id: `PRF-C-${String(i).padStart(6, '0')}`, + workItemId: `PRF-${String(i % count).padStart(6, '0')}`, + author: 'test@example.com', + comment: `A comment body on perf item ${i}.`, + createdAt: ts, + references: [], + }); + } + db.importComments(comments); + } + + it('delta export of a handful of changed records over a 2000-item store completes ≤ 2500ms', async () => { + const T0 = '2026-01-01T00:00:00.000Z'; + const T1 = '2026-02-01T00:00:00.000Z'; + seed(2000, T0); + // Baseline: everything exported/acked at T0. + db.markLastExportTimestamps({ workitems: T0, comments: T0, edges: T0, audit_results: T0 }); + + // Only 5 records change after the baseline. + const changed = ['PRF-000001', 'PRF-000100', 'PRF-000500', 'PRF-001000', 'PRF-001999']; + const items = db.getAll().map(i => (changed.includes(i.id) ? { ...i, description: 'changed after baseline', updatedAt: T1 } : i)); + db.import(items); + + const start = Date.now(); + const jsonlPath = await db.exportForSync({ mode: 'delta' }); + const elapsed = Date.now() - start; + + const exportedRaw = require('fs').readFileSync(jsonlPath, 'utf-8'); + const lines = exportedRaw.trim().split('\n').filter(Boolean); + // Lines: [header] + 1 JSONL per exported record; the header carries + // __worklog_sync__ so data lines are everything else. + const dataLines = lines.filter(l => !l.includes('__worklog_sync__')); + // Delta exports ONLY the changed records (proportional payload, AC1/§5.2). + expect(dataLines.length).toBe(changed.length); + expect(elapsed).toBeLessThan(2500); + }, 15000); + + it('full export of a 2000-item store completes ≤ 2500ms (delta-mode peer path)', async () => { + const T0 = '2026-01-01T00:00:00.000Z'; + seed(2000, T0); + + const start = Date.now(); + await db.exportForSync({ mode: 'full' }); + const elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(2500); + }, 15000); +}); \ No newline at end of file diff --git a/tests/unit/icons.test.ts b/tests/unit/icons.test.ts index 211db5c13..12604325b 100644 --- a/tests/unit/icons.test.ts +++ b/tests/unit/icons.test.ts @@ -1,4 +1,8 @@ import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; +import { readFileSync, readdirSync, statSync, lstatSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + import { priorityIcon, statusIcon, @@ -28,7 +32,7 @@ import { auditStaleIcon, auditStaleLabel, auditStaleFallback, -} from '../../src/icons.js'; +} from '../../src/theme.js'; describe('priorityIcon', () => { it('returns emoji for critical priority', () => { @@ -997,3 +1001,54 @@ describe('effortLabel', () => { }); }); }); + +// ─── Repo-wide grep guard (WL-0MT2GMTAZ003VKVL) ─────────────────────── +// The deprecated icons.ts wrappers were removed in WL-0MSJ4BT4Z002HH9B: +// src/icons.ts (consumers import from src/theme.js) +// packages/herdr/src/icons.ts (consumers import from @worklog/shared/icons) +// Any import of an `icons.js`/`icons.ts` path would regress that removal, so +// this guard fails the test suite if such an import ever reappears. + +const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url)); +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', '.git', '.worklog', 'coverage']); +const ICONS_FILE_RE = /(?:from\s+['"]|require\(\s*['"])([^'"]*icons\.(?:js|ts))(?:['"])/; + +function collectRepoFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir)) { + if (EXCLUDED_DIRS.has(entry)) continue; + const full = join(dir, entry); + // Skip symlinks (e.g. the root status-stage-rules.js -> dist/ shim): + // following a broken symlink raises ENOENT in fresh checkouts/worktrees + // where dist/ is not yet built. + if (lstatSync(full).isSymbolicLink()) continue; + if (statSync(full).isDirectory()) { + files.push(...collectRepoFiles(full)); + } else if (/\.(ts|js|mjs|cjs)$/.test(entry)) { + files.push(full); + } + } + return files; +} + +describe('repo-wide grep guard (WL-0MT2GMTAZ003VKVL)', () => { + it('no file imports from a removed icons.js/icons.ts path', () => { + const offenders: string[] = []; + for (const file of collectRepoFiles(REPO_ROOT)) { + const content = readFileSync(file, 'utf8'); + if (ICONS_FILE_RE.test(content)) offenders.push(file); + } + expect(offenders).toEqual([]); + }); + + it('removed icons.ts files do not exist (AC1 regression guard)', () => { + // WL-0MSJ4BT4Z002HH9B AC1/AC4 requires both deprecated icons.ts files to + // be fully deleted. Commit c720c024 (WL-0MT3I9QJU002S34W) re-added + // src/icons.ts with zero importers; that file must not come back. + const removedFiles = [join(REPO_ROOT, 'src', 'icons.ts'), join(REPO_ROOT, 'packages', 'herdr', 'src', 'icons.ts')]; + for (const file of removedFiles) { + expect(existsSync(file), `${file} should be deleted (WL-0MSJ4BT4Z002HH9B)`).toBe(false); + } + }); +}); + diff --git a/tests/unit/worklog-dir-delegation.test.ts b/tests/unit/worklog-dir-delegation.test.ts index 0fe21f4ed..5546116b4 100644 --- a/tests/unit/worklog-dir-delegation.test.ts +++ b/tests/unit/worklog-dir-delegation.test.ts @@ -12,17 +12,29 @@ * /.worklog fallback * * Run: npx vitest run tests/unit/worklog-dir-delegation.test.ts + * + * NOTE (WL-0MT1KLQYD004MOQT): the module under test is imported STATICALLY + * at the top of the file (after the vi.mock), never via `await import()` + * inside a test. The previous dynamic-import pattern intermittently bound + * the REAL @worklog/shared/worklog-paths inside src/worklog-paths.js under + * vitest's forks+isolate module caching — the top-level import got the mock + * while the dynamic import path resolved the real module (~1 in 3-4 full + * tests/unit runs), failing the first three delegation tests. The mock + * functions are created once via vi.hoisted and reset per-test. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { join } from 'node:path'; -vi.mock('@worklog/shared/worklog-paths', () => ({ +const mocks = vi.hoisted(() => ({ resolveWorklogRoot: vi.fn(), getGitRepoRoot: vi.fn(), })); +vi.mock('@worklog/shared/worklog-paths', () => mocks); + import { resolveWorklogRoot } from '@worklog/shared/worklog-paths'; +import { resolveWorklogDir, setWorklogDirOverride } from '../../src/worklog-paths.js'; describe('resolveWorklogDir delegation to shared resolveWorklogRoot', () => { const cwd = '/tmp/wl-delegation-cwd'; @@ -31,47 +43,43 @@ describe('resolveWorklogDir delegation to shared resolveWorklogRoot', () => { beforeEach(() => { origCwd = process.cwd; process.cwd = () => cwd; - vi.mocked(resolveWorklogRoot).mockReset(); + mocks.resolveWorklogRoot.mockReset(); }); afterEach(() => { process.cwd = origCwd; }); - it('delegates to the shared resolver with no args (defaults to process.cwd)', async () => { - const mod = await import('../../src/worklog-paths.js'); - vi.mocked(resolveWorklogRoot).mockReturnValue('/proj'); - expect(mod.resolveWorklogDir()).toBe('/proj/.worklog'); + it('delegates to the shared resolver with no args (defaults to process.cwd)', () => { + mocks.resolveWorklogRoot.mockReturnValue('/proj'); + expect(resolveWorklogDir()).toBe('/proj/.worklog'); expect(resolveWorklogRoot).toHaveBeenCalledTimes(1); expect(resolveWorklogRoot).toHaveBeenCalledWith(); }); - it('falls back to /.worklog when the shared resolver returns undefined', async () => { - const mod = await import('../../src/worklog-paths.js'); - vi.mocked(resolveWorklogRoot).mockReturnValue(undefined); - expect(mod.resolveWorklogDir()).toBe(join(cwd, '.worklog')); + it('falls back to /.worklog when the shared resolver returns undefined', () => { + mocks.resolveWorklogRoot.mockReturnValue(undefined); + expect(resolveWorklogDir()).toBe(join(cwd, '.worklog')); expect(resolveWorklogRoot).toHaveBeenCalledTimes(1); }); - it('falls back to /.worklog when an invalid non-stub .worklog/ stops the walk (documented boundary)', async () => { + it('falls back to /.worklog when an invalid non-stub .worklog/ stops the walk (documented boundary)', () => { // The shared resolver implements the documented invalid-cwd-stop // behavior change (WL-0MS7TQVK2001X4EG): an uninitialized .worklog/ in // cwd is a boundary → undefined → the CLI surfaces /.worklog. - const mod = await import('../../src/worklog-paths.js'); - vi.mocked(resolveWorklogRoot).mockReturnValue(undefined); - expect(mod.resolveWorklogDir()).toBe(join(cwd, '.worklog')); + mocks.resolveWorklogRoot.mockReturnValue(undefined); + expect(resolveWorklogDir()).toBe(join(cwd, '.worklog')); expect(resolveWorklogRoot).toHaveBeenCalledTimes(1); }); - it('the --worklog-dir override wins before delegation', async () => { - const mod = await import('../../src/worklog-paths.js'); + it('the --worklog-dir override wins before delegation', () => { try { - mod.setWorklogDirOverride('/override/.worklog'); - vi.mocked(resolveWorklogRoot).mockReturnValue('/proj'); - expect(mod.resolveWorklogDir()).toBe('/override/.worklog'); + setWorklogDirOverride('/override/.worklog'); + mocks.resolveWorklogRoot.mockReturnValue('/proj'); + expect(resolveWorklogDir()).toBe('/override/.worklog'); expect(resolveWorklogRoot).not.toHaveBeenCalled(); } finally { - mod.setWorklogDirOverride(undefined); + setWorklogDirOverride(undefined); } }); });