diff --git a/CHANGELOG.md b/CHANGELOG.md index 00372c283..247afa4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v1.0.17 (2026-09-30) +### Features +- Press r then i to start an interview on the selected work item. (WL-0MU95SGEB006HQAM) +- Downtime work now always picks the top item from your Herdr list, so automated choices match what you see. (WL-0MTK1ILM2009QYB2) +- Completed agent sessions now close automatically, freeing up your machine's resources. (WL-0MUJL1NAH0042GOS) +- Audited child items now show a dimmed "covered" icon so the queue accurately reflects their audit status. (WL-0MUBVH8QG0020H9L) +- Audit freshness now works consistently everywhere, so items no longer show as fresh in one view and stale in another. (WL-0MUBVH7ZR009PP80) +- Automated work panes now appear in your project's own workspace, grouped by work item. (WL-0MU321YK70035AYT) +- Completed work is now checked for proper review, with warnings and a report to catch anything missed. (WL-0MUBVH9FV0027COG) +- Audits no longer reset for harmless edits like comments or assignee changes. (WL-0MU1EWMHN000YUCG) +- Audits now run one at a time per machine, so they finish reliably instead of failing when busy. (WL-0MUIVE0YG000UVIA) +- Press f d to see the 20 most recent auto-dispatched items and their outcomes. (WL-0MUGLL9SS002E1D2) +### Bug Fixes +- Fixed a broken internal test so updates can be delivered more reliably. (WL-0MUJTF9SA0034BSP) +- Help info now stays visible even when text wraps. (WL-0MTV979LK005YB1B) +- Rejection reasons with apostrophes no longer break the audit reject shortcut. (WL-0MU7KEX65004X0U9) +- Shortcut commands with special characters now run correctly. (WL-0MUKH3DVA00488U0) +- Long-running sessions no longer crash from a stale recovery notification. (WL-0MUIV50EJ007VH9B) +- Panes will no longer close unexpectedly while you're still working. (WL-0MUMEKDK0008LKH8) +- Panes no longer close when an agent stops, and idle auto-close is off by default. (WL-0MUMEJHT9004EQPI) +- The review queue banner now tells you whether to keep auditing or ship. (WL-0MUJQFXYC009CFRJ) +- Fixed a test that expected an outdated keyboard shortcut in the review worklist. (WL-0MUKDNC7L000I1JO) +### Other +- Test runs now stay properly paced, so your game sessions won't be slowed by overloaded servers. (WL-0MUL1DSEF004IJU9) +- Old debug logs are now cleaned up automatically, keeping the game running fast and smooth. (WL-0MUJL0PTS009MC8F) + ## v1.0.16 (2026-09-27) ### Features - Projects now take turns getting work done, and urgent tasks jump to the front. (WL-0MTJ7IEI80055V2V) diff --git a/CLI.md b/CLI.md index a65d811a0..4f51ac086 100644 --- a/CLI.md +++ b/CLI.md @@ -299,6 +299,53 @@ wl audit-set WL-ABC123 --ready-to-close yes --audit-file report.md --summary "Fr wl audit-set WL-ABC123 --ready-to-close yes --fingerprint sha256-abc123 --summary "Content unchanged" ``` +### `audit-waive` [options] + +Record an explicit, durable audit-gap waiver on a work item (WL-0MUBVH9FV0027COG). + +A waiver is a deliberate, auditable exception to the "no audit" gate. It suppresses the +non-fatal closure warning emitted by `wl close` and excludes the item from the +`wl doctor audit-gaps` flagged set. The record is stored in the nullable +`workitems.auditWaiver` column, surfaced by `wl show --json`, and round-trips through +JSONL sync. Absence is fail-safe: a missing waiver never suppresses a flag. + +Behavior: + +- Requires a non-empty `--reason` (a waiver must record why the gap is accepted). +- `--author` defaults to the current user (`WL_USER` / `USER` / `USERNAME`). +- Records `{ reason, author, waivedAt }` on the work item. + +Options: + +- `-r, --reason ` — Why the audit gap is deliberately accepted (required). +- `-a, --author ` — Who recorded the waiver (defaults to the current user). +- `--prefix ` — Override default ID prefix (optional). +- `--json` — Output in JSON format. + +Examples: + +```sh +wl audit-waive WL-ABC123 --reason "Legacy item completed before audits were enforced" +wl audit-waive WL-ABC123 --reason "Accepted by producer" --author "producer" --json +``` + +### `audit-unwaive` [options] + +Remove an explicit audit-gap waiver from a work item. Idempotent: an item that is not +waived is left unchanged. + +Options: + +- `--prefix ` — Override default ID prefix (optional). +- `--json` — Output in JSON format. + +Examples: + +```sh +wl audit-unwaive WL-ABC123 +wl audit-unwaive WL-ABC123 --json +``` + ### `delete` [options] Delete a work item (marks as deleted): this sets the work item status to `deleted` in the local database. If you prefer to set the status explicitly, use `wl update -s deleted` instead. @@ -364,6 +411,22 @@ are closed deepest-first so that leaf items are completed before their parents. bypassing the audit/stage checks. For items without children, `--force` behaves identically to a standard close. +**Non-fatal audit-gap warning (WL-0MUBVH9FV0027COG):** When the non-force close path +closes an item from the `in_review` stage whose audit is absent or stale (per the shared +`isAuditFresh` predicate), and the item is not covered by a fresh-audited direct parent +and has no waiver, a clear non-fatal warning is printed to stderr (and returned in the +`auditGapWarnings` array in `--json` mode). The close still succeeds. To silence the +warning, re-audit the item or record a deliberate exception: + +``` +Warning: WL-ABC123 (root) is being closed from in_review with a missing audit and no waiver. +This is an audit gate leak; re-audit it or record a deliberate exception with +`wl audit-waive WL-ABC123 --reason ""`. +``` + +`wl close --force` bypasses the warning and records a durable waiver on an uncovered +`in_review` root, so the bypass is auditable. + **Output format (recursive close):** When the audit-gated recursive close path is triggered: - **Human-readable output** reports the count of successfully closed descendants: @@ -552,7 +615,7 @@ The `--recency-policy` flag controls how recently updated items are weighted dur When multiple candidate items exist, `wl next` ranks them using the following criteria (highest weight first): 1. **Priority** — higher-priority items always rank above lower-priority items. **`critical` is a hard top tier that no other boost can overtake.** -2. **Audit-not-ready tier (WL-0MTH7G2O1004BHN5)** — any non-critical item with a **fresh** audit (`readyToClose === false` and `auditedAt >= updatedAt`) that is not ready to close is ranked **ahead of every unaudited / ready-to-close / stale non-critical item**, regardless of base priority (high/medium/low) and regardless of age/effort/recency. The tier is second only to `critical`. Missing audit, `readyToClose === true`, or stale (`auditedAt < updatedAt`) audits receive no boost. The same tier governs `wl re-sort`/`reSort` and `wl next --no-re-sort` (via the `selectBySortIndex` stale-index prefix) and `wl next --json` ordering. +2. **Audit-not-ready tier (WL-0MTH7G2O1004BHN5)** — any non-critical item with a **fresh** audit (`readyToClose === false` and fresh per the shared `isAuditFresh` predicate) that is not ready to close is ranked **ahead of every unaudited / ready-to-close / stale non-critical item**, regardless of base priority (high/medium/low) and regardless of age/effort/recency. The tier is second only to `critical`. Missing audit, `readyToClose === true`, or stale audits receive no boost. Freshness is the single shared definition; with no fingerprint available the one-sided fallback time gate applies — fresh iff `auditedAt > updatedAt - 60 s`, i.e. an audit at or after the content timestamp is fresh (WL-0MUBVH7ZR009PP80). The same tier governs `wl re-sort`/`reSort` and `wl next --no-re-sort` (via the `selectBySortIndex` stale-index prefix) and `wl next --json` ordering. 3. **Blocks high-priority work** — among equal-tier candidates, an item that is a prerequisite for a `high` or `critical` downstream item is preferred. This ensures that unblocking high-value work takes precedence over unrelated tasks at the same priority. 4. **Blocked penalty** — items with active dependency blockers are excluded by default (see `--include-blocked`). 5. **Tie-breakers** — sort_index, then age (older items first) break remaining ties. @@ -970,6 +1033,7 @@ Subcommands: - `prune [options]` — Prune soft-deleted work items older than a specified age. Options: `--days ` (age threshold in days), `--dry-run` (show what would be pruned). - `file-paths [options]` — Check intake-stage items for missing or incorrect `**Key Files:**` sections. Options: `--add-placeholder` (add a placeholder section). - `foreign-items [options]` — Report work items whose ID prefix does not match the project prefix (cross-project pollution detection). Options: `--dry-run` (read-only; default), `--apply` (hard-delete foreign items with full cascade — destructive, requires explicit opt-in), `--push` (after `--apply`, rewrite the remote worklog ref so it contains only own items — destructive; requires `--apply`), `--prefix ` (override classification prefix). +- `audit-gaps [options]` — Read-only report of every `completed`/`in_review` item with **no audit record**, showing root vs `child of `, age (from `activityAt`/`updatedAt`), and coverage/waiver status (`uncovered` / `covered` / `waived`). Omits items with a fresh audit; a child covered by a fresh-audited direct parent is reported as `covered`, not flagged. Options: `--prefix `. No `--apply` (read-only). Examples: @@ -989,6 +1053,8 @@ wl doctor foreign-items # Report foreign-prefix work items (rea wl doctor foreign-items --dry-run --json # JSON report of foreign items wl doctor foreign-items --apply # Hard-delete foreign items (destructive) wl doctor foreign-items --apply --push # Clean DB and rewrite remote worklog ref +wl doctor audit-gaps # Report completed/in_review items with no audit (read-only) +wl doctor audit-gaps --json # Machine-readable audit-gap report Known stale combinations detected by `stage-sync`: diff --git a/DATA_FORMAT.md b/DATA_FORMAT.md index 1b356523a..deec52b4f 100644 --- a/DATA_FORMAT.md +++ b/DATA_FORMAT.md @@ -56,7 +56,8 @@ Worklog uses a **dual-storage model** to combine the benefits of persistent data "activityAt": "2024-01-01T00:00:00.000Z", "tags": ["feature", "backend"], "assignee": "john.doe", - "stage": "development" + "stage": "development", + "auditWaiver": null } ``` @@ -79,6 +80,7 @@ Worklog uses a **dual-storage model** to combine the benefits of persistent data - **createdBy**: Optional interoperability field for imported creator/actor - **deletedBy**: Optional interoperability field for imported deleter/actor - **deleteReason**: Optional interoperability field for imported deletion reason +- **auditWaiver** (optional, nullable): Explicit audit-gap waiver record `{ reason, author, waivedAt }`, or `null`/absent when the item is not waived. Suppresses the non-fatal closure warning and excludes the item from `wl doctor audit-gaps`; absence is fail-safe (never suppresses a flag). Managed with `wl audit-waive` / `wl audit-unwaive` (WL-0MUBVH9FV0027COG) ## Comment Structure diff --git a/DOCTOR_AND_MIGRATIONS.md b/DOCTOR_AND_MIGRATIONS.md index fdf6dcfc4..13ac129f4 100644 --- a/DOCTOR_AND_MIGRATIONS.md +++ b/DOCTOR_AND_MIGRATIONS.md @@ -12,6 +12,7 @@ This document describes the `wl doctor` command and the migration policy for Wor - **Dependency edges** — checks that all dependency edges reference existing work items. - **Pending migrations** — the `upgrade` subcommand detects and applies schema migrations. - **Stale deleted items** — the `prune` subcommand removes soft-deleted items older than a configurable threshold. +- **Audit gaps** — the `audit-gaps` subcommand reports `completed`/`in_review` items with no audit record (read-only). ## Running `wl doctor` @@ -100,6 +101,40 @@ See [docs/CROSS_PROJECT_POLLUTION_CLEANUP.md](docs/CROSS_PROJECT_POLLUTION_CLEAN Adding `--push` rewrites the project's remote worklog ref (`origin refs/worklog/data`, or the configured `syncBranch`) so it contains only the project's own items, bypassing the polluted remote history entirely (a fresh orphan commit is force-pushed and the local tracking ref is updated to match). `--push` requires `--apply` — rewriting the ref without cleaning the DB would publish foreign items. After the push, a subsequent `wl sync` pulls the clean ref and cannot re-import foreign items. +### Reporting audit gaps (`wl doctor audit-gaps`) + +Work items can reach `completed`/`in_review` — and ship — without an audit record. `wl doctor audit-gaps` is a **read-only** report of every such item (WL-0MUBVH9FV0027COG): + +```bash +# Report completed/in_review items with no audit record (read-only) +wl doctor audit-gaps + +# Machine-readable report +wl doctor audit-gaps --json +``` + +Each reported row shows: + +- **relationship** — `root` or `child of `. +- **age** — derived from `activityAt` (falling back to `updatedAt`), in milliseconds and whole days. +- **classification** — one of: + - `uncovered` — a genuine gap (flagged); + - `covered` — a child whose **direct** parent has a fresh audit (derived depth-1 coverage; never dispatched independently); + - `waived` — an explicit, durable waiver is recorded. + +Items with a *fresh* audit are omitted; stale-but-present audits are handled by the closure guard and the icon surfaces. The report has **no** `--apply`: it never mutates the database. The JSON payload exposes `items` (all no-audit items with their classification) and `flagged` (the `uncovered` subset), plus `noAuditCount`, `flaggedCount`, `coveredCount`, and `waivedCount`. + +#### Recording a waiver (`wl audit-waive` / `wl audit-unwaive`) + +To deliberately accept an audit gap, record a waiver with a reason: + +```bash +wl audit-waive WL-ABC123 --reason "Legacy item completed before audits were enforced" +wl audit-unwaive WL-ABC123 # remove the waiver +``` + +The waiver is stored durably in the nullable `workitems.auditWaiver` JSON column (`{ reason, author, waivedAt }`), surfaced by `wl show --json`, and round-trips through JSONL sync. A waived item is excluded from the `audit-gaps` flagged set and produces no closure warning. Absence of a waiver is fail-safe: it never suppresses a flag. This is the only schema addition made by WL-0MUBVH9FV0027COG, applied through `wl doctor upgrade` (migration `20260928-add-audit-waiver`). + ## Backups When `wl doctor upgrade` applies migrations, it automatically: diff --git a/README.md b/README.md index 63468a60c..8a1d8eec3 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ You can get a lot of value from using Worklog as a memory for your agents. But y | [API.md](API.md) | REST API endpoints and usage | | [docs/FILE_PATH_CONVENTION.md](docs/FILE_PATH_CONVENTION.md) | File path convention for work item descriptions | | [docs/CROSS_PROJECT_POLLUTION_CLEANUP.md](docs/CROSS_PROJECT_POLLUTION_CLEANUP.md) | Detecting and removing cross-project worklog pollution (`wl doctor foreign-items`) | +| [DOCTOR_AND_MIGRATIONS.md](DOCTOR_AND_MIGRATIONS.md#reporting-audit-gaps-wl-doctor-audit-gaps) | Reporting and waiving audit gaps: `wl doctor audit-gaps`, `wl audit-waive` / `wl audit-unwaive` | | [docs/SYNC_IDENTITY_GATE.md](docs/SYNC_IDENTITY_GATE.md) | Refusing foreign/empty-author commits on sync (`--allow-foreign-author`, polluted-ref recovery) | | [docs/AGENTS-INSTALL.md](docs/AGENTS-INSTALL.md) | How `wl init` installs AGENTS.md / workflow guidance and how local rules relate to the global agent file | @@ -145,6 +146,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/audit-debug-log-retention.md](docs/dev/audit-debug-log-retention.md) | Audit debug log retention policy + runbook: sweep locations/thresholds, dry-run/`--apply`/`--check`, cron install/removal, safety guarantees | | [docs/dev/downtime-dispatcher.md](docs/dev/downtime-dispatcher.md) | Leader-election + shared-coordination downtime dispatcher architecture (herdr); critical-first dispatch tier + freeze split-by-skill; dedicated Dispatcher workspace with per-project tabs (one tab per work-item prefix) | ## Tutorials diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index dc5c63d5c..8d4486993 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -394,9 +394,9 @@ simulates a 6-pane refresh and asserts ≥60% fewer work spawns. `wl next` and `wl re-sort`/`reSort` apply a hard audit-not-ready tier second only to `critical`: -- Any **fresh** non-critical audit with `readyToClose === false` and `auditedAt >= updatedAt` outranks **any** unaudited / `readyToClose === true` / stale (`auditedAt < updatedAt`) non-critical item regardless of base priority (high/medium/low) or age/effort/recency. +- Any **fresh** non-critical audit with `readyToClose === false` outranks **any** unaudited / `readyToClose === true` / stale non-critical item regardless of base priority (high/medium/low) or age/effort/recency. - `critical` is a hard boundary that no audit-not-ready boost can overtake. -- Missing/`true`/stale audits receive no boost; stale is defined as `auditedAt < updatedAt`. +- Missing/`true`/stale audits receive no boost. Freshness is delegated to the single shared predicate `isAuditFresh` (`packages/shared/src/icons.ts`); the ordering tier passes no fingerprints, so the one-sided fallback time gate applies — fresh iff `auditedAt > updatedAt - AUDIT_FRESHNESS_AT_NEAR_TOLERANCE_MS` (60 s), i.e. an audit at or after the content timestamp is fresh, and an audit older than a later content edit is stale. There is no competing `auditedAt`-vs-`updatedAt` comparison in `packages/shared/src/database.ts` (WL-0MUBVH7ZR009PP80). - The same tier is enforced in `packages/shared/src/database.ts` in `sortItemsByScore`/`computeScore` (batch audit `Map` + tiered comparator) and in `selectBySortIndex` both when all `sortIndex` coincide (before effective-priority/age) and when they diverge (tier prefix before `sortIndex`), so `wl next --no-re-sort` and `wl next --json` preserve boosted order without requiring a prior `reSort`. - `src/commands/grouping.ts` Critical Group placement is unchanged by design. diff --git a/docs/AUDIT_STATUS.md b/docs/AUDIT_STATUS.md index 98ba0963b..6251bfcaa 100644 --- a/docs/AUDIT_STATUS.md +++ b/docs/AUDIT_STATUS.md @@ -193,7 +193,16 @@ the TUI icon path (`stageDisplayIcon`), the `in_review` ordering predicate working-tree state`). 2. **Time gate (legacy fallback).** When no stored fingerprint is present (legacy audits) or the caller cannot supply a current fingerprint (e.g. a TUI - render), the original `auditedAt > updatedAt - 60s` floor applies unchanged. + render), the audit is fresh iff `auditedAt > updatedAt - + AUDIT_FRESHNESS_AT_NEAR_TOLERANCE_MS` (60 s, one-sided; WL-0MUBVH7ZR009PP80). + That is: an audit at or after the item's content timestamp is always fresh + (it covers the content), and an audit that precedes a later content edit is + stale once the gap exceeds the tolerance. The ordering tier, the icon path + and the dispatcher all share this single comparison — the earlier competing + `auditedAt >= updatedAt` (no tolerance) form in the ordering tier has been + removed. (SorraAgents' runner uses a stricter `updatedAt + 60 s` reuse gate; + that is a different, pipeline-skip decision and is deliberately not used as + the currency predicate here.) Freshness is **atomic**: `saveAuditResult` — the path behind `wl audit-set`, `wl update --audit-text`, and the audit runner's `persist_audit.py` (see @@ -202,6 +211,27 @@ Freshness is **atomic**: `saveAuditResult` — the path behind `wl audit-set`, in the same transaction, so `isAuditFresh` is true immediately after an audit (WL-0MT8KTE3E001Q1D9 / WL-0MTHRW3770014H51). +**Smart re-instatement (WL-0MU1EWMHN000YUCG).** When an audit is stale by the +*time gate*, `assessAuditInvalidate({ auditedAt, updatedAt, fingerprint, +currentFingerprint })` in `packages/shared/src/icons.ts` decides between +treating it as fresh, **re-instating** it, or flagging it for re-audit: + +- `fresh` — already current by the time gate; no action. +- `reinstate` — time-stale, but `fingerprint === currentFingerprint`, i.e. the + change since the audit was non-semantic (a comment/metadata/sync re-timestamp, + or any edit that did not alter the audited content). + `WorklogDatabase.reconcileAuditInvalidation(id, currentFingerprint)` then + resets `workitems.updatedAt = auditedAt` (the `saveAuditResult` pattern, + preserving `activityAt`), so consumers that cannot compute the current + fingerprint — notably the `wl next` ordering tier — no longer treat the + unchanged audit as stale. +- `re-audit` — no prior audit, a changed fingerprint (semantic change), or no + fingerprints available to prove the content is unchanged (fail-safe). + +The decision delegates the timestamp comparison to `isAuditFresh` itself (its +fingerprint-less path), so exactly one `auditedAt`-vs-`updatedAt` comparison +exists in the codebase. + Fingerprint sources: `wl audit-set --fingerprint `, or an `Audit content fingerprint: ` line embedded in `--summary`/`--raw-output` (the audit skill's report format), or `wl update --audit-text` carrying the same diff --git a/docs/dev/audit-debug-log-retention.md b/docs/dev/audit-debug-log-retention.md new file mode 100644 index 000000000..67aea3451 --- /dev/null +++ b/docs/dev/audit-debug-log-retention.md @@ -0,0 +1,229 @@ +# Audit Debug Log Retention + +Policy and runbook for the transient `audit_debug_*.jsonl` forensic dumps +written by the audit skill. Stale dumps are pruned by +[`scripts/prune_audit_debug_logs.py`](../../scripts/prune_audit_debug_logs.py) +and the daily sweep is installed as an idempotent cron entry by +[`scripts/install-audit-log-hygiene.sh`](../../scripts/install-audit-log-hygiene.sh). + +Work item: `WL-0MUJL0PTS009MC8F` (parent) / `WL-0MUJRQP5A003BKDH` (this page). + +## Why this exists + +Audit debug logs are transient forensics, not durable data. Legacy dumps had +accumulated unbounded — on this host, 48 files / 12.5 GB in the ContextHub +`.worklog` store, 33 files / 11.3 GB in `open_source_llm/.worklog`, and 2.5 GB +under `~/.audit_debug`. Multi-GB JSONL inputs turned recursive greps into +minutes-long, high-CPU scans. The retention sweep keeps the footprint bounded +and predictable. + +## What is swept + +| Location | Recursion | Notes | +|----------|-----------|-------| +| `~/projects/*/.worklog/` (every worklog store) | **non-recursive** — files directly in the store only | Worktree sub-directories (`.worklog/worktrees/**`) are never swept. | +| `~/.audit_debug/**` | **recursive** | Where `audit_runner.py` now writes dumps. | + +Only files whose basename matches `audit_debug_*.jsonl` are ever considered. +Every other file — `worklog.db`, `worklog.db-wal`, `worklog.db-shm`, +`config.yaml`, worktrees, and anything else — is never selected or deleted. + +`--root ` (repeatable) replaces the default locations. A root whose +basename is `.worklog` is scanned non-recursively; every other root is scanned +recursively. Missing roots are a no-op. + +## `~/.audit_debug` vs legacy `.worklog` + +- **Current.** The SorraAgents audit skill's `audit_runner.py` writes dumps to + `~/.audit_debug//` (`_debug_log_dir` / `_default_debug_log_path`) + and deletes them after a successful run (`_remove_debug_log`). +- **Legacy.** Dumps under `*/.worklog/audit_debug_*.jsonl` predate that move and + are stale; they will not regrow. They are still swept because the two largest + stores hold tens of GB of them. + +Both locations share the same name guard and thresholds. + +## Selection thresholds + +A name-matched candidate is selected for deletion when **either** holds: + +| Rule | Default | Flag | +|------|---------|------| +| Strictly older than *N* days | 7 days | `--older-than DAYS` | +| Strictly larger than *M* MB | 50 MB | `--max-size MB` | + +A file exactly at the boundary is kept (the comparisons are strict). Newer and +smaller files survive, which is why `~/.audit_debug` settles at a bounded +footprint rather than emptying. + +## Modes + +| Mode | Effect | +|------|--------| +| *(default)* | **Dry-run.** Prints the selected set and aggregate bytes; changes nothing on disk. | +| `--apply` | Deletes exactly the selected set, emitting one audit line per removed file (path + bytes). | +| `--check` | Deletes nothing. Exits non-zero when the total debug-log footprint exceeds `--budget-gb` (default **5**). | + +`--apply` and `--check` are mutually exclusive. A second `--apply` immediately +after the first reports `0 candidate(s) selected` — the sweep is idempotent. + +When `--log-file PATH` is given, each removed-file audit line is also appended +to that file. Only aggregate counts and sizes are ever reported — log +**contents** are never printed or recorded (dumps may contain provider +payloads). + +## CLI reference + +```text +prune_audit_debug_logs.py + --root PATH Search root (repeatable). Overrides the defaults. + --older-than DAYS Select files strictly older than this (default 7). + --max-size MB Select files strictly larger than this (default 50). + --budget-gb GB --check budget (default 5). + --log-file PATH Append one audit line per removed file. + --apply Delete the selected files (default: dry-run). + --check Delete nothing; non-zero when over --budget-gb. + -h, --help Show help. +``` + +Exit codes: `0` success / within budget; `1` `--check` over budget, invalid +argument, or usage error. The sweep never exits 0 while over budget. + +## Runbook + +Run from the repository root so the relative script path resolves: + +```bash +cd /home/rgardler/projects/ContextHub + +# 1. Inspect (safe): what would be removed, and the total footprint. +python3 scripts/prune_audit_debug_logs.py +python3 scripts/prune_audit_debug_logs.py --check + +# 2. Reclaim: delete the selected set and audit each removal. +python3 scripts/prune_audit_debug_logs.py --apply --log-file "$HOME/.audit_debug/hygiene-cron.log" + +# 3. Confirm idempotence (expect "0 candidate(s) selected"). +python3 scripts/prune_audit_debug_logs.py --apply +``` + +Tighter or looser thresholds: + +```bash +python3 scripts/prune_audit_debug_logs.py --older-than 3 --max-size 20 # dry-run +python3 scripts/prune_audit_debug_logs.py --older-than 3 --max-size 20 --apply +python3 scripts/prune_audit_debug_logs.py --check --budget-gb 2 +``` + +Sweep a specific tree (useful for testing or narrowing scope): + +```bash +python3 scripts/prune_audit_debug_logs.py --root "$HOME/.audit_debug" --check +python3 scripts/prune_audit_debug_logs.py --root "$HOME/projects/ContextHub/.worklog" +``` + +### Safety guarantees + +- **Name guard.** Only `audit_debug_*.jsonl` basenames are considered. +- **Never touches tracked/live data.** `worklog.db`, its WAL/SHM sidecars, + `config.yaml`, and worktree directories are never selected; live SQLite + worklog data is unaffected. +- **Dry-run by default.** Deletion requires the explicit `--apply` flag. +- **Resilient.** An `OSError` while stat-ing or unlinking a candidate is logged + and skipped; the sweep continues with the remaining candidates and never + aborts mid-set. +- **Aggregate-only reporting.** Only counts/sizes are recorded — never log + contents. + +## Automation (cron) + +### Install / remove with the helper + +The helper is idempotent: it tags the entry with a stable marker comment, so +re-running never adds a duplicate and `--remove` never disturbs unrelated +crontab lines. + +```bash +cd /home/rgardler/projects/ContextHub + +# Install the daily sweep (idempotent). Prints "already installed" on re-run. +./scripts/install-audit-log-hygiene.sh + +# Preview the change without writing anything. +./scripts/install-audit-log-hygiene.sh --dry-run + +# Remove only the managed entry. +./scripts/install-audit-log-hygiene.sh --remove + +# Rotate the hygiene log now if it exceeds the cap. +./scripts/install-audit-log-hygiene.sh --rotate-log +``` + +The managed entry runs the sweep with `--apply` once a day (default +`17 3 * * *`) and then rotates its own log: + +```cron +# managed by install-audit-log-hygiene.sh (worklog audit debug retention) +17 3 * * * cd '/home/rgardler/projects/ContextHub' && 'python3' '/home/rgardler/projects/ContextHub/scripts/prune_audit_debug_logs.py' --apply --log-file '/home/rgardler/.audit_debug/hygiene-cron.log' ; '/home/rgardler/projects/ContextHub/scripts/install-audit-log-hygiene.sh' --rotate-log >/dev/null 2>&1 +``` + +### Manual install (if you decline the helper) + +Operators who prefer to edit their own crontab can append the exact managed +line. Run this **from the repository root** — `$PWD` is expanded when the +snippet runs, so the installed line contains absolute paths: + +```bash +( crontab -l 2>/dev/null; \ + printf '%s\n' \ + '# managed by install-audit-log-hygiene.sh (worklog audit debug retention)' \ + "17 3 * * * cd '$PWD' && python3 '$PWD/scripts/prune_audit_debug_logs.py' --apply --log-file '$HOME/.audit_debug/hygiene-cron.log' ; '$PWD/scripts/install-audit-log-hygiene.sh' --rotate-log >/dev/null 2>&1" \ +) | crontab - +``` + +Verify with `crontab -l`. To reverse it, remove the two managed lines +(the marker comment and the line beneath it) with `crontab -e` — or just run +`./scripts/install-audit-log-hygiene.sh --remove`. + +### Bounded hygiene log + +`~/.audit_debug/hygiene-cron.log` receives one audit line per removed file. +After each sweep the managed entry calls the helper's `--rotate-log` action, +which rotates the log to `hygiene-cron.log.1` and starts a fresh file once it +exceeds the cap (default **5 MB**, `HYGIENE_LOG_MAX_BYTES`). At most two +generations exist, so the hygiene log can never itself become the next runaway. + +Environment overrides for the helper: + +| Variable | Default | Purpose | +|----------|---------|---------| +| `CRONTAB` | `crontab` | crontab command (test seam; rarely needed) | +| `HYGIENE_CRON_SCHEDULE` | `17 3 * * *` | cron schedule | +| `HYGIENE_LOG_FILE` | `$HOME/.audit_debug/hygiene-cron.log` | hygiene log path | +| `HYGIENE_LOG_MAX_BYTES` | `5242880` | rotation cap in bytes | +| `PYTHON` | `python3` | interpreter used by the sweep | + +## Relationship to the audit skill + +The SorraAgents audit skill ships `cleanup_debug_logs.py`, which sweeps +`~/.audit_debug` only, with a **14-day** retention and dry-run default. That +script is **referenced, not forked** — this page and its tooling do not edit +the audit skill (repo-boundary rule). The repo-local sweep broadens coverage to +the legacy `.worklog` stores, uses the 7-day/50 MB defaults, adds `--check`, +and is automated by cron. Upstreaming the `~/.audit_debug` half into the audit +skill is a follow-up, not scope. + +## Out of scope (follow-ups) + +- Machine-hygiene skill `WL-0MUJLCVRS0093EJJ` — the broader performance-pressure + remediation workflow. +- Pre-commit/pre-push enforcement of `--check` — only a documented optional + hook call; not wired in here. +- Cold-storage archiving (gzip `--archive-dir`) — deletion is the current + default; archiving would be an additive follow-up. + +## Related documentation + +- [`scripts/prune_audit_debug_logs.py`](../../scripts/prune_audit_debug_logs.py) — the sweep. +- [`scripts/install-audit-log-hygiene.sh`](../../scripts/install-audit-log-hygiene.sh) — cron installer. +- [wl process healthcheck](./wl-process-healthcheck.md) — another machine-hygiene watchdog. diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index 3de255b93..0dc9a1887 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -62,7 +62,8 @@ head yields no candidate**: Because of the extension, the **"no candidate" contract applies only to a genuinely empty dispatchable backlog** (or a backlog fully blocked by a safety gate) — not to an item hidden beyond the `browseItemCount` window. Other terminal reasons (code-freeze, `audit-in-flight`, -`fresh-audit-skip`, `review-queue-hold`, `wl-error`) keep their existing semantics. +`audit-host-saturated`, `fresh-audit-skip`, `review-queue-hold`, `wl-error`) keep their +existing semantics. **Coordination leader (F3, WL-0MTK1ILM2009QYB2):** the shared coordination file holds ONE entry per instance — an **offer** of that instance's own Herdr list head (computed at the @@ -265,115 +266,97 @@ coordination check-in. The legacy per-tier lookup chain in the Herdr head is genuinely empty — production-unreachable, see downtime-worker.ts) keeps the historical change-guard semantics unchanged. -### Dispatcher workspace anchor (C0 WL-0MTR01EU7005SYZG — anchor-by-ID) - -Automated downtime dispatches always spawn in a **dedicated Dispatcher workspace** -so panes never land in the leader's project workspace (e.g. Podcast `wR`). -Invariant: every `dispatchClaimedTier` / `dispatchFromCoordination` / -`dispatchScheduledPrompt` spawn that completes (`buildDowntimePaneArgs` → -`send-to-pi.sh`) creates its pane by **splitting the persisted Dispatcher -anchor pane** (`--anchor `), verified via `herdr pane list` — every -`Downtime triggered …` pane's `workspace_id` equals the Dispatcher workspace. - -Lifecycle (`packages/herdr/src/dispatcher-anchor.ts`, F1 WL-0MTR2CD4X006XI7U): - -- **Persistence:** machine coordination dir (`~/.herdr/downtime/` or - `HERDR_COORDINATION_DIR`, `machine-coordination.ts`) file - `downtime-dispatch-anchor.json` — `{ paneId, workspaceId }` (atomic - tmp+rename, `fs.renameSync`). -- **Provisioning:** `herdr workspace create --label Dispatcher --no-focus` + - its `root_pane` is the anchor pane. Provisioning runs **under the - coordination lock** (`tryAcquireCoordLock` on - `~/.herdr/downtime/downtime-coordination.lock`, `O_CREAT|O_EXCL`), so - concurrent first-dispatches produce exactly one workspace/pane (double-check - inside the lock + raced-read fallback). -- **Validation:** `isPaneAlive(paneId)` (`herdr pane get `) — when the - persisted pane is closed, the next dispatch re-provisions under the lock - and overwrites the file. An `isPaneAlive` throw is fail-closed (`null`). -- **Resolution (wiring, F2 WL-0MTR2HLLJ009PTPJ):** `createDowntimeDeps` wires - `defaultDispatcherAnchorResolver` (`src/index.ts` — `resolveDispatcherAnchor` - with `createDispatcherAnchorDeps(cwd, herdrBin)` inside a `try/catch` → - `null`). All three dispatch entry points check - `typeof deps.getDispatcherAnchor === 'function'`; `null` → neutral - `{ dispatched: false, reason: 'anchor-unavailable' }` — **no pane or marker, - never a fallback to `pane current`** (fail-closed `"no dispatch this - cycle"`). The resolved `anchorId` flows via `spawnAgentPane` → - `buildDowntimePaneArgs` → `--anchor ` → `send-to-pi.sh` (`herdr pane - split --pane `) and `grid.py ` — `send-to-pi.sh` skips the - `pane current` lookup entirely in anchor mode, and the split does not - steal focus (`--no-focus` preserved). -- **Coordination leader:** `dispatchFromCoordination` resolves the anchor - once per tick (machine-wide); `anchor-unavailable` stops the tier loop - immediately — no other offer can spawn without the machine-wide anchor. - -Duplicate workspaces: the anchor **file** is the authority, not the workspace -label count. Multiple `Dispatcher`-labelled workspaces are harmless — only the -`workspaceId` in `downtime-dispatch-anchor.json` is the live anchor. Surplus -idle `Dispatcher` workspaces can be closed when their panes finish -(`herdr workspace close ` one at a time); do not disturb active dispatch -panes. - -### Per-project tabs in the Dispatcher workspace (C1 WL-0MTRQT482001SNXC) - -Within the single `Dispatcher` workspace, each **work-item prefix** gets its -own tab so overnight runs for different projects never intermix in one grid. -For a candidate whose id is `-`, `` is the substring -before the first `-` (case preserved, no normalisation): `WL-…` → tab `WL`, -`TCE-…` → tab `TCE`, `CG-…` → tab `CG`. Automated downtime dispatches only — -manual `open-worklist` / `open-pi-agent` flows keep their current-pane/tab -behaviour, and scheduled-prompt panes (no work item) keep the C0 single -anchor. - -Invariant: every `dispatchClaimedTier` spawn for a worklog item resolves the -per-prefix tab anchor and forwards its anchor pane id via `spawnAgentPane` → -`buildDowntimePaneArgs` → `--anchor ` → `send-to-pi.sh`, so the pane -lands in ``'s tab. Verified with `herdr tab list` (the tab's label is -exactly the prefix) and `herdr pane list` (each `Downtime triggered …` pane's -`tab_id` equals the prefix tab's `tab_id`, `cwd` equals the item's worklog -root). - -Lifecycle (`packages/herdr/src/dispatcher-anchor.ts`, C1): - -- **Resolver:** `getDispatcherTabAnchor(cwd, deps, prefix)` — fast-path reuse - of a persisted live entry, else ensure the `Dispatcher` workspace (via - `getDispatcherAnchor`, reused not duplicated), then under the coordination - lock double-check the persisted map, adopt an existing matching tab, or - create one. Returns `{ workspaceId, tabId, paneId }` or `null` (fail-closed). -- **Persistence:** machine coordination dir file - `downtime-dispatch-tab-anchors.json` — - `{ workspaceId, byPrefix: { "": { tabId, paneId } } }` (atomic - tmp+rename). This map is the authority for reuse. Missing file → empty map - (inheriting the legacy anchor's `workspaceId` when present); corrupt JSON → - `null` (re-provision). -- **Provisioning:** `herdr tab create --workspace --label - --no-focus`; the returned `tab_id` + `root_pane.pane_id` are the tab anchor. - Runs **under the coordination lock** (`tryAcquireCoordLock` on - `~/.herdr/downtime/downtime-coordination.lock`) with a double-check inside - the lock, so concurrent first-dispatches for the same new prefix create the - tab exactly once. -- **Validation:** the tab's anchor pane is checked with `isPaneAlive` - (`herdr pane get `). A stale persisted entry (dead/missing pane) is - re-provisioned on the next dispatch for that prefix; a matching tab that - still hosts a live pane is adopted and re-persisted (e.g. after the anchor - file was lost). -- **Resolution (wiring, C1):** `createDowntimeDeps` wires - `defaultDispatcherTabAnchorResolver` (`src/index.ts` — - `resolveDispatcherTabAnchor` with `createDispatcherAnchorDeps(cwd, herdrBin)` - inside a `try/catch` → `null`). `dispatchClaimedTier` derives - `candidate.id.split('-', 1)[0]` and, when the resolver is present, uses it - **instead of** the legacy single anchor; a `null` result → neutral - `{ dispatched: false, reason: 'anchor-unavailable' }` — **never a fallback - to the legacy single anchor, another tab, or the leader's pane**. -- **CLI shape tolerance:** `tab list` / `tab create` / `pane list` parsers - accept `tab_id`/`tabId`/`id`, `pane_id`/`paneId`/`id`, `label`/`title`, a - nested `result` envelope, and log lines before the JSON. A parse failure is - **fail-closed** (`null` → `anchor-unavailable`) and logs the raw output — it - is never silently read as "no tab exists", which would duplicate tabs. - -Duplicate tabs: the persisted `byPrefix` map is the authority. If a prefix tab -must be reset, close it (`herdr tab close `) and delete or edit its -entry in `downtime-dispatch-tab-anchors.json`; the next dispatch for that -prefix re-creates the tab under the lock. +### Project-workspace placement + item-ID tabs (WL-0MU321YK70035AYT) + +Automated downtime panes for a work item spawn **in the project's own herdr +workspace**, in a **tab labelled with the exact work-item id** — so automated +work is co-located with the project it belongs to and each item has a reusable +tab. The machine-wide `Dispatcher` anchor is retained only as the fallback for +the case where no project plugin pane can be resolved (and for scheduled +prompts, which have no work-item id). + +Invariant: for a `dispatchClaimedTier` spawn of `-` whose +worklog root is `R`: + +1. **Project workspace resolution** — resolve the herdr plugin pane + (`label == "Work Items"`) whose logical project root equals `R` and return + `{ paneId, workspaceId, tabId }`. The logical root is read from + `HERDR_RESOLVED_CWD` via `herdr pane process-info --pane ` + + `/proc//environ` — NOT the pane's reported `cwd` (which is the + plugin directory) and NOT the workspace label. +2. **Item-ID tab** — ensure/reuse a tab labelled exactly the work-item id in + that workspace, then anchor the pane to the tab's root pane. +3. **Spawn** — `spawnAgentPane` → `buildDowntimePaneArgs` → `--anchor + ` → `send-to-pi.sh` (`herdr pane split --pane `), + `--no-focus` preserved. The new pane's `workspace_id` equals the project + workspace and its `tab_id` equals the item tab. +4. **Root-pane cleanup** — after the dispatch pane has spawned, the item + tab's initial root pane (herdr's automatically-provisioned empty bash + pane, used only as the split anchor) is closed, so the tab shows only the + productive dispatch pane (WL-0MU2EOHK900425VU). Only the ROOT pane is ever + closed: the anchor is closed only when live-pane liveness positively + confirms it is not itself a downtime dispatch pane (fail-safe — an + absent/failed liveness query leaves it open). The retained `Dispatcher` + fallback anchor is deliberately exempt: closing it would trigger the + stale-detection re-provision loop. + +If step 1 cannot resolve a project workspace for `R`, the dispatcher falls +back to the **retained machine-wide `Dispatcher` anchor** (C0 +WL-0MTR01EU7005SYZG). A `null` anchor there degrades to the neutral +`{ dispatched: false, reason: 'anchor-unavailable' }` — **no pane and no +marker, never the leader's current pane**. If the project workspace resolves +but the item tab cannot be provisioned, the dispatch also fails closed +(`anchor-unavailable`) rather than dropping the pane into the `Dispatcher` +workspace. + +Lifecycle (`packages/herdr/src/dispatcher-anchor.ts`): + +- **Project resolver:** `resolveProjectWorkspace(cwd, deps, root)` enumerates + machine-wide `herdr pane list`, keeps panes with `label == "Work Items"`, + reads each candidate's logical root (above), and matches `root`. Ambiguity + (≥2 matches) is deterministic — the **focused** matching pane wins, else the + **lowest pane id** — and never selects another root's workspace. Fail-closed + `null` on any CLI/parse error, missing `shell_pid`, missing/unreadable + `/proc//environ`, missing `HERDR_RESOLVED_CWD`, or no match. +- **Item tab:** `getItemTabAnchor(cwd, deps, workspaceId, itemId)` fast-paths + an existing tab labelled exactly `itemId` whose root pane is alive; else + under the coordination lock (with a double-check) adopts a matching tab or + runs `herdr tab create --workspace --label --no-focus` and + returns the new `tab_id` + `root_pane.pane_id`. A second dispatch for the + same item reuses the same tab (never a duplicate); no persistence file is + needed — the tab label IS the key, discovered via `herdr tab list`. + After the first dispatch the tab's root pane is closed (see invariant + step 4) so the tab is never left with an empty bash pane; a later dispatch + for the same item reuses the surviving dispatch pane as the split anchor + (WL-0MU2EOHK900425VU). +- **Fallback anchor:** `getDispatcherAnchor(cwd, deps)` provisions one + `Dispatcher` workspace + persisted anchor pane in the machine coordination + dir (`~/.herdr/downtime/downtime-dispatch-anchor.json`, atomic tmp+rename, + under the coordination lock). The freshly-provisioned root pane is adopted + into a `Downtime` tab (`herdr pane move --new-tab --tab-label Downtime + --no-focus`, best-effort) so the workspace never shows a blank pane + (WL-0MU2EOHK900425VU). `isPaneAlive` (`herdr pane get `) detects a closed + anchor and re-provisions. +- **Wiring:** `createDowntimeDeps` wires `defaultProjectWorkspaceResolver`, + `defaultItemTabAnchorResolver`, and (fallback + scheduled) + `defaultDispatcherAnchorResolver`. +- **CLI shape tolerance:** the `pane list` / `pane process-info` / `tab list` / + `tab create` / workspace-scoped `pane list` parsers accept a nested `result` + envelope, log lines before the JSON, `pane_id`/`paneId`, + `workspace_id`/`workspaceId`, `tab_id`/`tabId`, `shell_pid`/`shellPid`, + `label`/`title`, and `focused`/`is_focused`. A parse failure is + **fail-closed** (`null`) and logs the raw output. +- **Retired:** per-prefix routing (`getDispatcherTabAnchor`, + `candidate.id.split('-')[0]` tab labels) and + `downtime-dispatch-tab-anchors.json` are removed. `getDispatcherAnchor` and + `downtime-dispatch-anchor.json` remain **only** for the AC4 fallback and + scheduled prompts. + +Duplicate `Dispatcher` workspaces are harmless — the anchor file is the +authority, not the label count; close surplus idle ones one at a time without +disturbing active `Downtime triggered …` panes. Manual `open-worklist` / +`open-pi-agent` flows are unaffected. + ### No-candidate cooldown & the empty offer file @@ -417,6 +400,75 @@ the worklog still holds dispatchable work. Therefore: 2 × checkInIntervalMs)` (60 min) whenever the worklog holds dispatchable work — never once per full cooldown. +### Host-wide audit serialisation (WL-0MUIVE0YG000UVIA) + +**Problem.** On 2026-09-26 four downtime audits ran concurrently on a single host — +dispatched by *different projects' dispatchers*. The existing per-instance +active-audit single-flight (WL-0MT3PHW4I002SNOV) only reads the per-worklog +`/.worklog/downtime-dispatches.log`, so it cannot see an audit dispatched +by another project on the same host. Both the shared Local Proxy slots and the +audit runner's host-wide slot (`AUDIT_MAX_CONCURRENCY=1` in cheap-proxy mode) +were saturated; the audit for `SA-0MUG0WFP8008WN63` failed on every Pi call +after 90 s and was aborted with no usable verdict. + +**Contract.** At most **one audit-tier `/skill:audit` run is actively +executing per host**, across herdr instances and projects. The mechanism is a +dedicated machine-wide marker file +(`~/.herdr/downtime/active-audit`, or `HERDR_COORDINATION_DIR`): + +- **Written** by `recordDispatch` on an **audit** dispatch, immediately before +the per-worklog marker (best-effort: a write failure never aborts the +dispatch — the per-worklog marker and stale window still protect the project). +- **Read first** by `getActiveAudit` before the per-worklog check. A non-stale +marker returns `{ ok: true, active: true, source: 'host-wide' }` without any +`wl` call, so the audit tier is skipped host-wide; the candidate falls +through to the next tier (implement → plan → intake) or the tick reports the +neutral reason **`audit-host-saturated`** (never `no-candidate`, so the +no-candidate cooldown is not entered and the next idle tick re-checks). +- **Removed** by `recordPaneClose` when an audit pane completes +(`audit-passed` / `audit-failed`), releasing the host slot for other +instances, and by `recordDispatchFailure` when an audit **spawn fails** (the +attempt never produced a running audit, so the slot must not be stranded). A +best-effort removal; a crashed audit pane is released by the stale window. +- **Staleness:** a marker older than `DOWNTIME_AUDIT_HOST_STALE_WINDOW_MS` +(default **2 h**, matching `DOWNTIME_AUDIT_STALE_WINDOW_MS`) is treated as +released, so a crashed audit pane can never block the host indefinitely. + +**Relationship to the existing guards** (defence in depth — the host-wide +gate is the *outermost* audit-concurrency guard, never a replacement): + +| Guard | Scope | Sees | Action | +|---|---|---|---| +| **Host-wide marker** (this mechanism) | machine-wide, all projects/instances | an audit dispatched by ANY project on this host | skip audit tier; reason `audit-host-saturated` | +| **Per-worklog single-flight** (WL-0MT3PHW4I002SNOV) | one project's dispatch log | a non-stale `kind=audit` marker mapping to an `in_progress` item in THIS worklog | skip audit tier; reason `audit-in-flight` | +| **Proxy-slot gating** (`available_slots` / `contention_queue_depth` / per-slot ownership) | the Local Proxy | live slot availability, queue depth, live leases | ineligible/skip audit dispatch (per-tier minimum 2 free slots) or `proxy-contention` | +| **`AUDIT_PHASE2_PARALLELISM=1`** | the audit skill's child fan-out | Phase 2 deep-analysis children | parent + one child = exactly 2 local slots | + +**Observability.** The skip is logged to stderr as +`Downtime audit tier skipped: audit-host-saturated (concurrentAudits=1, +freeSlots=…, contentionQueueDepth=…)` and recorded (rate-limited) in +`.worklog/downtime-coordination.log` via the shared no-dispatch decision log +(`reason: 'audit-host-saturated'`, with the poll's `freeSlots`/`totalSlots`/ +`contentionDepth`). The concurrent-audit count is `1` by construction (the +host-wide single-flight); the free-slot and contention figures distinguish +infrastructure saturation from a content problem. + +**Scope.** Single-machine v1 only — matches the machine-coordination contract +(`machine-coordination.ts`): multi-machine (flock/NFS) is out of scope and the +dir resolves per-user. The marker adds no read-modify-write contention on the +shared `downtime-coordination.json` (a dedicated file, read as a cheap +fast-path before the `wl` query). + +**Tests.** `machine-coordination.test.ts` pins the marker lifecycle +(write/read/remove, malformed/stale/absent fail-safe); `index.test.ts` +exercises the real `createDowntimeDeps` wiring (a second instance sees the +marker; `recordDispatch` writes it for audits only; `recordPaneClose` releases +it on audit completion); `downtime-worker.test.ts` pins the worker gate (a +host-wide active audit skips the tier, reports `audit-host-saturated`, and +falls through/deferred; a per-worklog active audit still reports +`audit-in-flight`; the observability line carries the concurrent-audit count +and slot state). + ### Producer-review gate (WL-0MTIAL65N004T22F) Every dispatch tier — audit, critical (including its dependency-frontier walk), implement, @@ -452,6 +504,14 @@ moment the queue thins. (`DEFAULT_BROWSE_ITEM_COUNT = 20`). **Fail-closed:** a count-query failure activates the gate (only critical implements remain eligible) — same convention as the code-freeze "ambiguous ⇒ frozen" rule. + +> **Child coverage is display-only (WL-0MUBVH8QG0020H9L):** a `completed`/`in_review` +> child never enters the audit tier — it is *covered* by its parent's fresh audit +> (rendered as a dimmed parent-audit symbol in the worklist). Coverage is derived at +> read time by the shared `isCoveredByParent`/`isAuditFresh` predicates; nothing is +> persisted and the root-only dispatch query is unchanged (plus a belt-and-suspenders +> client-side `parentId` exclusion in `selectAuditCandidate`). Durable reporting of +> uncovered children is out of scope here (WL-0MUBVH9FV0027COG). - **Shared gate implementation** (`readReviewQueueGate` / `isImplementHeldByReviewGate` in `downtime-worker.ts`): ONE bounded `wl list` per dispatch/offer computation, applied identically on every dispatch path — `dispatchFromHerdrList` (direct Herdr-head @@ -807,6 +867,47 @@ Consequences of the removal: - A settings file still carrying either removed key loads cleanly and the key is ignored. +### Pane-closure reaper (WL-0MUJL1NAH0042GOS) + +Dispatched panes should not linger once their agent has finished or died. +Two mechanisms were built; only the scheduled reaper is active, and it is +idempotent: + +- **Dispatch monitor (`pane-lifecycle.ts`, WL-0MU308WSF0002JWN) — DISABLED + (WL-0MUMEKDK0008LKH8).** It closed *dispatched* panes recorded in the + rolling dispatch log (`.worklog/downtime-dispatches.log`) once their item + reached a terminal or attention state, but was observed to close panes + prematurely (stage-propagation lag; `requires-attention` outcomes still + closing) and to race with the reaper (the two share only a non-atomic + idempotency key). The worker therefore no longer invokes it + (`PANE_LIFECYCLE_MONITOR_ENABLED` is `false`). The classifier and its unit + tests are retained pending a redesign — do not re-enable it without first + reworking the double-close race. +- **Scheduled pane-closure reaper — the only active auto-close path** — covers + panes the monitor never saw + (manually opened panes, and marker-less panes whose agent died). It runs + on the downtime-worker tick at most once per `PANE_CLOSE_REAPER_INTERVAL_MS` + (default 60 s), gated by the `paneCloseEnabled` setting (default on), with + a `paneCloseIdleThresholdMinutes` idle threshold (default 30, clamped + [1, 1440]). + +The reaper classifies each Herdr pane via the shared `classifySession()` +module (`packages/herdr/src/pane-close.ts`): close when the final assistant +message ends with ``, when the agent process is gone, or when +the agent is alive but idle beyond the threshold. It never closes an +`implement` pane, an item awaiting producer review, the invoking pane, or a +pane with live children. A close failure for one pane is recorded and the +run continues; a reaper throw is caught and logged so it can never crash the +worker. + +**Coexistence.** With Mechanism B disabled there is a single active closer, +so the previous non-atomic double-close race is gone. The reaper still skips +pane ids already recorded as handled in the rolling dispatch log, so any +historical monitor entries remain honoured. +Session-scoped child processes are torn down on close +(`packages/herdr/src/process-group.ts`), so spawned audit/plan runners are +not reparented to PID 1. + ### Migration & legacy retirement (F6 WL-0MTII4CWT00452HU, parent AC5) The machine dir `~/.herdr/downtime/` (or `HERDR_COORDINATION_DIR`) is @@ -842,6 +943,8 @@ status refresh unchanged at 30s.** | Follower check-in | 5 min (`DEFAULT_COORDINATION_CHECK_IN_MS`, WL-0MTMPSCL8000O45H) — non-leader re-offer | `downtime-worker.ts` | | No-candidate cooldown | 60 min (`downtimeNoCandidateCooldownMs`; probe-before-pause in coordination mode, re-offer cancels) | `downtime-worker.ts` | | Success-marker staleness window | **24 h** (`downtimeMarkerStaleWindowMs`; clamped to 1 h – 7 d; releases a stranded success marker at an unchanged stage, WL-0MU6UL0RJ008IHGT) | `DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS`, `clampDowntimeMarkerStaleWindowMs` (`downtime-worker.ts`) | +| Pane-closure reaper cadence | **60 s** (`PANE_CLOSE_REAPER_INTERVAL_MS`; gated by `paneCloseEnabled`, WL-0MUJL1NAH0042GOS) | `pane-close-scheduler.ts` | +| Pane-closure idle threshold | **30 min** (`paneCloseIdleThresholdMinutes`; clamped to 1 min – 24 h) | `pane-close-scheduler.ts` | | (removed) Max running downtime panes | **none** — no client-side pane cap; the LLM idle / free-slot check is the concurrency limiter (WL-0MU2EP6JL006A1U3) | `downtime-worker.ts` | Both dispatch-poll and idle-threshold are configurable in the herdr plugin @@ -856,34 +959,52 @@ load (see `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` in | Path | Purpose | |---|---| -| `packages/herdr/src/dispatcher-anchor.ts` | Dispatcher anchor provisioning (C0) — `getDispatcherAnchor` + per-prefix `getDispatcherTabAnchor` (C1), persistence + lock + aliveness | +| `packages/herdr/src/dispatcher-anchor.ts` | Project-workspace resolver (`resolveProjectWorkspace`) + item-ID tab anchor (`getItemTabAnchor`) + retained Dispatcher fallback (`getDispatcherAnchor`): CLI parsers, coordination lock, aliveness | | `packages/herdr/src/machine-coordination.ts` | Machine coordination dir resolver (`~/.herdr/downtime` / `HERDR_COORDINATION_DIR`) | | `packages/herdr/src/leader-election.ts` | Lock acquisition, lease management, re-election (machine dir) | | `packages/herdr/src/coordination.ts` | Coordination file read/write (entries, prune, upsert) — machine dir `downtime-coordination.json` | | `packages/herdr/src/downtime-worker.ts` | Worker tick: election, check-in, idle gate, dispatch (anchor-before-claim) | | `packages/herdr/src/downtime-log.ts` | Coordination/dispatch rolling logs (per worklog root, retained) | +| `packages/herdr/src/pane-close.ts` | Shared pane-closure classifier (`classifySession`, `extractFinalAssistantText`) consumed by the reaper and `pane-triage` (WL-0MUJL1NAH0042GOS) | +| `packages/herdr/src/pane-close-reaper.ts` | Closure reaper orchestration + CLI (`runReaper`, `runReaperCli`) | +| `packages/herdr/src/pane-close-scheduler.ts` | Periodic scheduling, settings clamps, enabled guard (`runScheduledPaneClose`) | +| `packages/herdr/src/pane-close-herdr.ts` | Production `ReaperDeps` over `herdr pane list` + pi session logs | +| `packages/herdr/src/process-group.ts` | Session-scoped child-process teardown (SIGTERM → grace → SIGKILL) | | `packages/herdr/shared/send-to-pi.sh` | `--anchor ` \u2192 `herdr pane split --pane ` (no `pane current` in anchor mode); forwards `--cwd`/`--model`/`AUDIT_PHASE2_PARALLELISM` | | `packages/herdr/shared/grid.py` | Grid rebalance around anchor pane | -| `~/.herdr/downtime/downtime-dispatch-anchor.json` | Persisted Dispatcher anchor `{ paneId, workspaceId }` (machine dir, C0) | -| `~/.herdr/downtime/downtime-dispatch-tab-anchors.json` | Persisted per-prefix tab map `{ workspaceId, byPrefix: { "": { tabId, paneId } } }` (machine dir, C1) | +| `~/.herdr/downtime/downtime-dispatch-anchor.json` | Persisted Dispatcher fallback anchor `{ paneId, workspaceId }` (machine dir; AC4 fallback + scheduled prompts only) | +| ~~`~/.herdr/downtime/downtime-dispatch-tab-anchors.json`~~ | **Retired** (per-prefix map). Item tabs are discovered via `herdr tab list`; a leftover file is no longer read. | | `~/.herdr/downtime/downtime-leader.lock` | Leader lock file (machine dir, `O_CREAT\|O_EXCL`) | | `~/.herdr/downtime/downtime-leader-lease.json` | Leader lease (5-min TTL, machine dir) | | `~/.herdr/downtime/downtime-coordination.json` | Shared coordination list (machine dir, one entry per instance) | | `~/.herdr/downtime/downtime-coordination.lock` | Coordination lock (machine dir, guards anchor provisioning + coordination writes) | +| `~/.herdr/downtime/active-audit` | Host-wide audit serialisation marker `{ instanceId, dispatchedAt }` (machine dir; written on audit dispatch, removed on audit-pane close, stale-released after 2 h — WL-0MUIVE0YG000UVIA) | | `/.worklog/downtime-leader.lock` | Legacy per-worklog lock (orphaned after F6, ignored) | | `/.worklog/downtime-coordination.log` | Check-ins, elections, pruning (per worklog, retained) | | `/.worklog/downtime-dispatches.log` | Dispatched items (per worklog, retained; includes `anchor-unavailable` neutral no-dispatch) | +| `/.worklog/pane-close-ledger.jsonl` | Pane-closure reaper ledger (one JSONL row per evaluated pane, per worklog, WL-0MUJL1NAH0042GOS) | ## Troubleshooting / operations -- **Pane landed in project workspace (e.g. Podcast `wR`) instead of Dispatcher:** the dispatcher invariant is anchor-by-ID — every automated pane must split the persisted anchor pane. Check (a) `~/.herdr/downtime/downtime-dispatch-anchor.json` exists and `herdr pane get ` is alive and `herdr workspace list` shows its `workspaceId` labelled `Dispatcher` (stale/missing → delete the file and let the next dispatch re-provision under the coordination lock); (b) the running herdr plugin loads this repo's code — `herdr plugin list` must show `worklog-selection-list` → `local:/…/packages/herdr` (stale `dist/` or a worktree link dangles the plugin; rebuild with `npm run build` in `packages/herdr` / re-link with `herdr plugin link /packages/herdr/herdr-plugin.toml`); (c) duplicate `Dispatcher` workspaces are harmless — the anchor file is the authority, not the label count — close surplus idle ones with `herdr workspace close ` (one at a time) without disturbing active `Downtime triggered …` panes. Incident RCA 2026-09-07: the two Podcast `wR` dispatches (`wR:p1X` CG-0MTR7DLMY 13:03:57, `wR:p1Y` WL-0MTOHS5B4001Y9FX 13:10:14) pre-dated the anchor wiring commit `69ae52f2` (14:45) and the local `packages/herdr/dist/` rebuild (15:17) — the live code at incident time had no anchor resolution path (F1 `893d6d22` added the module only, F2 wired it), so the leader fell back to the legacy `pane current` split. After the rebuild the anchor persisted at `~/.herdr/downtime/downtime-dispatch-anchor.json` (observed 15:57 `wZ:p1` → later `w0:p1`) and all subsequent downtime panes landed in `Dispatcher` (`wZ`/`w0`, including this work item's own dispatch in `wZ:p4`). -- **No `Downtime triggered …` pane but `anchor-unavailable` in logs:** the Dispatcher anchor could not be provisioned — `getDispatcherAnchor()` / `getDispatcherTabAnchor()` returned `null` (fail-closed, never a project-workspace fallback). Check `~/.herdr/downtime/` writability, coordination lock contention (`downtime-coordination.lock` held by another dispatch), the `herdr workspace create --label Dispatcher` / `herdr tab create --workspace --label --no-focus` JSON parse (shape drift across herdr versions), and `herdr pane get ` RPC health. The worker degrades to “no dispatch this cycle” and retries next idle tick; an empty/missing anchor file or unreadable machine dir is treated as missing (never a crash). -- **Pane landed in the wrong tab/workspace (e.g. a `TCE-…` pane in the `WL` tab, or default `1` instead of ``):** the per-prefix tab invariant is anchor-by-ID. Check (a) `~/.herdr/downtime/downtime-dispatch-tab-anchors.json` has an entry for the item's prefix whose `paneId` is alive (`herdr pane get `) and whose `tabId` still exists (`herdr tab get `); (b) `herdr tab list --workspace ` shows a tab labelled exactly `` (prefix is the id substring before the first `-`, case preserved — a `CG-…` item routes to `CG`, not `TCE`); (c) the running plugin loads this repo's code (`herdr plugin list` → `worklog-selection-list` → `local:/…/packages/herdr`; rebuild with `npm run build` in `packages/herdr` after pulling). To re-provision a prefix, close its tab (`herdr tab close `) and delete/edit its `byPrefix` entry — the next dispatch re-creates it under the coordination lock. Duplicate tabs are harmless but stale: the persisted map is the authority. +- **Pane landed in the project workspace but not under the item tab:** the item-ID tab invariant is anchor-by-ID — every automated pane must split the item tab's root pane. Check (a) `herdr tab list --workspace ` shows a tab labelled exactly the work-item id and `herdr pane get ` is alive; (b) the resolved project workspace is the one whose `Work Items` plugin pane has `HERDR_RESOLVED_CWD` equal to the item's worklog root (`herdr pane process-info --pane ` → `shell_pid` → `grep HERDR_RESOLVED_CWD /proc//environ`); (c) the running plugin loads this repo's code — `herdr plugin list` must show `worklog-selection-list` → `local:/…/packages/herdr` (stale `dist/` or a dangling worktree link breaks resolution; rebuild with `npm run build` in `packages/herdr`). To reset an item tab, close it (`herdr tab close `) — the next dispatch re-creates it under the coordination lock. +- **No `Downtime triggered …` pane but `anchor-unavailable` in logs:** neither the project workspace/item tab nor the Dispatcher fallback anchor could be provisioned (fail-closed, never the leader's pane). For the project path check the `pane list` / `process-info` / `/proc//environ` reads (no `Work Items` pane for the root, missing `HERDR_RESOLVED_CWD`, or unreadable `/proc` on non-Linux); for the fallback check `~/.herdr/downtime/` writability, coordination lock contention (`downtime-coordination.lock`), and the `herdr workspace create --label Dispatcher` / `herdr tab create --workspace --label --no-focus` JSON parse (shape drift across herdr versions). The worker degrades to “no dispatch this cycle” and retries next idle tick; an empty/missing anchor file or unreadable machine dir is treated as missing (never a crash). +- **Pane landed in the wrong project's workspace:** resolution is fail-closed — it never selects another root's workspace. Check (a) the item's `cwd` (worklog root) matches exactly the plugin pane's `HERDR_RESOLVED_CWD` (trailing-slash tolerant); (b) when ≥2 `Work Items` panes resolve to the same root the focused pane wins, else the lowest pane id — close the surplus plugin pane to remove the ambiguity; (c) stale `dist/` in the running plugin (rebuild with `npm run build` in `packages/herdr`). If no plugin pane resolves, panes intentionally fall back to the `Dispatcher` workspace — that is AC4, not a misroute. - **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. +- **Audits skipped with `audit-host-saturated` (WL-0MUIVE0YG000UVIA):** a + non-stale `~/.herdr/downtime/active-audit` marker means an audit is running + on this host — possibly dispatched by another project's dispatcher. This is + expected host-wide single-flight behaviour, not a failure: audits resume + when the running pane completes and `recordPaneClose` removes the marker, + or after the 2-hour stale window. Inspect the marker (`cat + ~/.herdr/downtime/active-audit`) and the decision log + (`grep audit-host-saturated /.worklog/downtime-coordination.log`). If + no audit is actually running, the marker is stale — wait for the window or + remove the file; set `HERDR_COORDINATION_DIR` when testing to avoid the live + machine dir. - **Diagnosing wl errors with per-strike logs (WL-0MTJPYM53003ORCV):** when the downtime worker encounters CLI errors, it now logs a **structured JSONL entry on every strike** (not just the third). To diagnose: @@ -1030,6 +1151,47 @@ fields and continue to work — they simply ignore them. - **Lease expiry after a crash:** the other instances detect the stale lease (TTL 5 min), clean up, and re-elect automatically. +## Recent Dispatches View (Herdr) + +The Herdr worklist exposes a **recent dispatches** view (`f d`, or +`/wl dispatches`) that projects the local rolling dispatch log into browsable +rows (WL-0MUGLL9SS002E1D2). It exists because the pane-closure reaper (see +[Pane-closure reaper](#pane-closure-reaper-wl-0mujl1nah0042gos)) now +auto-closes panes whose dispatched work reached a clean terminal state, so +completed work otherwise disappears from the active worklist without a durable +trace. + +- **Local-log scope only.** The view reads + `/.worklog/downtime-dispatches.log` for the **current worklog + root** — the same per-root file the dispatcher writes. It deliberately does + **not** aggregate across project roots; machine-wide cross-project reporting + is a separate effort (`/skill:dispatch-logs`, WL-0MTJQOZ0K007KD40). +- **Bounded window.** The log keeps only the most recent + `DOWNTIME_LOG_MAX_ENTRIES` (100) entries, so a very busy period can push an + older candidate out of the retained window; the view therefore shows at most + the 20 most recent items that are still present in the log. +- **Projection semantics.** Rows are deduplicated by work item id, and the + 20 most recently active items are selected; the visible list is then + ordered exactly like the main selection list (Critical → plan/intake → + Idea → In Review, with the same within-group comparator). Each row is + annotated with the dispatch `kind` and the latest pane-close `outcome` + where available. The id and title come from the log for items absent from + `wl`, so closed/deleted items still appear. +- **Same icons as the live views.** The log decides which items appear and + their order, but a row for an item that still exists in `wl` is rendered + from the **live work item** (the same data the other Herdr views use), so + its status/stage/audit/producer-review/priority icons are identical to those + views (WL-0MUGLL9SS002E1D2). Only items no longer present in `wl` fall back + to log-derived metadata: they render the dispatch marker's stage icon and + show `—` for fields the log does not carry. +- **Read-only & fail-safe.** The view never writes the log or mutates work + items, and a missing/unreadable/empty/malformed log renders an empty list + (0 items) with no crash. Selecting a row opens the detail view with a + best-effort `wl show ` fetch that falls back to the log-derived + metadata for closed/deleted items. +- **Single filter slot.** Dispatches replaces (and is replaced by) the + stage/priority filters; bare `/wl` clears all three axes (WL-0MSKC8T46006999S). + ## Related - Work item: **WL-0MST3OJ8S0001ROL** *Refactor Downtime Dispatcher: leader diff --git a/package.json b/package.json index 39c95fbbe..fbfd214c8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "1.0.16", + "version": "1.0.17", "description": "A simple experimental issue tracker for AI agents", "main": "dist/index.js", "type": "module", diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 062ba4786..79699b4c8 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -5,9 +5,9 @@ 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 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) +- **Filter by stage, priority, or recent dispatches** — Press `f` then an axis key (`s`=stage, `p`=priority, `d`=dispatches), then a value key, or type `/wl ` / `/wl --priority ` / `/wl dispatches`. Stage axis: `f s i`=idea, `f s n`=intake, `f s p`=plan, `f s r`=review, `f s s`=sprint back to the default view. Priority axis: `f p l`=low, `f p m`=medium, `f p h`=high, `f p c`=critical, `f p s`=clear the priority filter. Dispatches axis: `f d` shows the most recent downtime dispatches from the local rolling log (see [Recent dispatches view](#recent-dispatches-view)). Stage, priority, and dispatches filters are **mutually exclusive** (replace semantics — applying one clears the others); sprint (`f s s`, `f p s`, or bare `/wl`) clears all three. Filtered views show every root item matching the filter's rule (open items for most stages; `completed`/`in-progress`/`open` for the in_review stage; log-derived rows for the dispatches view) — no `browseItemCount` cap and no `wl next` selection omission (WL-0MSDT8X1V003206G, WL-0MSKCRX730052IIW, WL-0MSKC8T46006999S, WL-0MUGLL9SS002E1D2) - **View details** — Press Enter on any item to see its full details (description, acceptance criteria, metadata, tags, priority, GitHub issue number, and audit status information such as audit result, review status, and last audit timestamp) -- **Audit indicators** — The list view shows audit icons next to `in_review` items (✅ audited, ❌ failed, ❓ unaudited). The metadata section (list-mode panel and detail view) mirrors the list's icons with text labels — the selected item's Stage row uses the same audit-aware `in_review` icon (✅/❌/❓ fresh, ⏳ stale-passed, 🔍 otherwise), and the Audit/Reviewed rows pair their icons with text (e.g. `✅ ready to close`, `❌ needs review`). The detail view additionally shows the last audit timestamp. +- **Audit indicators** — The list view shows audit icons next to `in_review` items (✅ audited, ❌ failed, ❓ unaudited). The metadata section (list-mode panel and detail view) mirrors the list's icons with text labels — the selected item's Stage row uses the same audit-aware `in_review` icon (✅/❌/❓ fresh, ⏳ stale-passed, 🔍 otherwise), and the Audit/Reviewed rows pair their icons with text (e.g. `✅ ready to close`, `❌ needs review`). The detail view additionally shows the last audit timestamp. **Child coverage (WL-0MUBVH8QG0020H9L):** a `completed`/`in_review` child whose direct parent has a fresh audit is *covered* — its list row and metadata Stage row show the parent's audit-result symbol in a dimmed/grey style (visually distinct from a bright own-audit icon) and the metadata panel adds a `Covered by ` row. Coverage is **derived at read time** (`isCoveredByParent` over the parent's audit freshness inputs via the shared `isAuditFresh` predicate) — nothing is persisted and there is no schema migration. A child with its own audit always shows its own verdict; an uncovered child (no own audit, parent demoted/stale) keeps the plain `🔍` stage icon and is never dispatched (see [audit-tier selection](#audit-tier-dispatch)). In text-only (`noIcons`) mode the covered indicator renders as `[COVERED]`. - **Chord shortcuts** — Multi-key chord sequences provide quick actions like updating priorities, stage/status, title, closing/deleting items, running workflows, and toggling review status (configurable via `shortcuts.json`) - **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). @@ -96,7 +96,7 @@ 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 or priority using chord shortcuts (or type `/wl ` / `/wl --priority `): +3. Filter by stage, priority, or recent dispatches using chord shortcuts (or type `/wl ` / `/wl --priority ` / `/wl dispatches`): - Press `f`, `s`, then a stage key — Filter to a stage: - `f`, `s`, `i` — idea-stage items - `f`, `s`, `n` — intake_complete items @@ -109,7 +109,9 @@ The plugin pane will then be available via the Herdr plugin system. - `f`, `p`, `h` — high priority items - `f`, `p`, `c` — critical priority items - `f`, `p`, `s` — Clear the priority filter (return to the unfiltered browse list) - - 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**. + - Press `f`, `d` — Show the most recent downtime dispatches (see [Recent dispatches view](#recent-dispatches-view)) + - Stage, priority, and dispatches filters are **mutually exclusive**: applying one replaces whichever other axis was active (single filter slot, replace semantics). Sprint (`f s s`, `f p s`, or `/wl` with no arguments) clears **all** of them. + - Type `/wl dispatches` directly to activate the dispatches view (equivalent to `f d`). - `/wl ` accepts shorthand aliases (`idea`, `intake`, `plan`, `progress`, `review`) and canonical stage names (`intake_complete`, `plan_complete`, `in_progress`, `in_review`) - `/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 @@ -125,8 +127,9 @@ The plugin pane will then be available via the Herdr plugin system. - Press `s` — Insert a search command - Press `S` (Shift+s) — **Ship It**: run the dev→main release. A pre-dialog guard first checks that no other live agent pane is working on a project work item; if one is, a blocked notice lists the offending panes and nothing is dispatched. Otherwise a typed-confirmation dialog anchored to the bottom of the list (the list stays visible above it) asks you to type `ship` (case-insensitive) and press Enter to dispatch `/skill:ship release`; Esc cancels. The release is a global command — no work item id is involved. `S` is distinct from lowercase `s` (Search). See [Ship It confirmation dialog](#ship-it-confirmation-dialog). -5. Producer review shortcut: - - Press `r` — Toggle 'Needs Producer Review' flag and add a comment to the selected item +5. Review chords (press `r` then a key): + - Press `r`, `p` — Toggle 'Needs Producer Review' flag and add a comment to the selected item + - Press `r`, `i` — Walk through outstanding interview questions on the selected item (`wl interview`) 6. Priority update chords (press `u` then `p` then a priority key): - Press `u`, `p`, `l` — Set priority to low @@ -150,6 +153,57 @@ The plugin pane will then be available via the Herdr plugin system. 10. Quit: - Press `q` to close the worklist pane +### Recent dispatches view + +Press `f` then `d` (or type `/wl dispatches`) to switch the list into a +**recent dispatches** view. This view restores visibility of work the +downtime worker auto-dispatched after it completed, since those panes are +auto-closed and their items then disappear from the active worklist +(WL-0MUGLL9SS002E1D2). + +Semantics: + +- **Source of truth is the local log only** — rows are projected from + `/.worklog/downtime-dispatches.log`, not from `wl list`. + The id and title come from the log entry itself, so an item that has + since been **closed or deleted** still appears. The view never mutates the + log or any work item; it is display-only and spawns no `wl` processes + beyond the existing refresh (and the detail fetch below). +- **Up to 20 rows, main-list order** — rows are deduplicated by work item id + (one row per id) and the 20 most recently active items are selected; the + visible list is then ordered exactly like the main selection list + (Critical → plan/intake → Idea → In Review, using the same within-group + comparator), but without group headings. The log itself is bounded to the + most recent 100 entries (`DOWNTIME_LOG_MAX_ENTRIES`), so the 20 most recent + items normally fall inside the retained window. +- **Kind and outcome annotations** — where the log carries them, each row + also shows the dispatch `kind` (`plan`/`intake`/`audit`/`risk-effort`/ + `implement`) and the latest pane-close **outcome** (e.g. + `closed-as-plan-complete`, `audit-passed`, `requires-attention`). +- **Rendered from the live item** — the log decides which items appear and + their order, but when the item still exists in `wl` the row is rendered from + that live work item (the same data source the other views use), so every + icon — status, stage, audit verdict, producer-review flag and priority — is + identical to every other view (WL-0MUGLL9SS002E1D2). Only an item that is no + longer in `wl` (closed/deleted) falls back to log-derived metadata: it + renders the dispatch marker's stage icon and shows `—` for fields the log + does not carry. +- **Selectable** — pressing Enter on a row opens the detail view. The plugin + makes a best-effort `wl show ` fetch so a still-existing item shows + fresh metadata; a closed/deleted item gracefully falls back to the + log-derived metadata rather than showing a blank item. +- **Fail-safe empty state** — a missing, unreadable, empty, or malformed log + yields an empty list (header shows `0 item(s)`) with no crash and no error + banner. The view participates in the normal auto-refresh cadence, so a + newly appended log entry appears on the next refresh. +- **Single filter slot** — activating the dispatches view clears any active + stage/priority filter, and activating a stage/priority filter (or sprint / + bare `/wl`) clears the dispatches view. The header shows + `(filtered: dispatches)` and the actual row count. + +This view is **per-worklog-root**; machine-wide cross-project aggregation is +out of scope here and tracked separately (WL-0MTJQOZ0K007KD40). + ### Agent status icons Rows that have a worklist-spawned pi agent pane attached show the agent's live status as the first icon in the row's icon prefix: @@ -299,6 +353,21 @@ dispatches; the other herdr instances coordinate instead of polling: election degrades to the pre-refactor behavior for that instance (no dispatch from it); the existing dispatched-marker exclusion and CAS claim guards are preserved unchanged. +- **Pane placement (WL-0MU321YK70035AYT)** — automated downtime panes for a + work item spawn in the **project's own herdr workspace** — the workspace + hosting the `Work Items` plugin pane whose `HERDR_RESOLVED_CWD` equals the + item's worklog root — inside a **tab labelled with the exact work-item id**, + created on first use and reused thereafter. The pane is split from that + tab's root pane (`send-to-pi.sh --anchor `), so a second + dispatch for the same item adds a pane to the same tab. When no plugin pane + resolves for the root, the dispatcher falls back to the retained + machine-wide `Dispatcher` anchor + (`~/.herdr/downtime/downtime-dispatch-anchor.json`); scheduled prompts (no + work-item id) always use that anchor. Resolution is fail-closed (never + another project's workspace, never the leader's pane); the rejected + per-prefix tab routing (`downtime-dispatch-tab-anchors.json`) is retired. + See + [docs/dev/downtime-dispatcher.md](../../docs/dev/downtime-dispatcher.md#project-workspace-placement--item-id-tabs-wl-0mu321yk70035ayt). Coordination operations (check-ins, elections/takeovers, eligibility drops) are recorded in `.worklog/downtime-coordination.log` — a separate rolling log from the dispatch log, so the dispatch-marker readers never see @@ -343,9 +412,10 @@ returns `true`. Since WL-0MUBVH5S0008NQ9K freshness is **content-based**: when both fingerprints are available a match is fresh regardless of `updatedAt` churn (comment, sync-merge re-timestamp, re-sort), and a mismatch is stale. When no fingerprint is available (legacy audits, or a TUI render that cannot compute -the current fingerprint) the legacy rule applies — `auditedAt` within the 60 s -staleness buffer of `updatedAt`. Missing audit timestamps are treated as -not-fresh and therefore selected. +the current fingerprint) the legacy time gate applies — the audit is fresh iff +`auditedAt > updatedAt - AUDIT_FRESHNESS_AT_NEAR_TOLERANCE_MS` (60 s, +one-sided: an audit at or after `updatedAt` is fresh). 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.). @@ -467,7 +537,7 @@ log is treated as empty (fail-safe), so audit dispatch keeps working on a fresh worklog. Every dispatch tier — audit, critical, implement, plan, and intake — additionally excludes items with `needsProducerReview === true` (WL-0MTIAL65N004T22F): items flagged for producer review are never -auto-dispatched (the `r` shortcut / `wl update --needs-producer-review true`), +auto-dispatched (the `r p` chord / `wl update --needs-producer-review true`), so the worker never consumes local slots on items awaiting a human decision. Absent/false/undefined → dispatchable (`=== true` only). Clearing the flag makes the item dispatchable again on the next idle poll. @@ -561,6 +631,9 @@ makes the item dispatchable again on the next idle poll. > 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). +> As a belt-and-suspenders guard (WL-0MUBVH8QG0020H9L), `selectAuditCandidate` +> also drops any candidate carrying a `parentId`, so even a leaking or faulty +> non-root-only response can never dispatch a child independently. 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 @@ -656,6 +729,94 @@ 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. +### Pane-closure reaper (WL-0MUJL1NAH0042GOS) + +Agent panes launched in Herdr run `pi` in interactive mode. A skill that +finishes emits a `` marker in its final report, but nothing +consumed it, so completed panes (and their ~200 MB `pi` processes) lingered +indefinitely. The pane-closure reaper closes settled or abandoned panes +programmatically. + +**Shared classifier (`packages/herdr/src/pane-close.ts`).** A pure module +that both the reaper and the `pane-triage` skill consume, so the two +mechanisms cannot diverge: + +- `classifySession(sample, opts?) → { close, reasonCode, reasonSnapshot }` + — `reasonSnapshot` is a full state snapshot (kind, agent state, idle + duration, item stage, `needsProducerReview`, invoking-pane/live-children + flags, activity signals, grace-period status, idle threshold) captured for + every outcome so a close can be explained from the log alone. +- `extractFinalAssistantText(entries) → string` — the `.trimEnd()`-ed final + assistant text; marker detection requires `` at the very end + (a marker quoted mid-message does not match) +- `SessionSample` fields: `lastAssistantText`, `agentProcessAlive`, `idleMs`, + `kind`, `needsProducerReview`, `isInvokingPane`, `childProcessCount`, + `ageSinceDispatchMs` (optional; age since first dispatch), + `hasRecentFileModifications` / `hasActiveNetworkConnections` (optional; + active-agent signals) +- `DEFAULT_IDLE_THRESHOLD_MS = 30 min` + +**Decision order (first match wins):** + +1. Never-close guards — `implement` pane, `needsProducerReview`, invoking + pane, live children. +2. Within the grace window since first dispatch → **no close** + (`grace-period`); disabled when the grace period is `0` or the pane age is + unknown. +3. Marker at the end of the final assistant message → close (`marker`). +4. Agent process gone → **no close** (`dead-agent`) — operator may need to + read final output. +5. Recent file modifications or active network connections → **no close** + (`active`), even when idle exceeds the threshold. +6. Agent alive but idle beyond the threshold → close (`idle-threshold`); + disabled when threshold ≤ 0. +7. Otherwise → keep (`active`). + +**Reaper orchestration (`pane-close-reaper.ts`).** `runReaper(deps, options)` +scans panes, classifies each, closes eligible panes, and appends one JSONL +row per pane to the ledger (default `.worklog/pane-close-ledger.jsonl`); each +row carries the decision's `reasonSnapshot` alongside the decision and +timestamp. I/O is injected via `ReaperDeps` (`listPanes`, `closePane`, +`terminateProcessGroup`) so the orchestration is fully testable. +`runReaperCli(deps, argv)` is the CLI entrypoint and accepts `--dry-run`, +`--threshold-minutes ` and `--ledger `. + +**Scheduling (`pane-close-scheduler.ts`).** The downtime worker runs the +reaper on its tick at most once per `PANE_CLOSE_REAPER_INTERVAL_MS` +(default 60 s), gated by `paneCloseEnabled`. `runScheduledPaneClose` is a +complete no-op when disabled; a throw is caught and logged so the reaper can +never crash the worker. + +**Configuration** (`~/.config/herdr/worklog-plugin.json`): + +| Key | Default | Meaning | +|-----|---------|---------| +| `paneCloseEnabled` | `true` | Master on/off switch for automatic pane closure. | +| `paneCloseIdleThresholdMinutes` | `0` | Marker-less idle threshold in minutes; `0` = never close on idle (clamped [0, 1440]). | +| `paneCloseGracePeriodMinutes` | `5` | Minimum pane age in minutes since first dispatch before it is eligible for auto-close (clamped [1, 1440]). Gives the agent time to start working. | + +**Child processes (`process-group.ts`).** On close, the pane's session-scoped +process group is torn down (SIGTERM → grace → SIGKILL) so spawned children +(audit/plan runners) are never reparented to PID 1. + +**Interaction with the dispatch monitor (`pane-lifecycle.ts`) — DISABLED +(WL-0MUMEKDK0008LKH8).** The dispatch monitor (`WL-0MU308WSF0002JWN`) used to +close *dispatched* panes recorded in `.worklog/downtime-dispatches.log` and +already excluded `implement` panes. It was observed to close panes prematurely +and to race with the reaper — the two share only a non-atomic idempotency key, +so a pane could be closed twice — so it is switched off +(`PANE_LIFECYCLE_MONITOR_ENABLED` is `false`) and the scheduled reaper is the +only active auto-close path. The classifier and its unit tests are retained +for a future redesign; do not re-enable it without first reworking the +double-close race. The reaper still skips pane ids already recorded as handled +in the dispatch log, so any historical monitor entries remain honoured. +Headless (JSON/RPC) modes are unaffected — closure is only performed by the +scheduled reaper over Herdr panes. + +**`pane-triage` skill reuse.** The approval-gated `pane-triage` skill +(`WL-0MUJMXVPO0016DZM`) consumes `classifySession` / `extractFinalAssistantText` +from this module rather than re-deriving idle state from the session log. + ### Mode-switch worker (activity-gated proxy mode switching, WL-0MSN3FWV5008KQE9) The mode-switch worker automatically switches the llama-proxy between @@ -1133,6 +1294,16 @@ 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 **dispatches** view (press `f` + `d`, or `/wl dispatches`) is the third +filter axis. It uses the local rolling dispatch log to choose up to 20 +read-only rows (deduplicated by work item id) and renders each surviving item +from the live work item so its icons match the other views, ordered like the +main selection list; an item no longer in `wl` falls back to the log-derived +id/title so closed/deleted items still appear (WL-0MUGLL9SS002E1D2). It is +mutually exclusive with the stage and priority filters (the header shows +`(filtered: dispatches)`), and a missing/malformed log renders an empty list +rather than an error. See [Recent dispatches view](#recent-dispatches-view). + The default (unfiltered) worklist is unaffected — `/wl` with no stage/priority argument keeps the smart-selection behaviour described above. @@ -1253,7 +1424,8 @@ safety net for unknown/custom stages. | 5 | passed audit, stale | | 6 | passed audit, fresh | -A "fresh" audit has `auditedAt > updatedAt - 60 s`; otherwise it is stale. +A "fresh" audit satisfies `auditedAt > updatedAt - 60 s` (one-sided, via the +shared `isAuditFresh` predicate); otherwise it is stale. Within the same bucket items are ordered by priority (high → medium → low), then by `updatedAt` (older first), then by `id` as a tie-break. The same predicate is shared by the Herdr worklist, `wl next --groups`, and @@ -1604,7 +1776,7 @@ Semantics: and `i` (implement) carry `work_item_types: ["bug","docs","feature", "task","chore","epic"]`, so they are hidden on non-code and non-docs types (e.g. `podcast`). All other bundled shortcuts (audit `a-*`, producer review - `r`, housekeeping `u-*`/`x-*`/`c`/`s`/`P-*`/`f-*`) remain untyped and are + `r-p`/`r-i`, housekeeping `u-*`/`x-*`/`c`/`s`/`P-*`/`f-*`) remain untyped and are available on all types. Consumer projects can add their own type-gated chords (e.g. a `w` chord leader → `wiki-podcast-script` for `podcast` items) via the project-local `shortcuts.json` mechanism above. diff --git a/packages/herdr/src/audit-freshness-agreement.test.ts b/packages/herdr/src/audit-freshness-agreement.test.ts new file mode 100644 index 000000000..d8f9519c4 --- /dev/null +++ b/packages/herdr/src/audit-freshness-agreement.test.ts @@ -0,0 +1,89 @@ +/** + * packages/herdr/src/audit-freshness-agreement.test.ts + * + * Cross-consumer agreement for audit freshness (WL-0MUBVH7ZR009PP80 AC4). + * + * `isAuditFresh` in `@worklog/shared/icons` is the single freshness + * definition. This suite pins that the icon path (`stageDisplayIcon`), the + * downtime-dispatch path (`selectAuditCandidate`) and the `in_review` + * ordering path (`inReviewBucket`) all reach the same verdict as + * `isAuditFresh` for fresh / just-persisted / stale / missing-audit inputs — + * including the one-sided boundary (audit before vs after `updatedAt`). + */ + +import { describe, it, expect } from 'vitest'; +import { isAuditFresh, stageDisplayIcon } from '@worklog/shared/icons'; +import { inReviewBucket } from './grouping.js'; +import { selectAuditCandidate, type AuditCandidate } from './downtime-worker.js'; + +const BASE = Date.parse('2026-01-10T10:00:00.000Z'); +const at = (offsetMs: number): string => new Date(BASE + offsetMs).toISOString(); +// Within the dispatch 7-day recency window so selection is governed by +// freshness only. +const NOW = BASE + 1_000; + +interface Fixture { + name: string; + auditedAt: string | null; + updatedAt: string; + fresh: boolean; + /** `inReviewBucket` (failed audit) expected bucket. */ + bucket: 2 | 3 | 4; + /** `stageDisplayIcon` glyph: ❌ = fresh failed audit, 🔍 = stale/no audit. */ + icon: string; +} + +const AUDIT_NOT_READY = '\u{274C}'; // ❌ +const IN_REVIEW_STAGE = '\u{1F50D}'; // 🔍 + +const FIXTURES: Fixture[] = [ + { name: 'just-persisted (auditedAt === updatedAt)', auditedAt: at(0), updatedAt: at(0), fresh: true, bucket: 2, icon: AUDIT_NOT_READY }, + { name: 'audit 30 s after updatedAt', auditedAt: at(30_000), updatedAt: at(0), fresh: true, bucket: 2, icon: AUDIT_NOT_READY }, + { name: 'audit 30 s before updatedAt', auditedAt: at(-30_000), updatedAt: at(0), fresh: true, bucket: 2, icon: AUDIT_NOT_READY }, + { name: 'audit 120 s before updatedAt (stale)', auditedAt: at(-120_000), updatedAt: at(0), fresh: false, bucket: 3, icon: IN_REVIEW_STAGE }, + { name: 'audit 120 s after updatedAt (fresh, one-sided)', auditedAt: at(120_000), updatedAt: at(0), fresh: true, bucket: 2, icon: AUDIT_NOT_READY }, + { name: 'missing audit', auditedAt: null, updatedAt: at(0), fresh: false, bucket: 4, icon: IN_REVIEW_STAGE }, +]; + +describe('audit-freshness agreement across icon / dispatch / ordering consumers (WL-0MUBVH7ZR009PP80)', () => { + it.each(FIXTURES)('$name → isAuditFresh agrees with each consumer', (fixture) => { + // 1. Canonical predicate. + expect(isAuditFresh(fixture.auditedAt, fixture.updatedAt)).toBe(fixture.fresh); + + // 2. Icon path. + expect( + stageDisplayIcon({ + stage: 'in_review', + auditResult: false, + auditedAt: fixture.auditedAt, + updatedAt: fixture.updatedAt, + }), + ).toBe(fixture.icon); + + // 3. in_review ordering path. + expect( + inReviewBucket({ + stage: 'in_review', + auditResult: false, + auditedAt: fixture.auditedAt, + updatedAt: fixture.updatedAt, + }), + ).toBe(fixture.bucket); + + // 4. Downtime-dispatch path: a fresh audit is never selected for + // re-dispatch; a stale/missing one is. + const candidate: AuditCandidate = { + id: 'WL-AGREE', + title: fixture.name, + auditedAt: fixture.auditedAt, + updatedAt: fixture.updatedAt, + sortIndex: 0, + }; + const selected = selectAuditCandidate([candidate], NOW); + if (fixture.fresh) { + expect(selected).toBeNull(); + } else { + expect(selected?.id).toBe('WL-AGREE'); + } + }); +}); diff --git a/packages/herdr/src/dispatch-view.test.ts b/packages/herdr/src/dispatch-view.test.ts new file mode 100644 index 000000000..7c4149eb9 --- /dev/null +++ b/packages/herdr/src/dispatch-view.test.ts @@ -0,0 +1,252 @@ +/** + * Unit tests for the synthetic "recent dispatch" row builder + * (WL-0MUL2IY8L009S3PQ, parent WL-0MUGLL9SS002E1D2). + * + * The projection in `downtime-log.ts` yields `RecentDispatchRow`s; this slice + * converts each into a synthetic `WorkItem` that renders through the existing + * list line formatter and metadata panel. Synthetic rows are marked + * `isLogDerived` so absent live fields degrade to `—` rather than crashing or + * blanking the panel. + */ + +import { describe, it, expect } from 'vitest'; +import { stageIcon, auditIcon } from '@worklog/shared/icons'; +import { buildDispatchWorkItem, mergeDispatchRow } from './dispatch-view.js'; +import type { RecentDispatchRow } from './downtime-log.js'; +import type { WorkItem } from './fetcher.js'; +import { formatItemLine, formatMetadataPanel, buildMetaRows } from './worklist.js'; + +const baseRow: RecentDispatchRow = { + itemId: 'WL-DISP1', + title: 'Dispatched item', + kind: 'plan', + latestOutcome: 'closed-as-plan-complete', + latestTimestamp: '2026-01-02T00:00:00.000Z', +}; + +/** Strip ANSI escapes so assertions are readable. */ +function visible(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ''); +} + +describe('buildDispatchWorkItem (synthetic log-derived row)', () => { + it('carries the log-sourced id and title (never a live wl value)', () => { + const item = buildDispatchWorkItem(baseRow); + expect(item.id).toBe('WL-DISP1'); + expect(item.title).toBe('Dispatched item'); + }); + + it('marks the row log-derived and exposes the dispatch metadata', () => { + const item = buildDispatchWorkItem(baseRow); + expect(item.isLogDerived).toBe(true); + expect(item.isLogOnly).toBe(true); + expect(item.dispatchKind).toBe('plan'); + expect(item.dispatchOutcome).toBe('closed-as-plan-complete'); + expect(item.dispatchedAt).toBe('2026-01-02T00:00:00.000Z'); + }); + + it('leaves live-only fields unset so the panel can degrade them', () => { + const item = buildDispatchWorkItem(baseRow); + expect(item.priority).toBeUndefined(); + expect(item.risk).toBeUndefined(); + expect(item.effort).toBeUndefined(); + expect(item.auditResult).toBeUndefined(); + }); + + it('derives a status from the latest outcome', () => { + expect(buildDispatchWorkItem({ ...baseRow, latestOutcome: undefined }).status).toBe('in_progress'); + expect(buildDispatchWorkItem({ ...baseRow, latestOutcome: 'requires-attention' }).status).toBe('blocked'); + expect(buildDispatchWorkItem({ ...baseRow, latestOutcome: 'audit-passed' }).status).toBe('completed'); + }); + + it('tolerates a row with no kind, outcome or timestamp', () => { + const item = buildDispatchWorkItem({ itemId: 'WL-MIN', title: 'Minimal' }); + expect(item.id).toBe('WL-MIN'); + expect(item.dispatchKind).toBeUndefined(); + expect(item.dispatchOutcome).toBeUndefined(); + expect(item.dispatchedAt).toBeUndefined(); + expect(item.status).toBe('in_progress'); + }); +}); + +describe('formatItemLine — synthetic dispatch row (WL-0MUL2IY8L009S3PQ)', () => { + it('renders the id and title without throwing', () => { + const line = formatItemLine(buildDispatchWorkItem(baseRow), 120); + expect(visible(line)).toContain('WL-DISP1'); + expect(visible(line)).toContain('Dispatched item'); + }); + + it('shows the dispatch kind and latest outcome', () => { + const line = visible(formatItemLine(buildDispatchWorkItem(baseRow), 160)); + expect(line).toContain('[plan]'); + expect(line).toContain('closed-as-plan-complete'); + }); + + it('does not annotate a normal (non-log-derived) item', () => { + const line = visible( + formatItemLine({ id: 'WL-LIVE', title: 'Live item', status: 'open' }, 160), + ); + expect(line).toContain('WL-LIVE'); + expect(line).not.toContain('[plan]'); + }); +}); + +describe('formatMetadataPanel — absent live fields degrade to — (WL-0MUL2IY8L009S3PQ)', () => { + it('renders — for priority, risk, effort and audit state', () => { + const joined = visible(formatMetadataPanel(buildDispatchWorkItem(baseRow), 80, 30, 0).join('\n')); + expect(joined).toMatch(/Priority\s+—/); + expect(joined).toMatch(/Risk\s+—/); + expect(joined).toMatch(/Effort\s+—/); + expect(joined).toMatch(/Audit\s+—/); + }); + + it('shows the dispatch kind, outcome and timestamp in the panel', () => { + const joined = visible(formatMetadataPanel(buildDispatchWorkItem(baseRow), 100, 30, 0).join('\n')); + expect(joined).toContain('Dispatch'); + expect(joined).toContain('plan'); + expect(joined).toContain('Outcome'); + expect(joined).toContain('closed-as-plan-complete'); + expect(joined).toContain('Dispatched'); + }); + + it('does not crash and still renders the id/title for a minimal synthetic row', () => { + const lines = formatMetadataPanel(buildDispatchWorkItem({ itemId: 'WL-MIN', title: '[unknown]' }), 80, 30, 0); + const joined = visible(lines.join('\n')); + expect(joined).toContain('WL-MIN'); + expect(joined).toContain('[unknown]'); + expect(lines.length).toBe(30); + }); + + it('leaves live items with a genuinely absent field omitted (no — regression)', () => { + const rows = buildMetaRows({ id: 'WL-LIVE', title: 'Live', status: 'open' }); + const labels = rows.map(([l]) => l); + expect(labels).not.toContain('Priority'); + expect(labels).not.toContain('Risk'); + expect(labels).not.toContain('Effort'); + }); +}); + +// ── Icon consistency with every other view (WL-0MUGLL9SS002E1D2 audit fix) ── +// A manual review rejected the first implementation because the dispatches view +// rendered the ❓ unknown-stage glyph where every other view shows a real +// stage/audit icon. The synthetic row now carries the log-derived stage (and +// any audit verdict) so the shared icon helpers produce identical output. +describe('buildDispatchWorkItem — stage/audit icons match other views', () => { + it('carries the log-derived stage onto the synthetic item', () => { + const item = buildDispatchWorkItem({ ...baseRow, stage: 'plan_complete' }); + expect(item.stage).toBe('plan_complete'); + }); + + it('renders a real stage icon in the list line, never the ❓ unknown fallback', () => { + const line = visible(formatItemLine(buildDispatchWorkItem({ ...baseRow, stage: 'plan_complete' }), 160)); + expect(line).toContain(stageIcon('plan_complete')); + expect(line).not.toContain('\u{2753}'); + }); + + it('renders the fresh audit verdict icon for an audit outcome (like a live in_review audit)', () => { + const passed = visible( + formatItemLine( + buildDispatchWorkItem({ ...baseRow, stage: 'in_review', auditResult: true, latestOutcome: 'audit-passed' }), + 160, + ), + ); + expect(passed).toContain(auditIcon(true)); + + const failed = visible( + formatItemLine( + buildDispatchWorkItem({ ...baseRow, stage: 'in_review', auditResult: false, latestOutcome: 'audit-failed' }), + 160, + ), + ); + expect(failed).toContain(auditIcon(false)); + }); + + it('shows the stage icon in the metadata Stage row', () => { + const rows = buildMetaRows(buildDispatchWorkItem({ ...baseRow, stage: 'plan_complete' })); + const stageRow = rows.find(([label]) => label === 'Stage'); + expect(stageRow?.[1]).toBe(`${stageIcon('plan_complete')} plan_complete`); + }); + + it('shows the audit verdict in the metadata Audit row for an audit outcome', () => { + const rows = buildMetaRows( + buildDispatchWorkItem({ ...baseRow, stage: 'in_review', auditResult: true, latestOutcome: 'audit-passed' }), + ); + const auditRow = rows.find(([label]) => label === 'Audit'); + expect(auditRow?.[1]).toContain(auditIcon(true)); + expect(auditRow?.[1]).toContain('ready to close'); + }); + + it('still shows — for the Audit row when the log carries no verdict', () => { + const rows = buildMetaRows(buildDispatchWorkItem({ ...baseRow, stage: 'plan_complete' })); + const auditRow = rows.find(([label]) => label === 'Audit'); + expect(auditRow?.[1]).toBe('—'); + }); +}); + +// ── Surviving items are rendered from the LIVE work item ───────────────────── +// The first cut built a bespoke synthetic row for every dispatch, so the row +// icons diverged from every other view (status/review/priority). A surviving +// item is now rendered from the live `WorkItem` with only the dispatch +// provenance overlaid (WL-0MUGLL9SS002E1D2 audit fix). +describe('mergeDispatchRow — surviving items render exactly like live rows', () => { + const live: WorkItem = { + id: 'WL-DISP1', + title: 'Live title', + status: 'completed', + stage: 'in_review', + priority: 'high', + risk: 'Medium', + effort: 'Small', + needsProducerReview: false, + auditResult: true, + auditedAt: '2026-01-03T00:00:00.000Z', + updatedAt: '2026-01-03T00:00:00.000Z', + }; + + it('keeps every live field so the icons match other views', () => { + const merged = mergeDispatchRow(live, baseRow); + expect(merged.title).toBe('Live title'); + expect(merged.status).toBe('completed'); + expect(merged.stage).toBe('in_review'); + expect(merged.priority).toBe('high'); + expect(merged.risk).toBe('Medium'); + expect(merged.effort).toBe('Small'); + expect(merged.needsProducerReview).toBe(false); + expect(merged.auditResult).toBe(true); + }); + + it('overlays the dispatch provenance and marks the row log-derived but not log-only', () => { + const merged = mergeDispatchRow(live, baseRow); + expect(merged.isLogDerived).toBe(true); + expect(merged.isLogOnly).toBeFalsy(); + expect(merged.dispatchKind).toBe('plan'); + expect(merged.dispatchOutcome).toBe('closed-as-plan-complete'); + expect(merged.dispatchedAt).toBe('2026-01-02T00:00:00.000Z'); + }); + + it('renders the identical live icon prefix, adding only the dispatch tag', () => { + const merged = mergeDispatchRow(live, baseRow); + const liveLine = visible(formatItemLine(live, 200)); + const mergedLine = visible(formatItemLine(merged, 200)); + const prefixOf = (line: string): string => line.slice(0, line.indexOf('WL-DISP1')); + expect(prefixOf(mergedLine)).toBe(prefixOf(liveLine)); + expect(mergedLine).toContain('[plan]'); + }); + + it('strips the live browse grouping so the view stays a flat newest-first list', () => { + const grouped: WorkItem = { ...live, group: 2, groupLabel: 'In Review' }; + const merged = mergeDispatchRow(grouped, baseRow); + expect(merged.group).toBeUndefined(); + expect(merged.groupLabel).toBeUndefined(); + }); + + it('renders live metadata instead of — (only the provenance rows are added)', () => { + const joined = visible(formatMetadataPanel(mergeDispatchRow(live, baseRow), 100, 30, 0).join('\n')); + expect(joined).toMatch(/Priority\s+.*high/); + expect(joined).toMatch(/Risk\s+.*Medium/); + expect(joined).toMatch(/Effort\s+.*Small/); + expect(joined).not.toMatch(/Priority\s+—/); + expect(joined).toContain('Dispatch'); + expect(joined).toContain('closed-as-plan-complete'); + }); +}); diff --git a/packages/herdr/src/dispatch-view.ts b/packages/herdr/src/dispatch-view.ts new file mode 100644 index 000000000..a6b1e1337 --- /dev/null +++ b/packages/herdr/src/dispatch-view.ts @@ -0,0 +1,106 @@ +/** + * Synthetic "recent dispatch" rows for the Herdr worklist + * (WL-0MUL2IY8L009S3PQ, parent WL-0MUGLL9SS002E1D2). + * + * The downtime worker now auto-closes panes whose dispatched work reached a + * clean terminal state, so completed work disappears from the active + * worklist. The `recentDispatchedItems` projection in `downtime-log.ts` + * recovers the raw log data; this module converts each projected row into a + * synthetic {@link WorkItem} so it can render through the existing list line + * formatter and metadata panel without any live `wl` fetch. + * + * Synthetic rows are DISPLAY-ONLY: they are never written back to `wl` and are + * marked with `isLogDerived: true` so the renderer can annotate the dispatch + * kind/outcome. When the work item still exists in `wl` the row is instead + * built by {@link mergeDispatchRow}, which renders the LIVE item (so status/ + * stage/audit/review/priority icons are byte-for-byte the same as every other + * view) and only adds the dispatch annotations; {@link buildDispatchWorkItem} + * is the fallback for an item that is now closed/deleted (WL-0MUGLL9SS002E1D2 + * audit fix). + */ + +import type { WorkItem } from './fetcher.js'; +import type { RecentDispatchRow } from './downtime-log.js'; + +/** + * Build a synthetic {@link WorkItem} from one projection row. Used ONLY when + * the item is absent from `wl` (closed/deleted): the id and title come from the + * log so the item still appears, and live-only fields (priority, risk, effort, + * audit state, parent, timestamps) are left unset — the renderer degrades them + * to `—` because `isLogOnly` is set. When the item still exists, + * {@link mergeDispatchRow} is preferred so the row renders exactly like every + * other view. + * + * The dispatch metadata is carried on `dispatchKind`/`dispatchOutcome`/ + * `dispatchedAt` for the renderer and metadata panel, and mirrored into the + * description so the detail fallback has something to show even when the item + * no longer exists in `wl` (parent AC5). + */ +export function buildDispatchWorkItem(row: RecentDispatchRow): WorkItem { + const outcome = row.latestOutcome; + // Derive a human-meaningful status from the pane-close outcome: flagged + // panes read as blocked, clean terminal closes as completed, and a dispatch + // with no close event yet as still in progress. + const status = + outcome === 'requires-attention' ? 'blocked' : outcome ? 'completed' : 'in_progress'; + + // Render the SAME stage/audit icons as a live row (WL-0MUGLL9SS002E1D2 audit + // fix): carry the log-derived stage so the list prefix and metadata Stage row + // show a real stage glyph (📥/📋/🔍/…) instead of the ❓ unknown fallback. + // For an audit outcome, the pane-close timestamp is both the audited-at and + // the last-content-change time (nothing later in the log), so the shared + // `stageDisplayIcon` resolves a fresh ✅/❌ verdict exactly as a freshly + // audited `in_review` live row would. + const auditTimestamps = + row.auditResult !== undefined && row.latestTimestamp !== undefined + ? { auditedAt: row.latestTimestamp, updatedAt: row.latestTimestamp } + : {}; + + const description = [ + 'Log-derived dispatch row — projected from `.worklog/downtime-dispatches.log`, not a live work item.', + '', + `- Dispatch kind: ${row.kind ?? '—'}`, + `- Latest outcome: ${outcome ?? '—'}`, + `- Latest activity: ${row.latestTimestamp ?? '—'}`, + ].join('\n'); + + return { + id: row.itemId, + title: row.title, + status, + stage: row.stage, + auditResult: row.auditResult, + ...auditTimestamps, + isLogDerived: true, + // No live item available — the renderer shows `—` for absent fields. + isLogOnly: true, + dispatchKind: row.kind, + dispatchOutcome: outcome, + dispatchedAt: row.latestTimestamp, + description, + }; +} + +/** + * A log-derived row for a work item that STILL EXISTS in `wl`, rendered from + * the LIVE item so the list row and metadata panel show the exact same status/ + * stage/audit/review/priority icons as every other view (WL-0MUGLL9SS002E1D2 + * audit fix). Only the dispatch provenance (`dispatchKind`/`dispatchOutcome`/ + * `dispatchedAt` + `isLogDerived`) is overlaid. `isLogOnly` is deliberately + * NOT set, so {@link buildMetaRows} renders the live fields normally rather + * than degrading them to `—`. + */ +export function mergeDispatchRow(live: WorkItem, row: RecentDispatchRow): WorkItem { + const merged: WorkItem = { ...live }; + // Strip the live browse list's grouping so the dispatches view stays a flat + // list; ordering is applied by the caller (fetchItemsForView) using the + // canonical main-list comparator. Interleaving live groups here would emit + // repeated headings (getDisplayRows emits one whenever `group` changes). + delete merged.group; + delete merged.groupLabel; + merged.isLogDerived = true; + merged.dispatchKind = row.kind; + merged.dispatchOutcome = row.latestOutcome; + merged.dispatchedAt = row.latestTimestamp; + return merged; +} diff --git a/packages/herdr/src/dispatcher-anchor.test.ts b/packages/herdr/src/dispatcher-anchor.test.ts index 75fd4a953..dcf7107b0 100644 --- a/packages/herdr/src/dispatcher-anchor.test.ts +++ b/packages/herdr/src/dispatcher-anchor.test.ts @@ -1,10 +1,13 @@ /** - * packages/herdr/src/dispatcher-anchor.test.ts — Dispatcher anchor provisioning (F1 WL-0MTR2CD4X006XI7U) + * packages/herdr/src/dispatcher-anchor.test.ts — Dispatcher anchors and + * project-workspace + item-ID tab resolution + * (WL-0MU321YK70035AYT, superseding the retired C1 per-prefix routing). * - * ACs: persistence / idempotent provisioning / concurrent safety / stale-pane - * re-provision / no-op when valid / fail-safe null. - * - * Parent: WL-0MTRQT482001SNXC (per-prefix tabs). TC1: types + persistence helpers. + * Coverage: + * - C0 `getDispatcherAnchor` provisioning (retained as the AC4 fallback). + * - machine-wide / process-info / proc-environ parsers. + * - `resolveProjectWorkspace` — project root → plugin workspace (AC1/AC3). + * - `getItemTabAnchor` — create-or-reuse the exact item-ID tab (AC2/AC5). */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; @@ -13,21 +16,24 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { getDispatcherAnchor, - getDispatcherTabAnchor, + getItemTabAnchor, + resolveProjectWorkspace, + createDispatcherAnchorDeps, DISPATCHER_ANCHOR_FILE, - DISPATCHER_TAB_ANCHOR_FILE, DISPATCHER_WORKSPACE_LABEL, + DISPATCHER_ROOT_TAB_LABEL, + PLUGIN_PANE_LABEL, type DispatcherAnchor, type DispatcherAnchorDeps, - type DispatcherTabAnchor, - type DispatcherTabAnchorEntry, type DispatcherTabInfo, type DispatcherPaneInfo, - readTabAnchors, - writeTabAnchors, + type MachinePaneInfo, parseTabListOutput, parseTabCreateOutput, parsePaneListOutput, + parseMachinePaneListOutput, + parsePaneProcessInfoOutput, + parseProcEnviron, } from './dispatcher-anchor.js'; function mkTmp(): string { @@ -64,18 +70,7 @@ function writePersisted(a: DispatcherAnchor): void { fs.writeFileSync(path.join(tmpDir, DISPATCHER_ANCHOR_FILE), JSON.stringify(a), 'utf-8'); } -function readTabAnchorPersisted(): DispatcherTabAnchor | null { - try { - const raw = fs.readFileSync(path.join(tmpDir, DISPATCHER_TAB_ANCHOR_FILE), 'utf-8'); - return JSON.parse(raw) as DispatcherTabAnchor; - } catch { return null; } -} - -function writeTabAnchorPersisted(a: DispatcherTabAnchor): void { - fs.writeFileSync(path.join(tmpDir, DISPATCHER_TAB_ANCHOR_FILE), JSON.stringify(a), 'utf-8'); -} - -describe('getDispatcherAnchor ACs', () => { +describe('getDispatcherAnchor ACs (C0 fallback, retained)', () => { it('AC2 idempotent provisioning: first call creates workspace and persists', async () => { const deps: DispatcherAnchorDeps = { createWorkspace: vi.fn(async () => ({ workspaceId: 'wD', paneId: 'wD:p1' })), @@ -111,7 +106,6 @@ describe('getDispatcherAnchor ACs', () => { }); it('AC3 concurrent safety: second caller sees persisted value when lock is held', async () => { - // First call provisions; second call is simulated by pre-holding the lock file. const lockPath = path.join(tmpDir, 'downtime-coordination.lock'); fs.writeFileSync(lockPath, ''); writePersisted({ paneId: 'wD:p1', workspaceId: 'wD' }); @@ -122,7 +116,6 @@ describe('getDispatcherAnchor ACs', () => { const got = await getDispatcherAnchor(tmpDir, deps); expect(got).toEqual({ paneId: 'wD:p1', workspaceId: 'wD' }); expect(deps.createWorkspace).not.toHaveBeenCalled(); - // cleanup lock for other tests (afterEach removes dir, but be tidy) try { fs.unlinkSync(lockPath); } catch { /* ignore */ } }); @@ -146,7 +139,6 @@ describe('getDispatcherAnchor ACs', () => { }); it('returns null when machine dir is unresolvable (empty HERDR_COORDINATION_DIR + ensure fails)', async () => { - // Simulate an unresolvable / uncreatable machine dir by making the dir a file. const fileAsDir = path.join(tmpDir, 'not-a-dir'); fs.writeFileSync(fileAsDir, 'x'); process.env.HERDR_COORDINATION_DIR = fileAsDir; @@ -157,350 +149,479 @@ describe('getDispatcherAnchor ACs', () => { const got = await getDispatcherAnchor(tmpDir, deps); expect(got).toBeNull(); }); -}); -// ── Per-prefix tab anchor tests (TC1: WL-0MU2LC5S00024LL6) ────────────── + // ── Root-pane adoption (WL-0MU2EOHK900425VU) ────────────────────────── -describe('readTabAnchors / writeTabAnchors TC1', () => { - it('TC1-1 readTabAnchors returns empty map when file absent', () => { - const result = readTabAnchors(tmpDir); - expect(result).toEqual({ workspaceId: '', byPrefix: {} }); + it('AC1 root-pane adoption: first provision moves the root pane into a labelled tab', async () => { + const movePaneToNewTab = vi.fn(async () => true); + const deps: DispatcherAnchorDeps = { + createWorkspace: vi.fn(async () => ({ workspaceId: 'wD', paneId: 'wD:p1' })), + isPaneAlive: vi.fn(async () => true), + movePaneToNewTab, + }; + const got = await getDispatcherAnchor(tmpDir, deps); + expect(got).toEqual({ paneId: 'wD:p1', workspaceId: 'wD' }); + expect(DISPATCHER_ROOT_TAB_LABEL).toBe('Downtime'); + expect(movePaneToNewTab).toHaveBeenCalledTimes(1); + expect(movePaneToNewTab).toHaveBeenCalledWith('wD:p1', DISPATCHER_ROOT_TAB_LABEL); + }); + + it('AC2 stale re-provision also adopts the replacement root pane', async () => { + writePersisted({ paneId: 'wD:OLD', workspaceId: 'wD' }); + const movePaneToNewTab = vi.fn(async () => true); + const deps: DispatcherAnchorDeps = { + createWorkspace: vi.fn(async () => ({ workspaceId: 'wD2', paneId: 'wD2:p1' })), + isPaneAlive: vi.fn(async () => false), + movePaneToNewTab, + }; + const got = await getDispatcherAnchor(tmpDir, deps); + expect(got).toEqual({ paneId: 'wD2:p1', workspaceId: 'wD2' }); + expect(movePaneToNewTab).toHaveBeenCalledWith('wD2:p1', DISPATCHER_ROOT_TAB_LABEL); }); - it('TC1-2 writeTabAnchors creates a valid JSON file', () => { - const anchors: DispatcherTabAnchor = { - workspaceId: 'wT', - byPrefix: { - WL: { tabId: 'wT:t1', paneId: 'wT:t1:p1' }, - }, + it('AC1 adoption returning false is non-fatal: anchor still persisted and returned', async () => { + const deps: DispatcherAnchorDeps = { + createWorkspace: vi.fn(async () => ({ workspaceId: 'wD', paneId: 'wD:p1' })), + isPaneAlive: vi.fn(async () => true), + movePaneToNewTab: vi.fn(async () => false), }; - const success = writeTabAnchors(tmpDir, anchors); - expect(success).toBe(true); - const persisted = readTabAnchorPersisted(); - expect(persisted).toEqual(anchors); + const got = await getDispatcherAnchor(tmpDir, deps); + expect(got).toEqual({ paneId: 'wD:p1', workspaceId: 'wD' }); + expect(readPersisted()).toEqual({ paneId: 'wD:p1', workspaceId: 'wD' }); }); +}); + +// ── herdr CLI parsers ────────────────────────────────────────────────── - it('TC1-3 readTabAnchors returns null on corrupt JSON', () => { - fs.writeFileSync(path.join(tmpDir, DISPATCHER_TAB_ANCHOR_FILE), 'not json{{{'); - const result = readTabAnchors(tmpDir); - expect(result).toBeNull(); +describe('tab/pane CLI parsers', () => { + it('parseTabListOutput: live nested result shape', () => { + const raw = '{"id":"cli:tab:list","result":{"tabs":[{"label":"WL-ABC","tab_id":"wD:tWL"},{"label":"Worklog","tab_id":"wD:tX"}]}}'; + expect(parseTabListOutput(raw)).toEqual([ + { tabId: 'wD:tWL', label: 'WL-ABC' }, + { tabId: 'wD:tX', label: 'Worklog' }, + ]); }); - it('TC1-4 persistence round-trip: write then read returns same data', () => { - const anchors: DispatcherTabAnchor = { - workspaceId: 'wRound', - byPrefix: { - WL: { tabId: 'wRound:t1', paneId: 'wRound:t1:p1' }, - TCE: { tabId: 'wRound:t2', paneId: 'wRound:t2:p1' }, - }, - }; - writeTabAnchors(tmpDir, anchors); - const result = readTabAnchors(tmpDir); - expect(result).toEqual(anchors); + it('parseTabListOutput: camelCase keys and log-line prefix tolerated', () => { + const raw = 'some log line\n{"result":{"tabs":[{"tabId":"wD:tWL","title":"WL-ABC"}]}}'; + expect(parseTabListOutput(raw)).toEqual([{ tabId: 'wD:tWL', label: 'WL-ABC' }]); }); - it('TC1-5 backward-compat: legacy anchor file yields workspaceId with empty byPrefix', () => { - // Write only the legacy anchor file (no tab anchor file) - writePersisted({ paneId: 'wLegacy:p1', workspaceId: 'wLegacy' }); - // Tab anchor file does NOT exist - const result = readTabAnchors(tmpDir); - expect(result).toEqual({ workspaceId: 'wLegacy', byPrefix: {} }); + it('parseTabListOutput: null on garbage / missing tabs array', () => { + expect(parseTabListOutput('not json')).toBeNull(); + expect(parseTabListOutput('{}')).toBeNull(); + expect(parseTabListOutput('{"result":{"tabs":"nope"}}')).toBeNull(); }); - it('TC1-6 backward-compat: corrupt legacy file + no tab file yields empty workspaceId', () => { - // Write corrupt legacy file - fs.writeFileSync(path.join(tmpDir, DISPATCHER_ANCHOR_FILE), '{ corrupt'); - const result = readTabAnchors(tmpDir); - expect(result).toEqual({ workspaceId: '', byPrefix: {} }); + it('parseTabCreateOutput: live nested tab + root_pane shape', () => { + const raw = '{"id":"cli:tab:create","result":{"root_pane":{"pane_id":"wD:tWL:p1"},"tab":{"tab_id":"wD:tWL","label":"WL-ABC"}}}'; + expect(parseTabCreateOutput(raw)).toEqual({ tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); }); - it('handles variant key shapes: tab_id vs tabId, pane_id vs paneId', () => { - // Write with variant key shapes (simulating older herdr CLI output) - const variantAnchors: DispatcherTabAnchor = { - workspaceId: 'wVariant', - byPrefix: { - WL: { tabId: 'wVariant:t1', paneId: 'wVariant:t1:p1' }, - }, - }; - writeTabAnchors(tmpDir, variantAnchors); - // Overwrite with variant shapes - const raw = JSON.stringify({ - workspace_id: 'wVariant', - byPrefix: { - WL: { tab_id: 'wVariant:t1', pane_id: 'wVariant:t1:p1' }, - }, + it('parseTabCreateOutput: null when fields missing', () => { + expect(parseTabCreateOutput('{"result":{"tab":{}}}')).toBeNull(); + expect(parseTabCreateOutput('garbage')).toBeNull(); + }); + + it('parsePaneListOutput: filters to paneId + tabId', () => { + const raw = '{"result":{"panes":[{"pane_id":"wD:tWL:p1","tab_id":"wD:tWL","label":"x"},{"pane_id":"wD:tTCE:p1","tab_id":"wD:tTCE"}]}}'; + expect(parsePaneListOutput(raw)).toEqual([ + { paneId: 'wD:tWL:p1', tabId: 'wD:tWL' }, + { paneId: 'wD:tTCE:p1', tabId: 'wD:tTCE' }, + ]); + }); + + it('parseMachinePaneListOutput: nested result, label + focused + key variants', () => { + const raw = '{"result":{"panes":[{"pane_id":"wC:pB","workspace_id":"wC","tab_id":"wC:t1","label":"Work Items","focused":true},{"paneId":"wC:pX","workspaceId":"wC","tabId":"wC:t2","title":"Editor","is_focused":true}]}}'; + expect(parseMachinePaneListOutput(raw)).toEqual([ + { paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1', label: 'Work Items', focused: true }, + { paneId: 'wC:pX', workspaceId: 'wC', tabId: 'wC:t2', label: 'Editor', focused: true }, + ]); + }); + + it('parseMachinePaneListOutput: null on unparseable output / missing arrays', () => { + expect(parseMachinePaneListOutput('not json')).toBeNull(); + expect(parseMachinePaneListOutput('{}')).toBeNull(); + }); + + it('parsePaneProcessInfoOutput: live nested shape and shellPid variant', () => { + expect( + parsePaneProcessInfoOutput('{"result":{"process_info":{"shell_pid":"4321"}}}'), + ).toEqual({ shellPid: '4321' }); + expect( + parsePaneProcessInfoOutput('{"result":{"processInfo":{"shellPid":99}}}'), + ).toEqual({ shellPid: '99' }); + }); + + it('parsePaneProcessInfoOutput: null on missing/empty shell_pid', () => { + expect(parsePaneProcessInfoOutput('{"result":{"process_info":{}}}')).toBeNull(); + expect(parsePaneProcessInfoOutput('{"result":{"process_info":{"shell_pid":""}}}')).toBeNull(); + expect(parsePaneProcessInfoOutput('garbage')).toBeNull(); + }); + + it('parseProcEnviron: NUL-delimited map, last value wins, values may contain =', () => { + const raw = 'A=1\0HERDR_RESOLVED_CWD=/repo/p\0HERDR_WORKSPACE_ID=wC\0URL=x=y\0A=2\0'; + expect(parseProcEnviron(raw)).toEqual({ + A: '2', + HERDR_RESOLVED_CWD: '/repo/p', + HERDR_WORKSPACE_ID: 'wC', + URL: 'x=y', }); - fs.writeFileSync(path.join(tmpDir, DISPATCHER_TAB_ANCHOR_FILE), raw); - const result = readTabAnchors(tmpDir); - expect(result).toEqual({ - workspaceId: 'wVariant', - byPrefix: { - WL: { tabId: 'wVariant:t1', paneId: 'wVariant:t1:p1' }, - }, + }); +}); + +// ── resolveProjectWorkspace (AC1/AC3) ───────────────────────────────── + +const ROOT = '/home/rgardler/projects/ContextHub'; + +function machinePane(over: Partial = {}): MachinePaneInfo { + return { paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1', label: PLUGIN_PANE_LABEL, focused: false, ...over }; +} + +function procEnv(env: Record): string { + return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\0') + '\0'; +} + +interface ResolverOverrides { + listMachinePanes?: DispatcherAnchorDeps['listMachinePanes']; + getPaneProcessInfo?: DispatcherAnchorDeps['getPaneProcessInfo']; + readProcEnviron?: DispatcherAnchorDeps['readProcEnviron']; +} + +function makeResolverDeps(over: ResolverOverrides = {}): DispatcherAnchorDeps { + return { + createWorkspace: async () => ({ workspaceId: 'wD', paneId: 'wD:p1' }), + isPaneAlive: async () => true, + listMachinePanes: over.listMachinePanes ?? (async () => [] as MachinePaneInfo[]), + getPaneProcessInfo: over.getPaneProcessInfo ?? (async () => ({ shellPid: '1' })), + readProcEnviron: + over.readProcEnviron ?? + (async () => procEnv({ HERDR_RESOLVED_CWD: ROOT, HERDR_WORKSPACE_ID: 'wC' })), + }; +} + +describe('resolveProjectWorkspace ACs (AC1/AC3)', () => { + it('AC1/AC3: matches on HERDR_RESOLVED_CWD and returns pane/workspace/tab', async () => { + const getPaneProcessInfo = vi.fn(async () => ({ shellPid: '123' })); + const readProcEnviron = vi.fn(async () => + procEnv({ HERDR_RESOLVED_CWD: ROOT, HERDR_WORKSPACE_ID: 'wC' }), + ); + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane()], + getPaneProcessInfo, + readProcEnviron, }); + const got = await resolveProjectWorkspace('/anywhere', deps, ROOT); + expect(got).toEqual({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1' }); + expect(getPaneProcessInfo).toHaveBeenCalledWith('wC:pB'); + expect(readProcEnviron).toHaveBeenCalledWith('123'); }); - it('handles empty byPrefix but valid workspaceId', () => { - const raw = JSON.stringify({ workspaceId: 'wEmpty', byPrefix: {} }); - fs.writeFileSync(path.join(tmpDir, DISPATCHER_TAB_ANCHOR_FILE), raw); - const result = readTabAnchors(tmpDir); - expect(result).toEqual({ workspaceId: 'wEmpty', byPrefix: {} }); + it('AC3: falls back to the pane workspaceId when HERDR_WORKSPACE_ID is absent', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane({ workspaceId: 'wFromPane' })], + readProcEnviron: async () => procEnv({ HERDR_RESOLVED_CWD: ROOT }), + }); + const got = await resolveProjectWorkspace('/anywhere', deps, ROOT); + expect(got).toEqual({ paneId: 'wC:pB', workspaceId: 'wFromPane', tabId: 'wC:t1' }); }); - it('handles empty file returns null', () => { - fs.writeFileSync(path.join(tmpDir, DISPATCHER_TAB_ANCHOR_FILE), ''); - const result = readTabAnchors(tmpDir); - expect(result).toBeNull(); + it('AC1: ignores non-plugin panes entirely (no process-info probe)', async () => { + const getPaneProcessInfo = vi.fn(async () => ({ shellPid: '1' })); + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane({ label: 'Editor' })], + getPaneProcessInfo, + }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); + expect(getPaneProcessInfo).not.toHaveBeenCalled(); }); - it('handles whitespace-only file returns null', () => { - fs.writeFileSync(path.join(tmpDir, DISPATCHER_TAB_ANCHOR_FILE), ' \n '); - const result = readTabAnchors(tmpDir); - expect(result).toBeNull(); + it('AC3: no plugin pane for R → null', async () => { + const deps = makeResolverDeps({ listMachinePanes: async () => [] }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); }); - it('writeTabAnchors returns false on I/O error (read-only dir)', () => { - const anchors: DispatcherTabAnchor = { - workspaceId: 'w', - byPrefix: { X: { tabId: 't', paneId: 'p' } }, - }; - // Make the dir read-only so write fails - fs.chmodSync(tmpDir, 0o444); - try { - const success = writeTabAnchors(tmpDir, anchors); - expect(success).toBe(false); - } finally { - fs.chmodSync(tmpDir, 0o755); // restore for cleanup - } + it('AC3: plugin panes exist but none match R → null (never another root)', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane({ paneId: 'wC:pOTHER' })], + getPaneProcessInfo: async () => ({ shellPid: '1' }), + readProcEnviron: async () => + procEnv({ HERDR_RESOLVED_CWD: '/home/rgardler/projects/Tableau-Card-Engine', HERDR_WORKSPACE_ID: 'wY' }), + }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); + }); + + it('AC3 ambiguity: prefers the focused matching pane', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [ + machinePane({ paneId: 'wC:pA', tabId: 'wC:tA', focused: false }), + machinePane({ paneId: 'wC:pB', tabId: 'wC:tB', focused: true }), + ], + getPaneProcessInfo: async (id) => ({ shellPid: id === 'wC:pA' ? '1' : '2' }), + readProcEnviron: async () => procEnv({ HERDR_RESOLVED_CWD: ROOT, HERDR_WORKSPACE_ID: 'wC' }), + }); + const got = await resolveProjectWorkspace('/anywhere', deps, ROOT); + expect(got).toEqual({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tB' }); + }); + + it('AC3 ambiguity: no focus → lowest pane id wins deterministically', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [ + machinePane({ paneId: 'wC:pZ', tabId: 'wC:tZ' }), + machinePane({ paneId: 'wC:pA', tabId: 'wC:tA' }), + ], + getPaneProcessInfo: async () => ({ shellPid: '1' }), + readProcEnviron: async () => procEnv({ HERDR_RESOLVED_CWD: ROOT, HERDR_WORKSPACE_ID: 'wC' }), + }); + const got = await resolveProjectWorkspace('/anywhere', deps, ROOT); + expect(got?.paneId).toBe('wC:pA'); + }); + + it('AC3 fail-closed: malformed pane list → null', async () => { + const deps = makeResolverDeps({ listMachinePanes: async () => null }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); + }); + + it('AC3 fail-closed: malformed process-info → null', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane()], + getPaneProcessInfo: async () => null, + }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); + }); + + it('AC3 fail-closed: missing shell_pid → null', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane()], + getPaneProcessInfo: async () => ({ shellPid: '' }), + }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); + }); + + it('AC3 fail-closed: unreadable /proc environ → null', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane()], + readProcEnviron: async () => null, + }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); + }); + + it('AC3 fail-closed: missing HERDR_RESOLVED_CWD → null', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane()], + readProcEnviron: async () => procEnv({ HERDR_WORKSPACE_ID: 'wC' }), + }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); + }); + + it('AC3 fail-closed: throwing CLI deps → null', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => { throw new Error('herdr down'); }, + }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toBeNull(); + }); + + it('empty root → null without probing panes', async () => { + const listMachinePanes = vi.fn(async () => [machinePane()]); + const deps = makeResolverDeps({ listMachinePanes }); + expect(await resolveProjectWorkspace('/anywhere', deps, '')).toBeNull(); + expect(listMachinePanes).not.toHaveBeenCalled(); + }); + + it('trailing-slash tolerant matching (root vs HERDR_RESOLVED_CWD)', async () => { + const deps = makeResolverDeps({ + listMachinePanes: async () => [machinePane()], + readProcEnviron: async () => procEnv({ HERDR_RESOLVED_CWD: `${ROOT}/`, HERDR_WORKSPACE_ID: 'wC' }), + }); + expect(await resolveProjectWorkspace('/anywhere', deps, ROOT)).toEqual({ + paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:t1', + }); }); }); -// ── Per-prefix tab anchor provisioning (TC2: WL-0MU2LF6SZ000JQYI) ─────── +// ── getItemTabAnchor (AC2/AC5) ───────────────────────────────────────── -interface TabDepsOverrides { +function makeTabDeps(over: ResolverOverrides & { createWorkspace?: DispatcherAnchorDeps['createWorkspace']; isPaneAlive?: DispatcherAnchorDeps['isPaneAlive']; listTabs?: DispatcherAnchorDeps['listTabs']; createTab?: DispatcherAnchorDeps['createTab']; listPanes?: DispatcherAnchorDeps['listPanes']; -} - -function makeTabDeps(overrides: TabDepsOverrides = {}): DispatcherAnchorDeps { +} = {}): DispatcherAnchorDeps { return { - createWorkspace: overrides.createWorkspace ?? (async () => ({ workspaceId: 'wD', paneId: 'wD:p1' })), - isPaneAlive: overrides.isPaneAlive ?? (async () => true), - listTabs: overrides.listTabs ?? (async () => [] as DispatcherTabInfo[]), - createTab: overrides.createTab ?? (async (ws: string, label: string) => ({ tabId: `${ws}:t${label}`, paneId: `${ws}:t${label}:p1` })), - listPanes: overrides.listPanes ?? (async () => [] as DispatcherPaneInfo[]), - }; + createWorkspace: over.createWorkspace ?? (async () => ({ workspaceId: 'wD', paneId: 'wD:p1' })), + isPaneAlive: over.isPaneAlive ?? (async () => true), + listTabs: over.listTabs ?? (async () => [] as DispatcherTabInfo[]), + createTab: over.createTab ?? (async (ws: string, label: string) => ({ tabId: `${ws}:t${label}`, paneId: `${ws}:t${label}:p1` })), + listPanes: over.listPanes ?? (async () => [] as DispatcherPaneInfo[]), + ...(over.listMachinePanes ? { listMachinePanes: over.listMachinePanes } : {}), + ...(over.getPaneProcessInfo ? { getPaneProcessInfo: over.getPaneProcessInfo } : {}), + ...(over.readProcEnviron ? { readProcEnviron: over.readProcEnviron } : {}), + } as DispatcherAnchorDeps; } -describe('getDispatcherTabAnchor ACs (TC2)', () => { - it('TC2-AC1 first dispatch creates the labelled tab and persists the mapping', async () => { +describe('getItemTabAnchor ACs (AC2/AC5)', () => { + it('AC2: first dispatch creates the exact item-ID tab and returns its anchor pane', async () => { const createTab = vi.fn(async (ws: string, label: string) => ({ tabId: `${ws}:t${label}`, paneId: `${ws}:t${label}:p1`, })); const deps = makeTabDeps({ createTab }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); - expect(createTab).toHaveBeenCalledWith('wD', 'WL'); - const persisted = readTabAnchorPersisted(); - expect(persisted).toEqual({ - workspaceId: 'wD', - byPrefix: { WL: { tabId: 'wD:tWL', paneId: 'wD:tWL:p1' } }, - }); + const got = await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC'); + expect(got).toEqual({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }); + expect(createTab).toHaveBeenCalledWith('wC', 'WL-ABC'); }); - it('TC2-AC2 second dispatch reuses the persisted tab without creating', async () => { - writeTabAnchorPersisted({ - workspaceId: 'wD', - byPrefix: { WL: { tabId: 'wD:tWL', paneId: 'wD:tWL:p1' } }, - }); + it('AC2: second dispatch REUSES the existing tab (no duplicate create)', async () => { const createTab = vi.fn(async () => null); - const createWorkspace = vi.fn(async () => { - throw new Error('should not provision workspace'); - }); const deps = makeTabDeps({ createTab, - createWorkspace: createWorkspace as unknown as DispatcherAnchorDeps['createWorkspace'], + listTabs: async () => [{ tabId: 'wC:tWL-ABC', label: 'WL-ABC' }], + listPanes: async () => [{ paneId: 'wC:tWL-ABC:p1', tabId: 'wC:tWL-ABC' }], isPaneAlive: async () => true, }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + const got = await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC'); + expect(got).toEqual({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }); expect(createTab).not.toHaveBeenCalled(); - expect(createWorkspace).not.toHaveBeenCalled(); }); - it('TC2-AC2 stale persisted pane is re-created (dead tab replaced)', async () => { - writePersisted({ paneId: 'wD:p1', workspaceId: 'wD' }); - writeTabAnchorPersisted({ - workspaceId: 'wD', - byPrefix: { WL: { tabId: 'wD:tWL', paneId: 'wD:tWL:pDEAD' } }, - }); - const createTab = vi.fn(async (ws: string) => ({ tabId: `${ws}:tWL2`, paneId: `${ws}:tWL2:p1` })); + it('AC2: matching tab with a dead pane is replaced (create)', async () => { + const createTab = vi.fn(async (ws: string) => ({ tabId: `${ws}:tNEW`, paneId: `${ws}:tNEW:p1` })); const deps = makeTabDeps({ createTab, - isPaneAlive: async (paneId: string) => paneId !== 'wD:tWL:pDEAD', - // The dead tab is still listed, but its only pane is dead. - listTabs: async () => [{ tabId: 'wD:tWL', label: 'WL' }], - listPanes: async () => [{ paneId: 'wD:tWL:pDEAD', tabId: 'wD:tWL' }], + listTabs: async () => [{ tabId: 'wC:tDEAD', label: 'WL-ABC' }], + listPanes: async () => [{ paneId: 'wC:tDEAD:p1', tabId: 'wC:tDEAD' }], + isPaneAlive: async () => false, }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL2', paneId: 'wD:tWL2:p1' }); - expect(createTab).toHaveBeenCalledWith('wD', 'WL'); - const persisted = readTabAnchorPersisted(); - expect(persisted?.byPrefix.WL).toEqual({ tabId: 'wD:tWL2', paneId: 'wD:tWL2:p1' }); + const got = await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC'); + expect(got).toEqual({ tabId: 'wC:tNEW', paneId: 'wC:tNEW:p1' }); + expect(createTab).toHaveBeenCalledWith('wC', 'WL-ABC'); }); - it('adopts an unpersisted matching tab when its anchor pane is alive', async () => { - writePersisted({ paneId: 'wD:p1', workspaceId: 'wD' }); - writeTabAnchorPersisted({ workspaceId: 'wD', byPrefix: {} }); + it('AC5: a different item id gets its own tab (no prefix collapsing)', async () => { + const createTab = vi.fn(async (ws: string, label: string) => ({ tabId: `${ws}:t${label}`, paneId: `${ws}:t${label}:p1` })); + const deps = makeTabDeps({ createTab }); + await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC'); + await getItemTabAnchor('/repo', deps, 'wC', 'WL-XYZ'); + expect(createTab).toHaveBeenNthCalledWith(1, 'wC', 'WL-ABC'); + expect(createTab).toHaveBeenNthCalledWith(2, 'wC', 'WL-XYZ'); + }); + + it('AC2 fail-closed: unparseable tab list → null, never creates', async () => { + const createTab = vi.fn(async () => null); + const deps = makeTabDeps({ listTabs: async () => null, createTab }); + expect(await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC')).toBeNull(); + expect(createTab).not.toHaveBeenCalled(); + }); + + it('AC2 fail-closed: unreadable pane list (unknown) → null, never creates', async () => { const createTab = vi.fn(async () => null); const deps = makeTabDeps({ + listTabs: async () => [{ tabId: 'wC:tWL-ABC', label: 'WL-ABC' }], + listPanes: async () => null, createTab, - listTabs: async () => [{ tabId: 'wD:tWL', label: 'WL' }], - listPanes: async () => [{ paneId: 'wD:tWL:p1', tabId: 'wD:tWL' }], - isPaneAlive: async () => true, }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + expect(await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC')).toBeNull(); expect(createTab).not.toHaveBeenCalled(); - const persisted = readTabAnchorPersisted(); - expect(persisted?.byPrefix.WL).toEqual({ tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); }); - it('TC2-AC3 concurrent first-dispatches do not duplicate (lock held, no entry → fail closed)', async () => { - writePersisted({ paneId: 'wD:p1', workspaceId: 'wD' }); - // Simulate another holder by pre-creating the lock file. + it('AC2 fail-closed: tab create returns null → null', async () => { + const deps = makeTabDeps({ createTab: async () => null }); + expect(await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC')).toBeNull(); + }); + + it('AC2 guard: empty item id or workspace id → null', async () => { + const deps = makeTabDeps(); + expect(await getItemTabAnchor('/repo', deps, 'wC', '')).toBeNull(); + expect(await getItemTabAnchor('/repo', deps, '', 'WL-ABC')).toBeNull(); + }); + + it('AC2 concurrent: lock held with no existing tab → fail closed (no duplicate)', async () => { const lockPath = path.join(tmpDir, 'downtime-coordination.lock'); fs.writeFileSync(lockPath, ''); const createTab = vi.fn(async () => null); const deps = makeTabDeps({ createTab }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toBeNull(); + expect(await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC')).toBeNull(); expect(createTab).not.toHaveBeenCalled(); try { fs.unlinkSync(lockPath); } catch { /* ignore */ } }); - it('does not duplicate when lock is held but the winner already persisted', async () => { - writePersisted({ paneId: 'wD:p1', workspaceId: 'wD' }); - writeTabAnchorPersisted({ - workspaceId: 'wD', - byPrefix: { WL: { tabId: 'wD:tWL', paneId: 'wD:tWL:p1' } }, - }); + it('AC2 concurrent: lock held but the winner already created the tab → adopt it', async () => { const lockPath = path.join(tmpDir, 'downtime-coordination.lock'); fs.writeFileSync(lockPath, ''); const createTab = vi.fn(async () => null); const deps = makeTabDeps({ createTab, - // re-read after lock contention is what the winner wrote + listTabs: async () => [{ tabId: 'wC:tWL-ABC', label: 'WL-ABC' }], + listPanes: async () => [{ paneId: 'wC:tWL-ABC:p1', tabId: 'wC:tWL-ABC' }], isPaneAlive: async () => true, }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); + const got = await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC'); + expect(got).toEqual({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }); expect(createTab).not.toHaveBeenCalled(); try { fs.unlinkSync(lockPath); } catch { /* ignore */ } }); - it('TC2-AC4 fail-closed: unparseable `tab list` → null, never creates a duplicate', async () => { - writePersisted({ paneId: 'wD:p1', workspaceId: 'wD' }); - const createTab = vi.fn(async () => null); - const deps = makeTabDeps({ - createTab, - listTabs: async () => null, // parse failure - }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toBeNull(); - expect(createTab).not.toHaveBeenCalled(); - }); - - it('TC2-AC4 fail-closed: `tab create` returns null (unparseable output) → null, nothing persisted', async () => { - writePersisted({ paneId: 'wD:p1', workspaceId: 'wD' }); - const deps = makeTabDeps({ createTab: async () => null }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toBeNull(); - // No WL entry may be persisted — the map stays empty (legacy anchor only). - const persisted = readTabAnchors(tmpDir); - expect(persisted?.byPrefix).toEqual({}); - }); - - it('preserves other prefixes when persisting a new one', async () => { - writePersisted({ paneId: 'wD:p1', workspaceId: 'wD' }); - writeTabAnchorPersisted({ - workspaceId: 'wD', - byPrefix: { TCE: { tabId: 'wD:tTCE', paneId: 'wD:tTCE:p1' } }, - }); - const createTab = vi.fn(async (ws: string) => ({ tabId: `${ws}:tWL`, paneId: `${ws}:tWL:p1` })); - const deps = makeTabDeps({ createTab }); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toEqual({ workspaceId: 'wD', tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); - const persisted = readTabAnchorPersisted(); - expect(persisted?.byPrefix.TCE).toEqual({ tabId: 'wD:tTCE', paneId: 'wD:tTCE:p1' }); - expect(persisted?.byPrefix.WL).toEqual({ tabId: 'wD:tWL', paneId: 'wD:tWL:p1' }); - }); - - it('empty prefix fails closed (no dispatch)', async () => { - const deps = makeTabDeps(); - const got = await getDispatcherTabAnchor(tmpDir, deps, ''); - expect(got).toBeNull(); - }); - it('machine dir unresolvable → null', async () => { const fileAsDir = path.join(tmpDir, 'not-a-dir'); fs.writeFileSync(fileAsDir, 'x'); process.env.HERDR_COORDINATION_DIR = fileAsDir; const deps = makeTabDeps(); - const got = await getDispatcherTabAnchor(tmpDir, deps, 'WL'); - expect(got).toBeNull(); + expect(await getItemTabAnchor('/repo', deps, 'wC', 'WL-ABC')).toBeNull(); }); }); -describe('tab CLI parsers (TC2)', () => { - it('parseTabListOutput: live nested result shape', () => { - const raw = '{"id":"cli:tab:list","result":{"tabs":[{"label":"WL","tab_id":"wD:tWL"},{"label":"Worklog","tab_id":"wD:tX"}]}}'; - expect(parseTabListOutput(raw)).toEqual([ - { tabId: 'wD:tWL', label: 'WL' }, - { tabId: 'wD:tX', label: 'Worklog' }, +// ── createDispatcherAnchorDeps CLI wrappers ──────────────────────────── + +describe('createDispatcherAnchorDeps.movePaneToNewTab', () => { + function writeFakeHerdr(script: string): string { + const binPath = path.join(tmpDir, 'herdr'); + fs.writeFileSync(binPath, `#!/usr/bin/env bash\n${script}\n`, 'utf-8'); + fs.chmodSync(binPath, 0o755); + return binPath; + } + + it('invokes `pane move --new-tab --tab-label