From dfcc11521df4b0ee8768448eef37f0b15b4a8359 Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 5 Aug 2026 22:30:30 +0100 Subject: [PATCH 01/59] WL-0MSGLOMLL004JFSW: regenerate src/version.ts (1.0.7 -> 1.0.8) Release merge 28719d61 bumped package.json to 1.0.8 without regenerating the auto-generated src/version.ts, breaking tests/cli/init.test.ts:111 (expected result.version to equal getPackageVersion()). Re-ran scripts/generate-version.cjs to sync WORKLOG_VERSION with package.json. --- src/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/version.ts b/src/version.ts index 1f6eae974..d0f04f93a 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.7'; +export const WORKLOG_VERSION = '1.0.8'; From c9e4b846d15f910108c3543724bafe1cc672b8e4 Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 5 Aug 2026 23:17:07 +0100 Subject: [PATCH 02/59] WL-0MSG4NMF0000YBRP: Consolidate herdr plugin timers into a single TaskScheduler loop --- packages/herdr/src/scheduler.test.ts | 296 +++++++++++++++++++++++++++ packages/herdr/src/scheduler.ts | 194 ++++++++++++++++++ packages/herdr/src/worklist.ts | 122 ++++++----- 3 files changed, 557 insertions(+), 55 deletions(-) create mode 100644 packages/herdr/src/scheduler.test.ts create mode 100644 packages/herdr/src/scheduler.ts diff --git a/packages/herdr/src/scheduler.test.ts b/packages/herdr/src/scheduler.test.ts new file mode 100644 index 000000000..c3e48b4a1 --- /dev/null +++ b/packages/herdr/src/scheduler.test.ts @@ -0,0 +1,296 @@ +/** + * packages/herdr/src/scheduler.test.ts — Unit tests for the single-loop + * task scheduler (WL-0MSG4NMF0000YBRP). + * + * The scheduler drives ALL periodic plugin work (auto-refresh, auto-sync, + * visibility resume-poll, and future downtime-worker ticks) through one + * `setInterval`. Tests cover due-work dispatch, independent intervals, + * fire-immediately tasks, disable/enable (the mechanism behind + * pause-when-hidden), single-flight (no overlapping runs, skipped ticks are + * not coalesced), disabled 0-interval tasks, and stop(). + * + * Run: npx vitest run packages/herdr/src/scheduler.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { TaskScheduler, DEFAULT_SCHEDULER_TICK_MS } from './scheduler.js'; + +describe('TaskScheduler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('due-work dispatch', () => { + it('fires a task when its interval elapses, then repeats at the same cadence', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, run }); + scheduler.start(); + + expect(run).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(29_000); + expect(run).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1_000); // t=30s + expect(run).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(30_000); // t=60s + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('dispatches independent tasks at their own deadlines', async () => { + const fast = vi.fn(); + const slow = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'fast', intervalMs: 2_000, run: fast }); + scheduler.addTask({ id: 'slow', intervalMs: 5_000, run: slow }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(4_000); + expect(fast).toHaveBeenCalledTimes(2); // t=2s, t=4s + expect(slow).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(1_000); // t=5s + expect(slow).toHaveBeenCalledTimes(1); + expect(fast).toHaveBeenCalledTimes(2); // t=6s not reached + + scheduler.stop(); + }); + + it('is a no-op when start() is called multiple times', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run }); + scheduler.start(); + scheduler.start(); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(1); // not 3 + + scheduler.stop(); + }); + }); + + describe('fireImmediately', () => { + it('runs fireImmediately tasks once on start(), then at the interval', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'sync', intervalMs: 60_000, run, fireImmediately: true }); + scheduler.start(); + + expect(run).toHaveBeenCalledTimes(1); // immediate first tick + + await vi.advanceTimersByTimeAsync(60_000); + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('does not run a fireImmediately task that is disabled at start', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ + id: 'poll', + intervalMs: 2_000, + run, + fireImmediately: true, + disabled: true, + }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(4_000); + expect(run).not.toHaveBeenCalled(); + + scheduler.stop(); + }); + }); + + describe('disable / enable (hidden-pane skipping mechanism)', () => { + it('skips a disabled task; enabling it arms it from the next interval', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'poll', intervalMs: 2_000, run, disabled: true }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(10_000); + expect(run).not.toHaveBeenCalled(); // disabled → never fires + + scheduler.setDisabled('poll', false); + await vi.advanceTimersByTimeAsync(1_999); + expect(run).not.toHaveBeenCalled(); // armed from enable time + + await vi.advanceTimersByTimeAsync(1); + expect(run).toHaveBeenCalledTimes(1); + + // Disabling again stops it; re-enabling restarts the cadence. + scheduler.setDisabled('poll', true); + await vi.advanceTimersByTimeAsync(10_000); + expect(run).toHaveBeenCalledTimes(1); + + scheduler.stop(); + }); + + it('setDisabled on an unknown id is a safe no-op', () => { + const scheduler = new TaskScheduler(1000); + expect(() => scheduler.setDisabled('nope', true)).not.toThrow(); + expect(() => scheduler.setDisabled('nope', false)).not.toThrow(); + }); + }); + + describe('single-flight', () => { + it('skips a tick whose previous run is still pending, and does not coalesce it', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const run = vi.fn(() => gate); + + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 30_000, run, singleFlight: true }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); // first tick starts (pending) + + // Next tick while pending: skipped (not coalesced) — next run only at + // the following interval boundary after the pending run settles. + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(1); + + release(); + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); // settling does not re-fire + + await vi.advanceTimersByTimeAsync(30_000); + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + + it('runs overlapping ticks when singleFlight is false', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const run = vi.fn(() => gate); + + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(1); + + // Without singleFlight the second tick fires even though the first + // run is still pending. + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(2); + + release(); + await vi.advanceTimersByTimeAsync(0); + scheduler.stop(); + }); + }); + + describe('error resilience', () => { + it('a rejecting task does not crash the loop and still runs on later ticks', async () => { + const run = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValue(undefined); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(1); // rejected, but loop survives + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(2); // cadence resumes + + scheduler.stop(); + }); + + it('a rejecting fireImmediately task does not crash start()', async () => { + const run = vi.fn().mockRejectedValue(new Error('boom')); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 60_000, run, fireImmediately: true }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(0); + expect(run).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(60_000); + expect(run).toHaveBeenCalledTimes(2); + + scheduler.stop(); + }); + }); + + describe('disabled zero/negative intervals', () => { + it('never fires a task with intervalMs <= 0', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'zero', intervalMs: 0, run }); + scheduler.addTask({ id: 'neg', intervalMs: -5, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(10_000); + expect(run).not.toHaveBeenCalled(); + + scheduler.stop(); + }); + }); + + describe('stop', () => { + it('stop() prevents further ticks', async () => { + const run = vi.fn(); + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run }); + scheduler.start(); + + await vi.advanceTimersByTimeAsync(2_000); + expect(run).toHaveBeenCalledTimes(1); + + scheduler.stop(); + const countAfterStop = run.mock.calls.length; + + await vi.advanceTimersByTimeAsync(10_000); + expect(run).toHaveBeenCalledTimes(countAfterStop); + }); + + it('stop() before start() is a safe no-op', () => { + const scheduler = new TaskScheduler(1000); + expect(() => scheduler.stop()).not.toThrow(); + }); + }); + + describe('process lifetime', () => { + it('unrefs its interval so the loop does not keep the process alive', () => { + const unref = vi.fn(); + const setIntervalSpy = vi.spyOn(globalThis, 'setInterval').mockReturnValue({ + unref, + hasRef: () => false, + refresh: () => {}, + } as any); + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {}); + + const scheduler = new TaskScheduler(1000); + scheduler.addTask({ id: 'a', intervalMs: 2_000, run: vi.fn() }); + scheduler.start(); + + expect(setIntervalSpy).toHaveBeenCalled(); + expect(unref).toHaveBeenCalled(); + + scheduler.stop(); + expect(clearIntervalSpy).toHaveBeenCalled(); + + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + }); + }); + + it('exposes DEFAULT_SCHEDULER_TICK_MS (1s)', () => { + expect(DEFAULT_SCHEDULER_TICK_MS).toBe(1000); + }); +}); diff --git a/packages/herdr/src/scheduler.ts b/packages/herdr/src/scheduler.ts new file mode 100644 index 000000000..099ab9d70 --- /dev/null +++ b/packages/herdr/src/scheduler.ts @@ -0,0 +1,194 @@ +/** + * packages/herdr/src/scheduler.ts — Single-loop task scheduler + * + * Consolidates ALL periodic plugin work (auto-refresh, auto-sync, + * visibility resume-poll, and future downtime-worker ticks) into ONE + * `setInterval` loop that dispatches due work by deadline + * (WL-0MSG4NMF0000YBRP). + * + * Key design decisions: + * - One loop: a single tick at `DEFAULT_SCHEDULER_TICK_MS` walks the task + * table and runs every task whose due time has passed, so tick cadence, + * pause-when-hidden gating, and shutdown cleanup live in one place. + * - Sequential dispatch: due tasks run one at a time (each awaited) so a + * task that memoizes a shared resource (e.g. the PollGate visibility + * cache) settles before the next task reads it. + * - Deadline scheduling: each task carries its own `intervalMs` and due + * time; the base tick only needs to be at the finest granularity needed + * (1s for the 2s resume-poll cadence). + * - Per-task single-flight: a task with `singleFlight: true` whose previous + * run is still pending has its tick SKIPPED and rescheduled to the next + * interval (a skipped tick is not coalesced) — same semantics as the + * refresh/sync guards they replace. + * - Error resilience: a rejecting task never takes down the loop; its + * cadence resumes on the next interval. + * - Disable/enable: tasks can start disabled (e.g. the resume-poll only + * runs while the pane is hidden) and be toggled at runtime without + * restarting the loop — the mechanism behind pause-when-hidden. + * - Unref'd: the interval is unref'd so the loop never keeps the process + * alive on its own. + */ + +// ── Constants ───────────────────────────────────────────────────────── + +/** + * Base cadence of the scheduler loop in milliseconds. The finest period any + * registered task needs is the visibility resume-poll (2s), so a 1s tick + * dispatches every due task within 1s of its deadline. + */ +export const DEFAULT_SCHEDULER_TICK_MS = 1000; + +// ── Task contract ───────────────────────────────────────────────────── + +/** + * A unit of periodic work scheduled by the loop. + */ +export interface SchedulerTask { + /** Unique task id, used to enable/disable the task at runtime. */ + id: string; + /** Period between runs in milliseconds (must be > 0). */ + intervalMs: number; + /** The work to run when the task is due. May be async. */ + run: () => Promise | void; + /** + * Skip (and reschedule) a tick when the previous run is still pending, so + * slow tasks never overlap. Skipped ticks are not coalesced: the next run + * happens at the following interval boundary. + */ + singleFlight?: boolean; + /** Run once immediately when the scheduler starts (first tick). */ + fireImmediately?: boolean; + /** Start disabled (skipped until `setDisabled(id, false)`). */ + disabled?: boolean; +} + +/** Internal bookkeeping for a registered task. */ +interface TaskEntry { + task: SchedulerTask; + /** Absolute time (ms) of the next scheduled run. */ + dueAt: number; + /** True while the task's previous run is still pending. */ + inFlight: boolean; +} + +// ── Scheduler ───────────────────────────────────────────────────────── + +/** + * A single-interval task scheduler that dispatches due work by deadline. + * + * ``` + * const scheduler = new TaskScheduler(DEFAULT_SCHEDULER_TICK_MS); + * scheduler.addTask({ id: 'refresh', intervalMs: 30_000, run: doRefresh }); + * scheduler.start(); + * ... + * scheduler.stop(); + * ``` + */ +export class TaskScheduler { + private timer: ReturnType | null = null; + private readonly tasks = new Map(); + + constructor(private readonly tickMs: number) {} + + /** + * Register a periodic task. Tasks with `intervalMs <= 0` are treated as + * disabled and never fire. Adding a task while the scheduler is running + * is supported; it is armed from `now + intervalMs`. + */ + addTask(task: SchedulerTask): void { + if (task.intervalMs <= 0) { + task = { ...task, disabled: true }; + } + this.tasks.set(task.id, { + task, + dueAt: Date.now() + task.intervalMs, + inFlight: false, + }); + } + + /** + * Enable or disable a task at runtime. Disabled tasks are skipped by the + * loop; re-enabling arms the task from now, so `setDisabled(id, false)` + * behaves like starting the task's interval from scratch. + */ + setDisabled(id: string, disabled: boolean): void { + const entry = this.tasks.get(id); + if (!entry) return; + entry.task.disabled = disabled; + if (!disabled) { + // (Re)arm from now — mirrors starting a fresh interval. + entry.dueAt = Date.now() + entry.task.intervalMs; + } + } + + /** + * Start the loop. Tasks flagged `fireImmediately` run once right away + * (promise fire-and-forget), then settle into their interval cadence. + * No-op if already running. + */ + start(): void { + if (this.timer !== null) return; + const now = Date.now(); + for (const entry of this.tasks.values()) { + if (entry.task.fireImmediately && !entry.task.disabled) { + void this.runTask(entry, now); + } + } + this.timer = setInterval(() => { + void this.tick(); + }, this.tickMs); + // Don't keep the process alive just for the loop. + if (this.timer.unref) this.timer.unref(); + } + + /** + * Stop the loop and clear the interval. Safe to call when not running. + */ + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + /** + * One scheduler tick: dispatch every due, enabled, non-in-flight task. + * Tasks are dispatched SEQUENTIALLY (each run is awaited before the next + * task is dispatched) so a task that memoizes a shared resource (e.g. the + * PollGate visibility cache) settles before the next task reads it — the + * same inter-timer behavior the independent intervals used to have. + */ + private readonly tick = async (): Promise => { + const now = Date.now(); + for (const entry of this.tasks.values()) { + if (entry.task.disabled) continue; + if (now < entry.dueAt) continue; + if (entry.task.singleFlight && entry.inFlight) { + // Skipped tick is not coalesced — reschedule to the next interval. + entry.dueAt = now + entry.task.intervalMs; + continue; + } + await this.runTask(entry, now); + } + }; + + /** + * Run a task: reschedule the next fire before running (so a slow run does + * not delay the cadence, matching `setInterval` semantics) and track the + * in-flight flag for single-flight tasks. Errors are swallowed so a + * failing task never takes down the loop; tasks that need to surface + * errors should catch them in their own `run` (as the worklist's + * doRefresh/doSync do). + */ + private async runTask(entry: TaskEntry, now: number): Promise { + entry.dueAt = now + entry.task.intervalMs; + entry.inFlight = true; + try { + await entry.task.run(); + } catch { + // A failing task must never take down the loop or the plugin. + } finally { + entry.inFlight = false; + } + } +} diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index eb54b6f13..6f55e5703 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -29,7 +29,8 @@ import { stageColor, type IconOptions, } from './icons.js'; -import { runSync, createSyncTimer, clampSyncInterval } from './auto-sync.js'; +import { runSync } from './auto-sync.js'; +import { TaskScheduler, DEFAULT_SCHEDULER_TICK_MS } from './scheduler.js'; import { showToast } from './notify.js'; import { recordCommand, getLastCommand } from './command-log.js'; import { @@ -2698,66 +2699,60 @@ export async function runWorklistTui( // Read keypresses process.stdin.on('data', onData); - // Resume poll — while the pane is hidden, visibility is polled on a short - // interval so the hidden → visible transition triggers an immediate - // doRefresh instead of waiting for the next refreshIntervalMs tick. The - // poll uses only herdr pane get (via the PollGate memoizer, TTL aligned - // with the poll interval) — never wl — preserving the zero-wl-when-hidden - // guarantee. - const RESUME_POLL_INTERVAL_MS = DEFAULT_POLL_GATE_TTL_MS; - let resumePollTimer: ReturnType | undefined; + // ── Consolidated scheduler loop (WL-0MSG4NMF0000YBRP) ────────────── + // All periodic plugin work — auto-refresh, auto-sync, visibility + // resume-poll, and future downtime-worker ticks — runs through ONE + // TaskScheduler interval. Tasks are dispatched by due-time deadline at a + // 1s base tick; each task applies its own visibility gate (refresh/sync + // skip when hidden, resume-poll only runs while hidden). Tick cadence, + // pause-when-hidden gating, and shutdown cleanup live in one place. + const scheduler = new TaskScheduler(DEFAULT_SCHEDULER_TICK_MS); const stopResumePoll = (): void => { - if (resumePollTimer !== undefined) { - clearInterval(resumePollTimer); - resumePollTimer = undefined; - } + scheduler.setDisabled('resume-poll', true); }; /** Start the resume poll (no-op when already running). */ const startResumePoll = (): void => { - if (resumePollTimer !== undefined) return; - resumePollTimer = setInterval(async () => { - if (await paneGate.visible()) { - // Hidden → visible transition: refresh immediately (with the - // "refreshed" notification) and let the normal cadence resume. - stopResumePoll(); - panePaused = false; - doRefresh(true); - } - }, RESUME_POLL_INTERVAL_MS); + scheduler.setDisabled('resume-poll', false); }; - // Auto-refresh timer — fetches fresh items on an interval. NOTE: this timer - // does NOT run `wl sync`; the dedicated SyncTimer below is the single sync - // source. Running sync from both timers caused a double-spawn per pane that + // Auto-refresh task — fetches fresh items on an interval. NOTE: this task + // does NOT run `wl sync`; the dedicated sync task below is the single sync + // source. Running sync from both tasks caused a double-spawn per pane that // amplified the wl sync lock storm (WL-0MSAB7ZUC004SK7E). // Visibility-gated: when the pane is hidden (not focused), ticks are // skipped so hidden panes spawn zero wl processes (pause-when-hidden). - let refreshTimer: ReturnType | undefined; if (opts.autoRefresh) { - refreshTimer = setInterval(async () => { - if (!(await paneGate.visible())) { - panePaused = true; - startResumePoll(); - return; - } - stopResumePoll(); - panePaused = false; - doRefresh(false); - }, opts.refreshIntervalMs); - } - - // Auto-sync timer (background wl sync) — the only auto-sync source. Uses the - // single-flight + lock-aware (--if-idle) guard so concurrent panes/TUI - // instances skip instead of piling up under lock contention. - // Visibility-gated like the refresh timer: hidden panes skip the sync - // (and its follow-up refresh) entirely. - let syncTimer: ReturnType | undefined; + scheduler.addTask({ + id: 'refresh', + intervalMs: opts.refreshIntervalMs, + singleFlight: true, + run: async () => { + if (!(await paneGate.visible())) { + panePaused = true; + startResumePoll(); + return; + } + stopResumePoll(); + panePaused = false; + doRefresh(false); + }, + }); + } + + // Auto-sync task (background wl sync) — the only auto-sync source. Uses + // the single-flight + lock-aware (--if-idle) guard so concurrent + // panes/TUI instances skip instead of piling up under lock contention. + // Visibility-gated like the refresh task: hidden panes skip the sync + // (and its follow-up refresh) entirely. Fires once immediately on start + // (as the previous SyncTimer did) so the first sync cycle is not delayed. if (opts.autoSync && opts.syncIntervalMs !== 0) { - syncTimer = createSyncTimer({ + scheduler.addTask({ + id: 'sync', intervalMs: opts.syncIntervalMs, - onSync: async () => { + fireImmediately: true, + run: async () => { if (!(await paneGate.visible())) { panePaused = true; startResumePoll(); @@ -2769,18 +2764,35 @@ export async function runWorklistTui( doRefresh(false); }, }); - syncTimer.start(); } + // Visibility resume-poll — runs only while the pane is hidden. Polls + // pane visibility on a short interval so the hidden → visible transition + // triggers an immediate doRefresh instead of waiting for the next + // refreshIntervalMs tick. The poll uses only herdr pane get (via the + // PollGate memoizer, TTL aligned with the poll interval) — never wl — + // preserving the zero-wl-when-hidden guarantee. + scheduler.addTask({ + id: 'resume-poll', + intervalMs: DEFAULT_POLL_GATE_TTL_MS, + singleFlight: true, + disabled: true, + run: async () => { + if (await paneGate.visible()) { + // Hidden → visible transition: refresh immediately (with the + // "refreshed" notification) and let the normal cadence resume. + stopResumePoll(); + panePaused = false; + doRefresh(true); + } + }, + }); + + scheduler.start(); + // Cleanup on promise resolution promise.finally(() => { - if (refreshTimer !== undefined) { - clearInterval(refreshTimer); - } - if (syncTimer !== undefined) { - syncTimer.stop(); - } - stopResumePoll(); + scheduler.stop(); cleanup(); process.stdout.removeListener('resize', onResize); process.stdin.removeListener('data', onData); From 18c05d5a3fcfa19481205cc035744576b6ec5fdc Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 5 Aug 2026 23:17:15 +0100 Subject: [PATCH 03/59] WL-0MSGAE35E001557V: Implementation complete --- docs/ARCHITECTURE.md | 44 +++ src/read-cache.ts | 444 ++++++++++++++++++++++ tests/read-cache-concurrent-child.ts | 55 +++ tests/read-cache.test.ts | 539 +++++++++++++++++++++++++++ 4 files changed, 1082 insertions(+) create mode 100644 src/read-cache.ts create mode 100644 tests/read-cache-concurrent-child.ts create mode 100644 tests/read-cache.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb5807919..ab3c82628 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -206,6 +206,50 @@ consumers agree because they share the same resolver. - **Git push**: Depends on network (typically 1-5s) - **Import**: ~100ms per 1000 items +## Read Cache (src/read-cache.ts) + +Pure-read commands (`list`, `next`, `show`, `search`, `status` in JSON mode) +cache their results on disk so repeated, byte-identical queries are served +without re-spawning work. + +### Cache layout + +- **Location**: XDG cache dir — `$WL_CACHE_DIR` if set, else + `$XDG_CACHE_HOME/wl`, else `~/.cache/wl`. +- **Entry files**: `/<64-hex-sha256>.json`, one per query. +- **Cache key**: SHA-256 over (wl version, resolved absolute worklog dir, + argv elements in order). The version is included so a wl upgrade with a + changed output schema never serves stale-shaped entries. +- **Entry header** stores the key inputs plus a WAL-aware DB fingerprint: + `[mtimeMs, size]` of `worklog.db`, `worklog.db-wal` and `worklog.db-shm` + captured at write time. + +### Invalidation semantics + +- **Primary (WAL-aware fingerprint)**: on read, the fingerprint is recomputed + and compared to the one stored in the entry. Any change to `worklog.db` + (checkpoint), `worklog.db-wal` (write commit), or `worklog.db-shm` + (connection) invalidates the entry. This is required because `worklog.db` + mtime alone is unreliable in WAL mode — writes land in the WAL and the main + file only changes on checkpoint. Missing DB files fingerprint as zeros, so a + DB that is later created (files appear) also invalidates. +- **TTL bounding safety net**: entries older than the TTL (default 30s, + aligned with the Herdr 30s refresh cadence) are never served. +- **Writes bypass the cache**: write commands do not read from the cache; F2+ + wires `invalidate()` so writes/sync drop the affected entries. + +### Concurrency & bounding + +- **Atomic writes**: entries are written to a temp file (`.json.tmp-*`) + then renamed into place, so concurrent readers/writers across processes + never observe partial entries. +- **LRU bound**: when the entry count exceeds `maxEntries` (default 1000), + TTL-expired entries are dropped first, then least-recently-accessed entries + are evicted. +- **Observability**: set `WL_CACHE_DEBUG=1` to log cache hits/misses/reasons to + stderr (`[wl:cache]`). `ReadCache.stats()` exposes hit/miss counters for + spawn-reduction instrumentation. + ## Future Considerations ### Potential Enhancements diff --git a/src/read-cache.ts b/src/read-cache.ts new file mode 100644 index 000000000..7082a773f --- /dev/null +++ b/src/read-cache.ts @@ -0,0 +1,444 @@ +/** + * On-disk read cache for the wl CLI (option A — primary win from + * WL-0MSAZQEQB008O7H3). + * + * Pure-read commands (`list`, `next`, `show`, `search`, `status` in JSON + * mode) cache their JSON result keyed by (absolute worklog-dir, full argv, + * wl version) in the XDG cache dir (`~/.cache/wl/`). Freshness is WAL-aware: + * the cached entry stores a fingerprint (mtime+size) of `worklog.db`, + * `worklog.db-wal` and `worklog.db-shm`, and is invalidated when any of them + * change. A TTL (default 30s) acts as a bounding safety net, and the cache is + * bounded via LRU purge. + * + * Concurrency safety: entries are written atomically (temp file + rename) so + * concurrent readers/writers across processes never observe partial entries. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createHash } from 'crypto'; +import { WORKLOG_VERSION } from './version.js'; + +// --------------------------------------------------------------------------- +// Constants / defaults +// --------------------------------------------------------------------------- + +/** Bounding safety net: entries older than this are never served (30s). */ +export const DEFAULT_TTL_MS = 30_000; + +/** LRU bound on the number of entry files kept in the cache dir. */ +export const DEFAULT_MAX_ENTRIES = 1_000; + +/** Entry files live in `/<64-hex-sha256>.json`. */ +const ENTRY_FILE_RE = /^[0-9a-f]{64}\.json$/; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ReadCacheOptions { + /** Cache root dir override (default: XDG `~/.cache/wl`). Tests use this. */ + cacheDir?: string; + /** TTL in ms (default 30_000). */ + ttlMs?: number; + /** Max entry files before LRU purge (default 1_000). */ + maxEntries?: number; + /** Injectable clock for TTL/LRU tests (default `Date.now`). */ + now?: () => number; + /** Version string included in the cache key (default `WORKLOG_VERSION`). */ + version?: string; +} + +/** + * mtime+size snapshot of the WAL-mode SQLite files for a worklog dir. + * Each pair is `[mtimeMs, size]`; missing files fingerprint as `[0, 0]`. + */ +export interface DbFingerprint { + /** worklog.db — rewritten on WAL checkpoint */ + db: [number, number]; + /** worklog.db-wal — grows/shrinks on every write commit */ + wal: [number, number]; + /** worklog.db-shm — shared-memory index, touched on connections */ + shm: [number, number]; +} + +/** A single cache entry: header (key + freshness metadata) + cached value. */ +interface CacheEntry { + key: string; + worklogDir: string; + argv: string[]; + version: string; + dbFingerprint: DbFingerprint; + createdAt: number; + accessedAt: number; + value: unknown; +} + +// --------------------------------------------------------------------------- +// Path + key helpers +// --------------------------------------------------------------------------- + +/** The WAL-mode SQLite files that make up the DB state for a worklog dir. */ +export function dbFilesForWorklogDir(worklogDir: string): { db: string; wal: string; shm: string } { + return { + db: path.join(worklogDir, 'worklog.db'), + wal: path.join(worklogDir, 'worklog.db-wal'), + shm: path.join(worklogDir, 'worklog.db-shm'), + }; +} + +function statPair(p: string): [number, number] { + try { + const st = fs.statSync(p); + return [st.mtimeMs, st.size]; + } catch { + return [0, 0]; + } +} + +/** + * WAL-aware DB-state fingerprint for a worklog dir. + * + * `worklog.db` mtime alone is unreliable in WAL mode (writes land in the WAL, + * and the main file only changes on checkpoint), so we fingerprint all three + * files: db + `-wal` + `-shm`. A fingerprint mismatch on read invalidates the + * entry. Missing files fingerprint as zeros, so a DB that is later created + * (files appear) also invalidates. + */ +export function computeDbFingerprint(worklogDir: string): DbFingerprint { + const { db, wal, shm } = dbFilesForWorklogDir(worklogDir); + return { db: statPair(db), wal: statPair(wal), shm: statPair(shm) }; +} + +/** Structural equality for fingerprints. */ +export function fingerprintsEqual(a: DbFingerprint, b: DbFingerprint): boolean { + return ( + a.db[0] === b.db[0] && + a.db[1] === b.db[1] && + a.wal[0] === b.wal[0] && + a.wal[1] === b.wal[1] && + a.shm[0] === b.shm[0] && + a.shm[1] === b.shm[1] + ); +} + +/** + * Resolve the read-cache directory. + * + * Precedence: `WL_CACHE_DIR` (explicit override, e.g. tests) → XDG cache home + * (`$XDG_CACHE_HOME/wl`) → `~/.cache/wl`. + */ +export function resolveCacheDir(): string { + const override = process.env.WL_CACHE_DIR; + if (override) return path.resolve(override); + const xdg = process.env.XDG_CACHE_HOME; + const base = xdg && xdg.trim() !== '' ? path.resolve(xdg) : path.join(os.homedir(), '.cache'); + return path.join(base, 'wl'); +} + +/** + * Deterministic cache key: SHA-256 over (wl version, resolved worklog dir, + * argv elements in order). Includes the wl version so a binary upgrade with a + * changed output schema never serves stale-shaped entries. + */ +export function deriveCacheKey(worklogDir: string, argv: string[], version: string): string { + const hash = createHash('sha256'); + hash.update(version); + hash.update('\0'); + hash.update(path.resolve(worklogDir)); + for (const arg of argv) { + hash.update('\0'); + hash.update(arg); + } + return hash.digest('hex'); +} + +// --------------------------------------------------------------------------- +// ReadCache +// --------------------------------------------------------------------------- + +/** Emit a debug line to stderr when `WL_CACHE_DEBUG` is set. */ +function debugLog(...args: unknown[]): void { + if (process.env.WL_CACHE_DEBUG) { + process.stderr.write(`[wl:cache] ${args.map(String).join(' ')}\n`); + } +} + +export class ReadCache { + private readonly cacheDir: string; + private readonly ttlMs: number; + private readonly maxEntries: number; + private readonly now: () => number; + private readonly version: string; + private hits = 0; + private misses = 0; + + constructor(options: ReadCacheOptions = {}) { + this.cacheDir = options.cacheDir ? path.resolve(options.cacheDir) : resolveCacheDir(); + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + this.now = options.now ?? (() => Date.now()); + this.version = options.version ?? WORKLOG_VERSION; + } + + getCacheDir(): string { + return this.cacheDir; + } + + /** Cache key for a (worklog-dir, argv) pair under this cache's version. */ + keyFor(worklogDir: string, argv: string[]): string { + return deriveCacheKey(worklogDir, argv, this.version); + } + + /** Hit/miss counters — used by spawn-reduction instrumentation (F2). */ + stats(): { hits: number; misses: number } { + return { hits: this.hits, misses: this.misses }; + } + + /** + * Read a cached value for (worklog-dir, argv). + * + * Returns the cached JSON value on a hit, or `null` on a miss. A miss + * happens when the entry is absent, corrupt, header-mismatched, TTL-expired, + * or when the WAL-aware DB fingerprint no longer matches (a write happened). + * Stale/corrupt entries are removed as a side effect. + */ + get(worklogDir: string, argv: string[]): unknown | null { + const dir = path.resolve(worklogDir); + const key = this.keyFor(dir, argv); + const entryPath = this.entryPath(key); + + let raw: string; + try { + raw = fs.readFileSync(entryPath, 'utf-8'); + } catch { + this.misses++; + return null; + } + + let entry: CacheEntry; + try { + entry = JSON.parse(raw) as CacheEntry; + } catch { + debugLog(`corrupt entry ${key}; removing`); + this.removeFile(entryPath); + this.misses++; + return null; + } + + // Header invariants: key, version, argv and worklog dir must all match. + if ( + entry.key !== key || + entry.version !== this.version || + !arraysEqual(entry.argv, argv) || + path.resolve(entry.worklogDir) !== dir + ) { + debugLog(`header mismatch for ${key}; removing`); + this.removeFile(entryPath); + this.misses++; + return null; + } + + const now = this.now(); + + // TTL bounding safety net. + if (now - entry.createdAt > this.ttlMs) { + debugLog(`ttl expired for ${key}; removing`); + this.removeFile(entryPath); + this.misses++; + return null; + } + + // WAL-aware freshness: any change to db/-wal/-shm invalidates. + const fp = computeDbFingerprint(dir); + if (!fingerprintsEqual(fp, entry.dbFingerprint)) { + debugLog(`db state changed for ${key}; removing`); + this.removeFile(entryPath); + this.misses++; + return null; + } + + // Hit. Refresh the access time (LRU ordering) without churning when the + // clock has not advanced (rapid repeat queries within the same ms). + this.hits++; + if (now !== entry.accessedAt) { + entry.accessedAt = now; + this.safeAtomicWrite(entryPath, entry); + } + return entry.value; + } + + /** + * Store a JSON value for (worklog-dir, argv). + * + * The DB fingerprint is captured at write time; a later DB change makes the + * entry stale on read. Writes are atomic (temp file + rename). + * + * Callers that read the DB to produce `value` may pass a fingerprint they + * captured BEFORE the read (options.dbFingerprint); the entry is then only + * consistent with that state. `set()` does not re-verify, so the caller + * should skip caching (or re-check) if the DB changed mid-query. + */ + set(worklogDir: string, argv: string[], value: unknown, options?: { dbFingerprint?: DbFingerprint }): void { + const dir = path.resolve(worklogDir); + const key = this.keyFor(dir, argv); + const now = this.now(); + const entry: CacheEntry = { + key, + worklogDir: dir, + argv: [...argv], + version: this.version, + dbFingerprint: options?.dbFingerprint ?? computeDbFingerprint(dir), + createdAt: now, + accessedAt: now, + value, + }; + fs.mkdirSync(this.cacheDir, { recursive: true }); + this.atomicWrite(this.entryPath(key), entry); + this.purgeIfNeeded(now); + } + + /** + * Remove every entry belonging to a worklog dir (used after writes/sync). + * Returns the number of entries removed. + */ + invalidate(worklogDir: string): number { + const dir = path.resolve(worklogDir); + let removed = 0; + for (const entryPath of this.listEntryPaths()) { + try { + const entry = JSON.parse(fs.readFileSync(entryPath, 'utf-8')) as CacheEntry; + if (path.resolve(entry.worklogDir) === dir && this.removeFile(entryPath)) removed++; + } catch { + // Unparseable entries are not attributed to a dir; leave for get()/purge. + } + } + return removed; + } + + /** Remove all cache entries. Returns the number removed. */ + clear(): number { + let removed = 0; + for (const entryPath of this.listEntryPaths()) { + if (this.removeFile(entryPath)) removed++; + } + return removed; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + private entryPath(key: string): string { + return path.join(this.cacheDir, `${key}.json`); + } + + private listEntryPaths(): string[] { + try { + return fs + .readdirSync(this.cacheDir) + .filter((f) => ENTRY_FILE_RE.test(f)) + .map((f) => path.join(this.cacheDir, f)); + } catch { + return []; + } + } + + /** Atomic write: temp file + rename so readers never see partial data. */ + private atomicWrite(target: string, entry: CacheEntry): void { + const tmp = `${target}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`; + fs.writeFileSync(tmp, JSON.stringify(entry), 'utf-8'); + try { + fs.renameSync(tmp, target); + } catch (err: any) { + if (err && (err.code === 'EEXIST' || err.code === 'EPERM')) { + // Windows cannot rename over an existing file — unlink then retry. + try { + fs.unlinkSync(target); + } catch { + /* ignore */ + } + try { + fs.renameSync(tmp, target); + } catch (err2) { + this.removeFile(tmp); + throw err2; + } + } else { + this.removeFile(tmp); + throw err; + } + } + } + + /** Best-effort atomic write (used for LRU touch on hit — never fails reads). */ + private safeAtomicWrite(target: string, entry: CacheEntry): void { + try { + this.atomicWrite(target, entry); + } catch (err) { + debugLog(`failed to touch ${target}: ${(err as Error).message}`); + } + } + + /** Remove a file, ignoring missing-file errors. Returns true if removed. */ + private removeFile(p: string): boolean { + try { + fs.unlinkSync(p); + return true; + } catch (err: any) { + if (err && err.code === 'ENOENT') return false; + debugLog(`failed to remove ${p}: ${(err as Error).message}`); + return false; + } + } + + /** + * Keep the cache bounded: evict TTL-expired entries and, if still over the + * max-entry bound, evict least-recently-accessed entries. Only runs when the + * entry count exceeds `maxEntries`, so the header-parsing cost is bounded + * and rare. + */ + private purgeIfNeeded(now: number): void { + const entries = this.listEntryPaths(); + if (entries.length <= this.maxEntries) return; + + const parsed = entries.map((p): { file: string; accessedAt: number; createdAt: number } => { + try { + const h = JSON.parse(fs.readFileSync(p, 'utf-8')) as CacheEntry; + return { file: p, accessedAt: h.accessedAt ?? 0, createdAt: h.createdAt ?? 0 }; + } catch { + // Unparseable entries sort as oldest → evicted first. + return { file: p, accessedAt: 0, createdAt: 0 }; + } + }); + + // Pass 1: drop TTL-expired entries. + for (const p of parsed) { + if (now - p.createdAt > this.ttlMs) this.removeFile(p.file); + } + + // Pass 2: LRU eviction until at (or under) the bound. + const remaining = this.listEntryPaths(); + if (remaining.length <= this.maxEntries) return; + const alive = new Set(remaining); + const sorted = parsed.filter((p) => alive.has(p.file)).sort((a, b) => a.accessedAt - b.accessedAt); + let toRemove = sorted.length - this.maxEntries; + for (const p of sorted) { + if (toRemove <= 0) break; + if (this.removeFile(p.file)) toRemove--; + } + } +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/tests/read-cache-concurrent-child.ts b/tests/read-cache-concurrent-child.ts new file mode 100644 index 000000000..c465dfb00 --- /dev/null +++ b/tests/read-cache-concurrent-child.ts @@ -0,0 +1,55 @@ +/** + * Child-process helper for read-cache cross-process concurrency tests. + * + * Spawned via `tsx tests/read-cache-concurrent-child.ts `. + * Each child: + * - writes/reads `rounds` unique keys (per-child argv variants) — must round-trip exactly; + * - hammers a shared key (`list --json`) so multiple processes race on one entry; + * - exits non-zero with a diagnostic if a unique-key round-trip ever fails. + * + * The parent test asserts exit codes, absence of temp files, and that every + * entry file parses with a matching key header. + */ + +import { ReadCache } from '../src/read-cache.js'; + +const childId = process.argv[2]; +const rounds = Number(process.argv[3] ?? 10); + +const cacheDir = process.env.CACHE_DIR; +const worklogDir = process.env.WORKLOG_DIR; +if (!cacheDir || !worklogDir) { + process.stderr.write('CACHE_DIR and WORKLOG_DIR env vars are required\n'); + process.exit(3); +} + +const cache = new ReadCache({ cacheDir }); + +// Unique-key round-trips: no other process touches these keys. +for (let round = 0; round < rounds; round++) { + const argv = ['list', '--json', '--child', childId, '--round', String(round)]; + cache.set(worklogDir, argv, { child: childId, round }); + const got = cache.get(worklogDir, argv); + if (!got || (got as any).child !== childId || (got as any).round !== round) { + process.stderr.write(`UNIQUE-KEY MISMATCH child=${childId} round=${round}\n`); + process.exit(1); + } +} + +// Shared-key hammering: races with other workers on one entry; the final +// value is whoever wrote last, but the file must never be unreadable. +for (let i = 0; i < rounds; i++) { + cache.set(worklogDir, ['list', '--json'], { writer: childId, i }); + const got = cache.get(worklogDir, ['list', '--json']); + if (got === null) { + // Tolerated only transiently (LRU purge / concurrent invalidation) — + // a *corrupt* entry would have been deleted by get() and reported below. + continue; + } + if (typeof (got as any).writer !== 'string') { + process.stderr.write(`SHARED-KEY CORRUPT child=${childId} i=${i}\n`); + process.exit(2); + } +} + +process.exit(0); diff --git a/tests/read-cache.test.ts b/tests/read-cache.test.ts new file mode 100644 index 000000000..b5df98432 --- /dev/null +++ b/tests/read-cache.test.ts @@ -0,0 +1,539 @@ +/** + * Tests for the wl read-cache module (src/read-cache.ts) + * + * Covers the F1 acceptance criteria from WL-0MSGAE35E001557V: + * - deterministic key derivation (dir + argv + version) with no collisions + * - WAL-aware freshness (worklog.db / -wal / -shm fingerprint invalidation) + * - TTL staleness bounding + * - bounded LRU purge + * - concurrent readers/writers (in-process + cross-process) + * - atomic writes (temp-file + rename, no partial entries) + * - invalidate()/clear() helpers (used by sync/write invalidation in F2/F3) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as childProcess from 'child_process'; +import { + ReadCache, + deriveCacheKey, + computeDbFingerprint, + resolveCacheDir, + dbFilesForWorklogDir, + fingerprintsEqual, + DEFAULT_TTL_MS, + DEFAULT_MAX_ENTRIES, +} from '../src/read-cache.js'; +import { createTempDir, cleanupTempDir } from './test-utils.js'; + +// ── helpers ──────────────────────────────────────────────────────────── + +/** Create a fake WAL-mode worklog dir with db/-wal/-shm files (empty). */ +function createFakeWorklogDir(dir: string): void { + const files = dbFilesForWorklogDir(dir); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(files.db, ''); + fs.writeFileSync(files.wal, ''); + fs.writeFileSync(files.shm, ''); +} + +/** Simulate a SQLite WAL-mode write by appending to the -wal file (size+mtime change). */ +function simulateWalWrite(dir: string): void { + const files = dbFilesForWorklogDir(dir); + fs.appendFileSync(files.wal, 'WAL-CHUNK-' + Math.random()); +} + +/** Simulate a checkpoint that rewrites worklog.db (mtime+size change). */ +function simulateDbWrite(dir: string): void { + const files = dbFilesForWorklogDir(dir); + fs.appendFileSync(files.db, 'DB-CHUNK-' + Math.random()); +} + +function listEntryFiles(cacheDir: string): string[] { + if (!fs.existsSync(cacheDir)) return []; + return fs.readdirSync(cacheDir).filter((f) => /^[0-9a-f]{64}\.json$/.test(f)); +} + +// ── suite ────────────────────────────────────────────────────────────── + +describe('read-cache', () => { + let worklogDir: string; + let cacheDir: string; + + beforeEach(() => { + worklogDir = createTempDir(); + cacheDir = createTempDir(); + createFakeWorklogDir(worklogDir); + }); + + afterEach(() => { + cleanupTempDir(worklogDir); + cleanupTempDir(cacheDir); + }); + + describe('resolveCacheDir', () => { + const saved = { xdg: process.env.XDG_CACHE_HOME, wl: process.env.WL_CACHE_DIR }; + + afterEach(() => { + if (saved.xdg === undefined) delete process.env.XDG_CACHE_HOME; + else process.env.XDG_CACHE_HOME = saved.xdg; + if (saved.wl === undefined) delete process.env.WL_CACHE_DIR; + else process.env.WL_CACHE_DIR = saved.wl; + }); + + it('honors XDG_CACHE_HOME and appends wl', () => { + vi.stubEnv('XDG_CACHE_HOME', '/xdg/cache'); + expect(resolveCacheDir()).toBe(path.join('/xdg/cache', 'wl')); + }); + + it('defaults to ~/.cache/wl when XDG_CACHE_HOME is unset', () => { + delete process.env.XDG_CACHE_HOME; + delete process.env.WL_CACHE_DIR; + expect(resolveCacheDir()).toBe(path.join(os.homedir(), '.cache', 'wl')); + }); + + it('WL_CACHE_DIR takes precedence over XDG_CACHE_HOME', () => { + vi.stubEnv('XDG_CACHE_HOME', '/xdg/cache'); + vi.stubEnv('WL_CACHE_DIR', '/custom/wl-cache'); + expect(resolveCacheDir()).toBe(path.resolve('/custom/wl-cache')); + }); + }); + + describe('deriveCacheKey', () => { + it('is deterministic for identical inputs', () => { + const argv = ['list', '--json', '--priority', 'critical']; + expect(deriveCacheKey(worklogDir, argv, '1.0.0')).toBe(deriveCacheKey(worklogDir, argv, '1.0.0')); + }); + + it('separates different worklog dirs (no collisions)', () => { + const otherDir = createTempDir(); + try { + const argv = ['list', '--json']; + const a = deriveCacheKey(worklogDir, argv, '1.0.0'); + const b = deriveCacheKey(otherDir, argv, '1.0.0'); + expect(a).not.toBe(b); + expect(a).toMatch(/^[0-9a-f]{64}$/); + } finally { + cleanupTempDir(otherDir); + } + }); + + it('separates arg variants (argv is part of the key)', () => { + const k1 = deriveCacheKey(worklogDir, ['list', '--json'], '1.0.0'); + const k2 = deriveCacheKey(worklogDir, ['list', '--json', '--root-only'], '1.0.0'); + const k3 = deriveCacheKey(worklogDir, ['list', '--status', 'open'], '1.0.0'); + const k4 = deriveCacheKey(worklogDir, ['list', '--status', 'blocked'], '1.0.0'); + expect(new Set([k1, k2, k3, k4]).size).toBe(4); + }); + + it('separates wl versions (schema-drift guard)', () => { + const argv = ['list', '--json']; + expect(deriveCacheKey(worklogDir, argv, '1.0.7')).not.toBe(deriveCacheKey(worklogDir, argv, '1.0.8')); + }); + + it('orders argv (arg order matters, byte-identical output depends on it)', () => { + expect(deriveCacheKey(worklogDir, ['list', '--json'], '1.0.0')).not.toBe( + deriveCacheKey(worklogDir, ['--json', 'list'], '1.0.0'), + ); + }); + + it('resolves relative worklog dirs to absolute paths', () => { + const abs = deriveCacheKey(worklogDir, ['list'], '1.0.0'); + const rel = deriveCacheKey(path.relative(process.cwd(), worklogDir), ['list'], '1.0.0'); + expect(rel).toBe(abs); + }); + }); + + describe('computeDbFingerprint (WAL-aware)', () => { + it('reflects db, wal and shm mtime+size', () => { + const fp = computeDbFingerprint(worklogDir); + expect(fp.db).toEqual([expect.any(Number), expect.any(Number)]); + expect(fp.wal).toEqual([expect.any(Number), expect.any(Number)]); + expect(fp.shm).toEqual([expect.any(Number), expect.any(Number)]); + }); + + it('treats missing DB files as zeros (empty/never-initialized dir)', () => { + const emptyDir = createTempDir(); + try { + const fp = computeDbFingerprint(emptyDir); + expect(fp).toEqual({ db: [0, 0], wal: [0, 0], shm: [0, 0] }); + } finally { + cleanupTempDir(emptyDir); + } + }); + + it('changes when the -wal file changes (WAL-mode write)', () => { + const before = computeDbFingerprint(worklogDir); + simulateWalWrite(worklogDir); + const after = computeDbFingerprint(worklogDir); + expect(fingerprintsEqual(before, after)).toBe(false); + }); + + it('changes when worklog.db changes (checkpoint/rewrite)', () => { + const before = computeDbFingerprint(worklogDir); + simulateDbWrite(worklogDir); + const after = computeDbFingerprint(worklogDir); + expect(fingerprintsEqual(before, after)).toBe(false); + }); + + it('changes when the -shm file changes', () => { + const before = computeDbFingerprint(worklogDir); + const files = dbFilesForWorklogDir(worklogDir); + fs.appendFileSync(files.shm, 'SHM-CHUNK'); + const after = computeDbFingerprint(worklogDir); + expect(fingerprintsEqual(before, after)).toBe(false); + }); + }); + + describe('get/set round-trip', () => { + it('returns null on a cache miss', () => { + const cache = new ReadCache({ cacheDir }); + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(cache.stats().misses).toBe(1); + expect(cache.stats().hits).toBe(0); + }); + + it('round-trips a JSON value', () => { + const cache = new ReadCache({ cacheDir }); + const value = { success: true, count: 2, workItems: [{ id: 'WI-1' }, { id: 'WI-2' }] }; + cache.set(worklogDir, ['list', '--json'], value); + expect(cache.get(worklogDir, ['list', '--json'])).toEqual(value); + expect(cache.stats().hits).toBe(1); + }); + + it('round-trips a string value (exact CLI JSON output)', () => { + const cache = new ReadCache({ cacheDir }); + const output = '{"success":true,"count":1,"workItems":[]}'; + cache.set(worklogDir, ['list', '--json'], output); + expect(cache.get(worklogDir, ['list', '--json'])).toBe(output); + }); + + it('keeps arg variants separated (no cross-contamination)', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { scope: 'all' }); + cache.set(worklogDir, ['list', '--json', '--root-only'], { scope: 'root-only' }); + cache.set(worklogDir, ['list', '--status', 'open'], { scope: 'open' }); + expect(cache.get(worklogDir, ['list', '--json'])).toEqual({ scope: 'all' }); + expect(cache.get(worklogDir, ['list', '--json', '--root-only'])).toEqual({ scope: 'root-only' }); + expect(cache.get(worklogDir, ['list', '--status', 'open'])).toEqual({ scope: 'open' }); + }); + + it('keeps different worklog dirs separated', () => { + const otherDir = createTempDir(); + try { + createFakeWorklogDir(otherDir); + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['next', '--json'], { dir: 'A' }); + cache.set(otherDir, ['next', '--json'], { dir: 'B' }); + expect(cache.get(worklogDir, ['next', '--json'])).toEqual({ dir: 'A' }); + expect(cache.get(otherDir, ['next', '--json'])).toEqual({ dir: 'B' }); + } finally { + cleanupTempDir(otherDir); + } + }); + + it('creates the cache dir on demand', () => { + const nested = path.join(cacheDir, 'nested', 'deep'); + const cache = new ReadCache({ cacheDir: nested }); + cache.set(worklogDir, ['list'], { ok: true }); + expect(fs.existsSync(nested)).toBe(true); + expect(cache.get(worklogDir, ['list'])).toEqual({ ok: true }); + }); + }); + + describe('WAL-aware invalidation', () => { + it('invalidates when the -wal file changes (write-then-read is fresh)', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { v: 1 }); + + // A write lands in the WAL: cached entry must NOT be served. + simulateWalWrite(worklogDir); + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + + // New data is cached and served immediately. + cache.set(worklogDir, ['list', '--json'], { v: 2 }); + expect(cache.get(worklogDir, ['list', '--json'])).toEqual({ v: 2 }); + }); + + it('invalidates when worklog.db changes (checkpoint)', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['show', 'WI-1', '--json'], { title: 'old' }); + simulateDbWrite(worklogDir); + expect(cache.get(worklogDir, ['show', 'WI-1', '--json'])).toBeNull(); + }); + + it('invalidates when -shm changes', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['search', 'bug'], { n: 1 }); + const files = dbFilesForWorklogDir(worklogDir); + fs.appendFileSync(files.shm, 'SHM-CHUNK'); + expect(cache.get(worklogDir, ['search', 'bug'])).toBeNull(); + }); + + it('invalidates when the DB files first appear (fresh DB creation)', () => { + const emptyDir = createTempDir(); + try { + const cache = new ReadCache({ cacheDir }); + cache.set(emptyDir, ['list', '--json'], { v: 1 }); + expect(cache.get(emptyDir, ['list', '--json'])).toEqual({ v: 1 }); + + // DB appears → fingerprint goes from zeros to real values → invalidate. + createFakeWorklogDir(emptyDir); + expect(cache.get(emptyDir, ['list', '--json'])).toBeNull(); + } finally { + cleanupTempDir(emptyDir); + } + }); + + it('only invalidates entries for the touched worklog dir', () => { + const otherDir = createTempDir(); + try { + createFakeWorklogDir(otherDir); + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { dir: 'A' }); + cache.set(otherDir, ['list', '--json'], { dir: 'B' }); + + simulateWalWrite(worklogDir); + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(cache.get(otherDir, ['list', '--json'])).toEqual({ dir: 'B' }); + } finally { + cleanupTempDir(otherDir); + } + }); + + it('stores a caller-provided fingerprint (captured before the query)', () => { + const cache = new ReadCache({ cacheDir }); + const fpBefore = computeDbFingerprint(worklogDir); + const value = { v: 1 }; + cache.set(worklogDir, ['list', '--json'], value, { dbFingerprint: fpBefore }); + + // Still served while the DB is unchanged… + expect(cache.get(worklogDir, ['list', '--json'])).toEqual(value); + // …and invalidated the moment the DB changes (write-then-read is fresh). + simulateWalWrite(worklogDir); + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + }); + }); + + describe('TTL staleness', () => { + it('serves entries within TTL and rejects after expiry', () => { + let now = 1_000_000; + const cache = new ReadCache({ cacheDir, ttlMs: 30_000, now: () => now }); + cache.set(worklogDir, ['list', '--json'], { v: 1 }); + + now += 29_999; + expect(cache.get(worklogDir, ['list', '--json'])).toEqual({ v: 1 }); + + now += 2; // 30_001ms elapsed → past TTL + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(cache.stats().misses).toBe(1); + }); + + it('a hit does not extend the TTL window (createdAt is the base)', () => { + let now = 1_000_000; + const cache = new ReadCache({ cacheDir, ttlMs: 10_000, now: () => now }); + cache.set(worklogDir, ['list'], { v: 1 }); + + now += 9_000; + expect(cache.get(worklogDir, ['list'])).toEqual({ v: 1 }); // hit, touches access time + now += 2_000; + expect(cache.get(worklogDir, ['list'])).toBeNull(); // 11s since creation → stale + }); + + it('defaults TTL to 30s and max entries to 1000', () => { + expect(DEFAULT_TTL_MS).toBe(30_000); + expect(DEFAULT_MAX_ENTRIES).toBe(1_000); + }); + }); + + describe('bounded cache (LRU purge)', () => { + it('purges oldest entries when over the bound', () => { + const cache = new ReadCache({ cacheDir, maxEntries: 5, ttlMs: 60_000 }); + for (let i = 0; i < 8; i++) { + cache.set(worklogDir, ['list', '--json', '--page', String(i)], { i }); + } + const files = listEntryFiles(cacheDir); + expect(files.length).toBeLessThanOrEqual(5); + }); + + it('keeps recently accessed entries and evicts least-recently used', () => { + // Each set advances the injected clock so accessedAt ordering is deterministic. + let now = 1_000_000; + const cache = new ReadCache({ cacheDir, maxEntries: 5, ttlMs: 60_000, now: () => now }); + const setP = (i: number) => { + now += 1; + cache.set(worklogDir, ['list', '--p', String(i)], { i }); + }; + for (let i = 0; i < 5; i++) setP(i); // accessedAt 1_000_001..1_000_005 + + // Touch entry 0 (most recent access) then add 3 more: with maxEntries=5, + // adding 5,6,7 evicts the three oldest untouched entries 1,2,3. + now += 1_000; + expect(cache.get(worklogDir, ['list', '--p', '0'])).toEqual({ i: 0 }); // accessedAt → 1_001_005 + for (let i = 5; i < 8; i++) setP(i); // accessedAt 1_001_006..1_001_008 + + expect(cache.get(worklogDir, ['list', '--p', '0'])).toEqual({ i: 0 }); // touched → survived + expect(cache.get(worklogDir, ['list', '--p', '1'])).toBeNull(); // oldest → evicted + expect(cache.get(worklogDir, ['list', '--p', '4'])).toEqual({ i: 4 }); // newest of the originals → survived + expect(cache.get(worklogDir, ['list', '--p', '7'])).toEqual({ i: 7 }); + }); + + it('evicts TTL-expired entries during purge', () => { + // maxEntries=1: B's set pushes the count over the bound, forcing a purge + // in which the TTL-expired entry A is removed. + let now = 1_000_000; + const cache = new ReadCache({ cacheDir, maxEntries: 1, ttlMs: 10_000, now: () => now }); + cache.set(worklogDir, ['list', '--a'], { a: 1 }); + now += 11_000; // entry A expires + cache.set(worklogDir, ['list', '--b'], { b: 2 }); // triggers purge; expired A removed + const files = listEntryFiles(cacheDir); + expect(files.length).toBe(1); + expect(cache.get(worklogDir, ['list', '--a'])).toBeNull(); + expect(cache.get(worklogDir, ['list', '--b'])).toEqual({ b: 2 }); + }); + }); + + describe('atomicity and corruption handling', () => { + it('leaves no temp files behind after set', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { ok: true }); + const leftovers = fs.readdirSync(cacheDir).filter((f) => f.includes('.tmp-') || f.endsWith('.tmp')); + expect(leftovers).toEqual([]); + }); + + it('serves a partial/corrupt entry as a miss and removes it', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list', '--json'], { ok: true }); + const files = listEntryFiles(cacheDir); + expect(files.length).toBe(1); + fs.writeFileSync(path.join(cacheDir, files[0]), '{"key": "truncated'); + + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(listEntryFiles(cacheDir).length).toBe(0); // corrupt file removed + }); + + it('treats a header mismatch (foreign/other-key file) as a miss', () => { + const cache = new ReadCache({ cacheDir }); + // Write an entry, then tamper with the stored key header. + cache.set(worklogDir, ['list', '--json'], { ok: true }); + const files = listEntryFiles(cacheDir); + const p = path.join(cacheDir, files[0]); + const entry = JSON.parse(fs.readFileSync(p, 'utf-8')); + entry.key = 'f'.repeat(64); + fs.writeFileSync(p, JSON.stringify(entry)); + + expect(cache.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(listEntryFiles(cacheDir).length).toBe(0); + }); + }); + + describe('invalidate/clear', () => { + it('invalidate(worklogDir) removes only that dir entries', () => { + const otherDir = createTempDir(); + try { + createFakeWorklogDir(otherDir); + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list'], { a: 1 }); + cache.set(worklogDir, ['next'], { a: 2 }); + cache.set(otherDir, ['list'], { b: 1 }); + + const removed = cache.invalidate(worklogDir); + expect(removed).toBe(2); + expect(cache.get(worklogDir, ['list'])).toBeNull(); + expect(cache.get(worklogDir, ['next'])).toBeNull(); + expect(cache.get(otherDir, ['list'])).toEqual({ b: 1 }); // untouched + } finally { + cleanupTempDir(otherDir); + } + }); + + it('clear() removes all entries', () => { + const cache = new ReadCache({ cacheDir }); + cache.set(worklogDir, ['list'], { a: 1 }); + cache.set(worklogDir, ['next'], { a: 2 }); + const removed = cache.clear(); + expect(removed).toBe(2); + expect(listEntryFiles(cacheDir).length).toBe(0); + }); + }); + + describe('concurrency', () => { + it('survives concurrent in-process readers/writers across instances', async () => { + // Multiple cache instances = separate "processes" sharing one cache dir. + const instances = Array.from({ length: 6 }, () => new ReadCache({ cacheDir })); + const tasks: Promise[] = []; + for (let inst = 0; inst < instances.length; inst++) { + const cache = instances[inst]; + for (let round = 0; round < 25; round++) { + tasks.push( + Promise.resolve().then(() => { + const argv = ['list', '--json', '--inst', String(inst), '--round', String(round)]; + cache.set(worklogDir, argv, { inst, round }); + const got = cache.get(worklogDir, argv); + expect(got).toEqual({ inst, round }); + }), + ); + } + } + await Promise.all(tasks); + // All entries written atomically — every file parses and has a valid key. + for (const f of listEntryFiles(cacheDir)) { + const entry = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf-8')); + expect(entry.key).toBe(f.replace(/\.json$/, '')); + expect(typeof entry.value).toBe('object'); + } + }); + + it('survives concurrent cross-process readers/writers (spawned via tsx)', async () => { + const childScript = path.resolve(__dirname, 'read-cache-concurrent-child.ts'); + const tsxBin = path.resolve(__dirname, '..', 'node_modules', '.bin', 'tsx'); + const workers = 4; + const rounds = 15; + const run = (id: number) => + new Promise((resolve, reject) => { + const child = childProcess.spawn(tsxBin, [childScript, String(id), String(rounds)], { + env: { + ...process.env, + CACHE_DIR: cacheDir, + WORKLOG_DIR: worklogDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr.on('data', (d) => (stderr += String(d))); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`child ${id} exited ${code}: ${stderr.slice(0, 500)}`)); + }); + }); + + await Promise.all(Array.from({ length: workers }, (_, i) => run(i))); + + // Post-conditions: no temp files, every entry parses, unique keys round-tripped. + const leftovers = fs.readdirSync(cacheDir).filter((f) => f.includes('.tmp-')); + expect(leftovers).toEqual([]); + for (const f of listEntryFiles(cacheDir)) { + const entry = JSON.parse(fs.readFileSync(path.join(cacheDir, f), 'utf-8')); + expect(entry.key).toBe(f.replace(/\.json$/, '')); + expect(typeof entry.value).toBe('object'); + } + // The shared key entry was written by all workers — final file must be valid. + const shared = new ReadCache({ cacheDir }).get(worklogDir, ['list', '--json']); + expect(shared).not.toBeNull(); + expect(typeof (shared as any).writer).toBe('string'); + }); + }); + + describe('version guard', () => { + it('does not serve entries written under a different wl version', () => { + const cacheV1 = new ReadCache({ cacheDir, version: '1.0.7' }); + const cacheV2 = new ReadCache({ cacheDir, version: '1.0.8' }); + cacheV1.set(worklogDir, ['list', '--json'], { v: 'old-schema' }); + expect(cacheV2.get(worklogDir, ['list', '--json'])).toBeNull(); + expect(cacheV1.get(worklogDir, ['list', '--json'])).toEqual({ v: 'old-schema' }); + }); + }); +}); From f78e53ccdc9d514447c4195e2fee4c6384fb2c99 Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 5 Aug 2026 23:26:10 +0100 Subject: [PATCH 04/59] WL-0MSGNDSCR0096MCR: wrap single git worktree add with unborn-HEAD translation (fixes duplicate worktree add) --- src/sync.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/sync.ts b/src/sync.ts index f6b62d820..c10a2e9cf 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -935,7 +935,26 @@ async function withTempWorktree( const baseRef = hasRemote ? remoteTrackingRef : 'HEAD'; try { - await execAsync(`git worktree add --detach ${escapeShellArg(worktreePath)} ${escapeShellArg(baseRef)}`); + try { + await execAsync(`git worktree add --detach ${escapeShellArg(worktreePath)} ${escapeShellArg(baseRef)}`); + } catch (err) { + // Unborn-HEAD translation (SA-0MSG57UNY009DE51): when the worktree + // would be created from local HEAD on a repo with no commits yet, git + // fails with a cryptic error. Surface an actionable message naming the + // cause and remedy instead. + if (baseRef === 'HEAD') { + const raw = (err as Error).message || ''; + if (/HEAD/.test(raw) && /(not a commit|invalid reference|cannot be created from|not a valid object name)/i.test(raw)) { + throw new Error( + 'Cannot sync: this repository has no commits yet, so git cannot create ' + + 'a temporary worktree from HEAD. Create an initial commit first ' + + '(e.g. `git commit --allow-empty -m "chore: initial"`), or run ' + + '`wl sync --no-push` to keep worklog data local.' + ); + } + } + throw err; + } // If remote branch doesn't exist, create an orphan branch in the temp worktree. if (!hasRemote) { From ccf58d6f11e17e5cf101962a8812f73e076e3526 Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 5 Aug 2026 23:34:27 +0100 Subject: [PATCH 05/59] WL-0MSG7ZTC000163FL: Add downtime worker test suite (test-first contract) with fixtures --- .../herdr/src/downtime-worker.fixtures.ts | 136 +++++++ packages/herdr/src/downtime-worker.test.ts | 334 ++++++++++++++++++ packages/herdr/src/downtime-worker.ts | 264 ++++++++++++++ 3 files changed, 734 insertions(+) create mode 100644 packages/herdr/src/downtime-worker.fixtures.ts create mode 100644 packages/herdr/src/downtime-worker.test.ts create mode 100644 packages/herdr/src/downtime-worker.ts diff --git a/packages/herdr/src/downtime-worker.fixtures.ts b/packages/herdr/src/downtime-worker.fixtures.ts new file mode 100644 index 000000000..682a6cc72 --- /dev/null +++ b/packages/herdr/src/downtime-worker.fixtures.ts @@ -0,0 +1,136 @@ +/** + * packages/herdr/src/downtime-worker.fixtures.ts — mock fixtures for the + * downtime worker tests + * + * Covers every response class for `GET /llama/local/status` required by the + * test contract (WL-0MSG7ZTC000163FL): idle (all slots free), busy (active + * query / model switch / local lease / server down / not all slots free), + * ambiguous (`total_slots` 0 or missing fields), and network error / + * timeout / HTTP-failure responses for the poller tests (F2). + */ + +import type { LlamaStatus } from './downtime-worker.js'; + +// ── Status payloads ─────────────────────────────────────────────────── + +export const idleAllSlotsFree: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + current_model: 'qwen3-8b', +}; + +export const busyActiveQuery: LlamaStatus = { ...idleAllSlotsFree, active_query: true }; +export const busyModelSwitch: LlamaStatus = { ...idleAllSlotsFree, model_switch_in_progress: true }; +export const busyLocalLease: LlamaStatus = { ...idleAllSlotsFree, local_lease_active: true }; +export const busyServerNotRunning: LlamaStatus = { ...idleAllSlotsFree, llama_server_running: false }; + +/** available_slots < total_slots → busy with the default N=0 (all slots). */ +export const busyNotAllSlotsFree: LlamaStatus = { ...idleAllSlotsFree, available_slots: 2 }; + +/** `total_slots` 0 with everything else idle → ambiguous → busy. */ +export const ambiguousZeroTotalSlots: LlamaStatus = { + ...idleAllSlotsFree, + available_slots: 0, + total_slots: 0, +}; + +/** Raw (unparsed) response missing the numeric slot fields → ambiguous. */ +export const ambiguousMissingFieldsRaw: Record = { + llama_server_running: true, + current_model: 'qwen3-8b', +}; + +export interface LlamaStatusFixture { + name: string; + status: LlamaStatus; + /** Expected classification with requiredFreeSlots = 0 (all slots). */ + expectedIdle: boolean; + note: string; +} + +/** The corpus used by the fixture-coherence test. */ +export const statusFixtures: LlamaStatusFixture[] = [ + { + name: 'idle: all slots free', + status: idleAllSlotsFree, + expectedIdle: true, + note: 'server up, no query/switch/lease, all 4/4 slots free', + }, + { + name: 'busy: active query', + status: busyActiveQuery, + expectedIdle: false, + note: 'active_query true', + }, + { + name: 'busy: model switch in progress', + status: busyModelSwitch, + expectedIdle: false, + note: 'model_switch_in_progress true', + }, + { + name: 'busy: local lease active', + status: busyLocalLease, + expectedIdle: false, + note: 'local_lease_active true', + }, + { + name: 'busy: server not running', + status: busyServerNotRunning, + expectedIdle: false, + note: 'llama_server_running false', + }, + { + name: 'busy: not all slots free', + status: busyNotAllSlotsFree, + expectedIdle: false, + note: 'available 2 < total 4 → busy with N=0 (all slots)', + }, + { + name: 'ambiguous: total_slots 0', + status: ambiguousZeroTotalSlots, + expectedIdle: false, + note: 'total_slots 0 → ambiguous → busy', + }, +]; + +// ── HTTP response / error fixtures (poller tests, F2) ───────────────── + +/** + * Minimal structural Response for the poller contract (the DOM `Response` + * type is not part of this project's tsconfig lib). Node's real `Response` + * satisfies the shape at runtime. + */ +export interface LlamaStatusHttpResponse { + ok: boolean; + status: number; + json(): Promise; +} + +export function jsonResponseFixture(body: unknown, status = 200): LlamaStatusHttpResponse { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + }; +} + +export const httpErrorResponseFixture: LlamaStatusHttpResponse = jsonResponseFixture( + { error: 'boom' }, + 500, +); + +/** fetch() rejecting (connection refused / DNS failure). */ +export const networkErrorFixture: Error = Object.assign(new Error('fetch failed'), { + name: 'TypeError', +}); + +/** fetch() aborting on timeout. */ +export const timeoutErrorFixture: Error = Object.assign( + new Error('The operation was aborted'), + { name: 'AbortError' }, +); diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts new file mode 100644 index 000000000..faab10409 --- /dev/null +++ b/packages/herdr/src/downtime-worker.test.ts @@ -0,0 +1,334 @@ +/** + * Unit tests for downtime-worker.ts — Local-LLM downtime worker contract + * + * Test-first contract for the herdr downtime worker (WL-0MSG7ZTC000163FL, + * parent WL-0MSF49FMW009M06K). Follows the repo convention of a dedicated + * test-suite feature created before the implementation features + * (cf. WL-0MQD1N3JD007B0FZ). + * + * Green suites test the parts implemented in this feature (idle detection, + * blocked-questions prompt, settings clamps, fixture coherence). The + * `describe.skip` blocks define the remaining contract matrix; their owning + * implementation features flip them back on: + * + * - endpoint failures / poller → WL-0MSG80254005ZNE9 (F2) + * - threshold timing / dispatch / + * single-flight → WL-0MSG80AG700429M8 (F3) + * + * The current stubs are fail-closed (never dispatch, never throw), which is + * the safe default until the full worker lands. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + isIdleStatus, + buildDowntimePrompt, + BLOCKED_QUESTIONS_INSTRUCTION, + clampDowntimePollInterval, + clampDowntimeIdleThresholdMs, + clampDowntimeRequiredFreeSlots, + DOWNTIME_POLL_INTERVAL_FLOOR_MS, + DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + createIdleTracker, + createDowntimePoller, + dispatchDowntimeWork, + type LlamaStatus, + type DowntimeCandidate, +} from './downtime-worker.js'; +import { + statusFixtures, + ambiguousMissingFieldsRaw, + idleAllSlotsFree, + networkErrorFixture, + timeoutErrorFixture, + httpErrorResponseFixture, + jsonResponseFixture, +} from './downtime-worker.fixtures.js'; + +// ── Fixture coherence (AC1) ─────────────────────────────────────────── + +describe('downtime worker fixtures', () => { + it.each(statusFixtures)( + '$name is classified idle=$expectedIdle by isIdleStatus (N=0)', + (fixture) => { + expect(isIdleStatus(fixture.status, 0)).toBe(fixture.expectedIdle); + }, + ); + + it('a raw payload with missing slot fields is treated as busy (fail-closed)', () => { + const partial = ambiguousMissingFieldsRaw as unknown as LlamaStatus; + expect(isIdleStatus(partial, 0)).toBe(false); + }); +}); + +// ── Idle detection (AC2) ────────────────────────────────────────────── + +describe('idle detection (isIdleStatus)', () => { + const idle: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + + it('is idle when the server is up, nothing active, and all slots free', () => { + expect(isIdleStatus(idle, 0)).toBe(true); + }); + + it('is busy when llama-server is not running', () => { + expect(isIdleStatus({ ...idle, llama_server_running: false }, 0)).toBe(false); + }); + + it('is busy while a query is active', () => { + expect(isIdleStatus({ ...idle, active_query: true }, 0)).toBe(false); + }); + + it('is busy while a model switch is in progress', () => { + expect(isIdleStatus({ ...idle, model_switch_in_progress: true }, 0)).toBe(false); + }); + + it('is busy while a local lease is active', () => { + expect(isIdleStatus({ ...idle, local_lease_active: true }, 0)).toBe(false); + }); + + it('N=0 (default) requires ALL slots free', () => { + expect(isIdleStatus({ ...idle, available_slots: 3 }, 0)).toBe(false); + expect(isIdleStatus({ ...idle, available_slots: 4 }, 0)).toBe(true); + }); + + it('a positive N requires at least N slots free', () => { + expect(isIdleStatus({ ...idle, available_slots: 2 }, 2)).toBe(true); + expect(isIdleStatus({ ...idle, available_slots: 1 }, 2)).toBe(false); + }); + + it('N > total_slots can never be idle (never dispatches)', () => { + expect(isIdleStatus({ ...idle, available_slots: 4 }, 5)).toBe(false); + }); + + it('ambiguous responses (total_slots 0 or missing fields) are busy', () => { + expect(isIdleStatus({ ...idle, total_slots: 0, available_slots: 0 }, 0)).toBe(false); + expect(isIdleStatus({ llama_server_running: true } as LlamaStatus, 0)).toBe(false); + }); + + it('non-finite or negative slot counts are busy', () => { + expect(isIdleStatus({ ...idle, total_slots: Number.NaN }, 0)).toBe(false); + expect(isIdleStatus({ ...idle, available_slots: -1 }, 0)).toBe(false); + }); +}); + +// ── Threshold timing (AC3) — implemented in F3 (WL-0MSG80AG700429M8) ── + +describe.skip('threshold timing (idle-duration tracker)', () => { + const thresholdMs = 240_000; + + it('dispatches only after idle has lasted the full threshold continuously', () => { + vi.useFakeTimers(); + const tracker = createIdleTracker(); + const start = Date.now(); + + tracker.record(true, start); + expect(tracker.idleSince).toBe(start); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs - 1)).toBe(false); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); + + vi.useRealTimers(); + }); + + it('any busy poll resets the idle-since timestamp', () => { + vi.useFakeTimers(); + const tracker = createIdleTracker(); + const start = Date.now(); + + tracker.record(true, start); + tracker.record(false, start + 120_000); + expect(tracker.idleSince).toBeNull(); + expect(tracker.isThresholdMet(thresholdMs, start + 360_000)).toBe(false); + + vi.useRealTimers(); + }); +}); + +// ── Dispatch selection (AC4) — implemented in F3 (WL-0MSG80AG700429M8) ─ + +describe.skip('dispatch selection', () => { + it('runs /skill:plan on the next intake_complete item', async () => { + const getNextItem = vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }); + const spawnAgentPane = vi.fn().mockResolvedValue(undefined); + + const outcome = await dispatchDowntimeWork( + { getNextItem, spawnAgentPane }, + { model: 'plan', cwd: '/repo' }, + ); + + expect(getNextItem).toHaveBeenCalledWith('intake_complete'); + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('plan'); + expect(spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:plan WL-ABC'), + { model: 'plan', cwd: '/repo' }, + ); + }); + + it('falls back to /skill:intake on the next idea item when none is intake_complete', async () => { + const getNextItem = vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'WL-DEF', title: 'An idea', stage: 'idea' }); + const spawnAgentPane = vi.fn().mockResolvedValue(undefined); + + const outcome = await dispatchDowntimeWork( + { getNextItem, spawnAgentPane }, + { model: 'plan', cwd: '/repo' }, + ); + + expect(getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + expect(outcome.kind).toBe('intake'); + expect(spawnAgentPane).toHaveBeenCalledWith( + expect.stringContaining('/skill:intake WL-DEF'), + expect.anything(), + ); + }); + + it('does not dispatch when no item exists in either stage', async () => { + const getNextItem = vi.fn().mockResolvedValue(null); + const spawnAgentPane = vi.fn().mockResolvedValue(undefined); + + const outcome = await dispatchDowntimeWork( + { getNextItem, spawnAgentPane }, + { model: 'plan', cwd: '/repo' }, + ); + + expect(outcome.dispatched).toBe(false); + expect(spawnAgentPane).not.toHaveBeenCalled(); + }); +}); + +// ── Single-flight (AC5) — implemented in F3 (WL-0MSG80AG700429M8) ───── + +describe.skip('single-flight dispatch guard', () => { + it('does not dispatch a second time while one dispatch is in flight', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const getNextItem = vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }); + const spawnAgentPane = vi.fn().mockImplementation(() => gate); + + const first = dispatchDowntimeWork( + { getNextItem, spawnAgentPane }, + { model: 'plan', cwd: '/repo' }, + ); + const second = dispatchDowntimeWork( + { getNextItem, spawnAgentPane }, + { model: 'plan', cwd: '/repo' }, + ); + release(); + const [firstOutcome, secondOutcome] = await Promise.all([first, second]); + + expect(spawnAgentPane).toHaveBeenCalledTimes(1); + expect(firstOutcome.dispatched).toBe(true); + expect(secondOutcome.dispatched).toBe(false); + }); +}); + +// ── Endpoint failures (AC6) — implemented in F2 (WL-0MSG80254005ZNE9) ─ + +describe.skip('endpoint failures', () => { + it('network errors are treated as busy and never throw', async () => { + const fetcher = vi.fn().mockRejectedValue(networkErrorFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('timeouts are treated as busy and never throw', async () => { + const fetcher = vi.fn().mockRejectedValue(timeoutErrorFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('HTTP error statuses are treated as busy', async () => { + const fetcher = vi.fn().mockResolvedValue(httpErrorResponseFixture); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('polls GET {proxyUrl}/llama/local/status', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await poller.poll(); + expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); + }); +}); + +// ── Blocked-questions prompt (AC7) ──────────────────────────────────── + +describe('blocked-questions prompt instruction', () => { + const candidate: DowntimeCandidate = { + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }; + + it('the plan prompt runs /skill:plan on the item id', () => { + const prompt = buildDowntimePrompt('plan', candidate); + expect(prompt).toContain('/skill:plan WL-ABC'); + }); + + it('the intake prompt runs /skill:intake on the item id', () => { + const prompt = buildDowntimePrompt('intake', { ...candidate, stage: 'idea' }); + expect(prompt).toContain('/skill:intake WL-ABC'); + }); + + it('instructs recording questions in a comment, flagging needs-producer-review, and stopping', () => { + const prompt = buildDowntimePrompt('plan', candidate); + expect(prompt).toContain('comment'); + expect(prompt).toContain('--needs-producer-review true'); + expect(prompt).toContain('stop'); + expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl comment add'); + expect(BLOCKED_QUESTIONS_INSTRUCTION).toContain('wl update --needs-producer-review true'); + }); +}); + +// ── Settings clamps (AC8) ───────────────────────────────────────────── + +describe('downtime settings clamps', () => { + it('clampDowntimePollInterval enforces the 10s floor and keeps valid values', () => { + expect(clampDowntimePollInterval(5_000)).toBe(DOWNTIME_POLL_INTERVAL_FLOOR_MS); + expect(clampDowntimePollInterval(10_000)).toBe(10_000); + expect(clampDowntimePollInterval(45_000)).toBe(45_000); + }); + + it('clampDowntimePollInterval falls back to the 30s default for non-finite values', () => { + expect(clampDowntimePollInterval(Number.NaN)).toBe(DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + expect(clampDowntimePollInterval(Infinity)).toBe(DEFAULT_DOWNTIME_POLL_INTERVAL_MS); + }); + + it('clampDowntimeIdleThresholdMs rejects negative and non-finite values', () => { + expect(clampDowntimeIdleThresholdMs(-1)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + expect(clampDowntimeIdleThresholdMs(Number.NaN)).toBe(DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS); + }); + + it('clampDowntimeIdleThresholdMs keeps valid values and floors at 1s', () => { + expect(clampDowntimeIdleThresholdMs(240_000)).toBe(240_000); + expect(clampDowntimeIdleThresholdMs(0)).toBe(1_000); + }); + + it('clampDowntimeRequiredFreeSlots maps negative/non-finite to 0 (all slots) and rounds', () => { + expect(clampDowntimeRequiredFreeSlots(-3)).toBe(0); + expect(clampDowntimeRequiredFreeSlots(Number.NaN)).toBe(0); + expect(clampDowntimeRequiredFreeSlots(2.7)).toBe(3); + expect(clampDowntimeRequiredFreeSlots(2)).toBe(2); + }); +}); diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts new file mode 100644 index 000000000..571a1602d --- /dev/null +++ b/packages/herdr/src/downtime-worker.ts @@ -0,0 +1,264 @@ +/** + * packages/herdr/src/downtime-worker.ts — Local-LLM downtime worker (contract) + * + * Test-first contract for the herdr downtime worker (parent + * WL-0MSF49FMW009M06K). This module defines the exported contracts the + * implementation features must satisfy, plus the PURE parts that are fully + * implemented here: + * + * - `isIdleStatus` — idle detection from a `/llama/local/status` payload. + * - `buildDowntimePrompt` / `BLOCKED_QUESTIONS_INSTRUCTION` — dispatched + * agent prompt, including the blocked-questions instruction. + * - `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` / + * `clampDowntimeRequiredFreeSlots` — settings clamps. The settings keys + * and load/merge wiring live in `settings.ts` (WL-0MSG80254005ZNE9). + * + * The stateful / side-effectful parts are FAIL-CLOSED stubs whose contracts + * are implemented by the downstream features: + * + * - `createDowntimePoller` — real HTTP poller for `GET {proxyUrl}/llama/ + * local/status` in WL-0MSG80254005ZNE9 (F2). + * - `createIdleTracker` — real idle-duration tracker in + * WL-0MSG80AG700429M8 (F3). + * - `dispatchDowntimeWork` — real dispatch orchestration (`wl next`, + * send-to-pi.sh pane spawn, pre-dispatch claim, single-flight) in + * WL-0MSG80AG700429M8 (F3). + * + * Fail-closed stub behaviour (never dispatch, never throw) is the SAFE + * default: until the full worker lands, the plugin must not dispatch work + * based on unverified logic. + */ + +// ── Constants ───────────────────────────────────────────────────────── + +/** Proxy status endpoint path. */ +export const DOWNTIME_STATUS_PATH = '/llama/local/status'; + +/** Hard floor for the poll interval (must not hammer the proxy). */ +export const DOWNTIME_POLL_INTERVAL_FLOOR_MS = 10_000; + +/** Defensive floor for the idle threshold (prevents immediate dispatch). */ +export const DOWNTIME_IDLE_THRESHOLD_FLOOR_MS = 1_000; + +export const DEFAULT_DOWNTIME_POLL_INTERVAL_MS = 30_000; +export const DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS = 240_000; + +/** 0 = all slots must be free (default). Any positive integer N is accepted. */ +export const DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS = 0; + +export const DEFAULT_DOWNTIME_PROXY_URL = 'http://192.168.0.199:8000'; +export const DEFAULT_DOWNTIME_MODEL = 'plan'; + +// ── Types ───────────────────────────────────────────────────────────── + +/** Shape of `GET /llama/local/status` as served by the llama-proxy. */ +export interface LlamaStatus { + llama_server_running: boolean; + active_query: boolean; + model_switch_in_progress: boolean; + local_lease_active: boolean; + available_slots: number; + total_slots: number; + current_model?: string; + local_owner_session_id?: string | null; + local_owner_lease_remaining_seconds?: number | null; +} + +// ── Idle detection (implemented) ────────────────────────────────────── + +/** + * True when the proxy reports an idle state for the required free-slot + * count: + * + * - llama-server is running, + * - no active query, model switch, or local lease, + * - `requiredFreeSlots <= 0` (default) requires ALL slots free + * (`available_slots >= total_slots`) with `total_slots > 0`, + * - a positive N requires at least N slots free; N > total_slots can never + * be satisfied, so it is never idle, + * - ambiguous responses (missing/non-finite fields, `total_slots` 0) are + * busy (fail-closed). + */ +export function isIdleStatus(status: LlamaStatus, requiredFreeSlots: number): boolean { + if (!status.llama_server_running) return false; + if (status.active_query) return false; + if (status.model_switch_in_progress) return false; + if (status.local_lease_active) return false; + + const total = status.total_slots; + if (!Number.isFinite(total) || total <= 0) return false; // ambiguous → busy + + const available = status.available_slots; + if (!Number.isFinite(available) || available < 0) return false; + + const required = requiredFreeSlots <= 0 ? total : requiredFreeSlots; + if (required > total) return false; // N > total_slots can never be idle + return available >= required; +} + +// ── Idle-duration tracker (stub — F3) ───────────────────────────────── + +export interface IdleTracker { + /** Idle-since timestamp (ms), or null when not currently in an idle run. */ + readonly idleSince: number | null; + /** + * Record one poll result. `isIdle` true advances the idle run; false + * resets it (idleSince = null). + */ + record(isIdle: boolean, now?: number): void; + /** + * True once the current idle run has lasted `thresholdMs` or longer + * continuously. + */ + isThresholdMet(thresholdMs: number, now?: number): boolean; +} + +/** + * FAIL-CLOSED stub (implemented in WL-0MSG80AG700429M8): never reports an + * idle threshold met, so no dispatch can occur until the real tracker lands. + */ +export function createIdleTracker(): IdleTracker { + return { + idleSince: null, + record(_isIdle: boolean, _now?: number): void { + // stub + }, + isThresholdMet(_thresholdMs: number, _now?: number): boolean { + return false; + }, + }; +} + +// ── Poller (stub — F2) ──────────────────────────────────────────────── + +/** + * Minimal structural fetch signature (the DOM `fetch` type is not part of + * this project's tsconfig lib). Node's global `fetch` satisfies it at + * runtime. + */ +export type LlamaStatusFetcher = ( + url: string, + init?: { signal?: unknown }, +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +export interface DowntimePoller { + /** + * Poll `GET {proxyUrl}/llama/local/status` once. Resolves to the parsed + * status, or null when the endpoint failed/timed out or the payload was + * ambiguous (both cases are treated as busy by the caller). + */ + poll(): Promise; + /** True while a poll is in flight (single-flight guard for the loop). */ + isPolling(): boolean; +} + +/** + * FAIL-CLOSED stub (implemented in WL-0MSG80254005ZNE9): resolves busy + * (null) without performing any network I/O. + */ +export function createDowntimePoller( + _proxyUrl: string, + _fetcher: LlamaStatusFetcher = globalThis.fetch as unknown as LlamaStatusFetcher, +): DowntimePoller { + return { + async poll(): Promise { + return null; + }, + isPolling(): boolean { + return false; + }, + }; +} + +// ── Dispatch (stub — F3) ────────────────────────────────────────────── + +export type DowntimeStage = 'intake_complete' | 'idea'; +export type DowntimeSkillKind = 'plan' | 'intake'; + +export interface DowntimeCandidate { + id: string; + title: string; + stage: DowntimeStage; +} + +/** External boundaries injected so the dispatch logic is testable. */ +export interface DowntimeWorkerDeps { + /** Runs `wl next --stage --json` and returns the first candidate or null. */ + getNextItem(stage: DowntimeStage): Promise; + /** Opens a visible pi agent pane running the prompt (via send-to-pi.sh). */ + spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise; +} + +export interface DowntimeDispatchOutcome { + dispatched: boolean; + candidate?: DowntimeCandidate; + kind?: DowntimeSkillKind; + reason?: string; +} + +/** + * FAIL-CLOSED stub (implemented in WL-0MSG80AG700429M8): never dispatches. + * The real implementation selects `wl next --stage intake_complete` → + * `/skill:plan ` (falling back to `--stage idea` → `/skill:intake + * `), opens a visible pane via `spawnAgentPane`, and honours the + * single-flight guard. + */ +export async function dispatchDowntimeWork( + _deps: DowntimeWorkerDeps, + _opts: { model: string; cwd: string }, +): Promise { + return { dispatched: false, reason: 'not-implemented' }; +} + +// ── Blocked-questions prompt (implemented) ──────────────────────────── + +/** + * Instruction appended to every dispatched prompt: when the skill run + * cannot proceed because answers are needed, record the questions in a + * comment on the work item, mark it as needing producer review, and stop — + * never block indefinitely (parent AC6). + */ +export const BLOCKED_QUESTIONS_INSTRUCTION = + 'If you cannot proceed because you need answers, record the questions in a ' + + 'comment on the work item (wl comment add --comment "question: ...") and ' + + 'mark the item as needing producer review (wl update --needs-producer-review ' + + 'true), then stop — do not block indefinitely.'; + +/** Build the prompt dispatched to a pi agent pane for the given candidate. */ +export function buildDowntimePrompt(kind: DowntimeSkillKind, candidate: DowntimeCandidate): string { + const skill = kind === 'plan' ? '/skill:plan' : '/skill:intake'; + return [ + `Run ${skill} ${candidate.id} — ${candidate.title}.`, + BLOCKED_QUESTIONS_INSTRUCTION, + ].join('\n'); +} + +// ── Settings clamps (implemented — wired into settings.ts by F2) ────── + +/** + * Clamp the downtime poll interval: never below the 10s hard floor, default + * 30s for non-finite input. + */ +export function clampDowntimePollInterval(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_DOWNTIME_POLL_INTERVAL_MS; + return Math.max(Math.round(value), DOWNTIME_POLL_INTERVAL_FLOOR_MS); +} + +/** + * Clamp the idle threshold: reject negative/non-finite (fall back to the + * 4-minute default) and floor at 1s to prevent immediate dispatch. + */ +export function clampDowntimeIdleThresholdMs(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS; + return Math.max(Math.round(value), DOWNTIME_IDLE_THRESHOLD_FLOOR_MS); +} + +/** + * Clamp the required-free-slots count: a non-negative integer; 0 = all + * slots. Negative/non-finite input falls back to 0 (all slots, the + * strictest safe default). + */ +export function clampDowntimeRequiredFreeSlots(value: number): number { + if (!Number.isFinite(value) || value < 0) return DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS; + return Math.round(value); +} From 74f9f9acf8a8dac64349b4cc31d81d05e9dc3569 Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 5 Aug 2026 23:41:31 +0100 Subject: [PATCH 06/59] WL-0MSG80254005ZNE9: Downtime settings (6 keys + clamps) and single-flight llama-proxy status poller --- packages/herdr/src/downtime-worker.test.ts | 152 +++++++++++++++++++-- packages/herdr/src/downtime-worker.ts | 140 ++++++++++++++++--- packages/herdr/src/settings.ts | 43 ++++++ tests/herdr/settings.test.ts | 54 ++++++++ 4 files changed, 361 insertions(+), 28 deletions(-) diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index faab10409..90ac786fc 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -6,22 +6,25 @@ * test-suite feature created before the implementation features * (cf. WL-0MQD1N3JD007B0FZ). * - * Green suites test the parts implemented in this feature (idle detection, - * blocked-questions prompt, settings clamps, fixture coherence). The - * `describe.skip` blocks define the remaining contract matrix; their owning - * implementation features flip them back on: + * Green suites test the parts implemented so far (idle detection, + * blocked-questions prompt, settings clamps, fixture coherence, and — since + * WL-0MSG80254005ZNE9 — the poller, fail-closed parsing and runtime idle + * evaluation). The `describe.skip` blocks define the remaining contract + * matrix; the owning implementation feature flips them back on: * - * - endpoint failures / poller → WL-0MSG80254005ZNE9 (F2) - * - threshold timing / dispatch / - * single-flight → WL-0MSG80AG700429M8 (F3) + * - threshold timing / dispatch / single-flight → WL-0MSG80AG700429M8 (F3) * - * The current stubs are fail-closed (never dispatch, never throw), which is - * the safe default until the full worker lands. + * The remaining stubs are fail-closed (never dispatch, never throw), which + * is the safe default until the full worker lands. */ import { describe, it, expect, vi } from 'vitest'; import { isIdleStatus, + evaluateIdle, + parseLlamaStatus, + fetchLocalStatus, + createDowntimePoller, buildDowntimePrompt, BLOCKED_QUESTIONS_INSTRUCTION, clampDowntimePollInterval, @@ -31,9 +34,9 @@ import { DEFAULT_DOWNTIME_POLL_INTERVAL_MS, DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, createIdleTracker, - createDowntimePoller, dispatchDowntimeWork, type LlamaStatus, + type LlamaStatusFetcher, type DowntimeCandidate, } from './downtime-worker.js'; import { @@ -44,6 +47,7 @@ import { timeoutErrorFixture, httpErrorResponseFixture, jsonResponseFixture, + type LlamaStatusHttpResponse, } from './downtime-worker.fixtures.js'; // ── Fixture coherence (AC1) ─────────────────────────────────────────── @@ -243,9 +247,9 @@ describe.skip('single-flight dispatch guard', () => { }); }); -// ── Endpoint failures (AC6) — implemented in F2 (WL-0MSG80254005ZNE9) ─ +// ── Endpoint failures & poller (AC6) — implemented in F2 ────────────── -describe.skip('endpoint failures', () => { +describe('endpoint failures and poller', () => { it('network errors are treated as busy and never throw', async () => { const fetcher = vi.fn().mockRejectedValue(networkErrorFixture); const poller = createDowntimePoller('http://proxy:8000', fetcher); @@ -270,6 +274,130 @@ describe.skip('endpoint failures', () => { await poller.poll(); expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); }); + + it('normalises a trailing slash on the proxy URL', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000/', fetcher); + await poller.poll(); + expect(fetcher).toHaveBeenCalledWith('http://proxy:8000/llama/local/status', expect.anything()); + }); + + it('returns the parsed status on a successful poll', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toEqual(idleAllSlotsFree); + }); + + it('treats invalid JSON as busy', async () => { + const fetcher = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token'); + }, + }); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('treats payloads with missing required fields as busy', async () => { + const fetcher = vi.fn().mockResolvedValue(jsonResponseFixture(ambiguousMissingFieldsRaw)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + await expect(poller.poll()).resolves.toBeNull(); + }); + + it('derives local_lease_active from the lease fields when the boolean is absent', async () => { + const status = parseLlamaStatus({ + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + available_slots: 4, + total_slots: 4, + local_owner_session_id: 'session-1', + local_owner_lease_remaining_seconds: 120, + }); + expect(status).not.toBeNull(); + expect(status!.local_lease_active).toBe(true); + expect(evaluateIdle(status!, 0)).toBe(false); + }); + + it('coalesces overlapping polls (single-flight, no overlapping fetches)', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = () => resolve(jsonResponseFixture(idleAllSlotsFree)); + }); + const fetcher = vi.fn().mockImplementation(() => gate); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + + const first = poller.poll(); + expect(poller.isPolling()).toBe(true); + const second = poller.poll(); + release(); + + const [firstResult, secondResult] = await Promise.all([first, second]); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(firstResult).toEqual(idleAllSlotsFree); + expect(secondResult).toBe(firstResult); // same coalesced result + expect(poller.isPolling()).toBe(false); + }); + + it('aborts the request after the per-poll timeout (fail closed)', async () => { + vi.useFakeTimers(); + let receivedSignal: unknown; + const fetcher = vi.fn((_url: string, init?: { signal?: unknown }) => { + receivedSignal = init?.signal; + return new Promise((_resolve, reject) => { + const signal = init?.signal as AbortSignal | undefined; + signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }); + }); + + const poller = createDowntimePoller('http://proxy:8000', fetcher, 5_000); + const pollPromise = poller.poll(); + expect(receivedSignal).toBeDefined(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(pollPromise).resolves.toBeNull(); + expect(poller.isPolling()).toBe(false); + vi.useRealTimers(); + }); +}); + +// ── Runtime idle evaluation (F2, AC5) ───────────────────────────────── + +describe('runtime idle evaluation (evaluateIdle)', () => { + const idle: LlamaStatus = { + llama_server_running: true, + active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 4, + total_slots: 4, + }; + + it('N=0 (default) requires all slots free', () => { + expect(evaluateIdle(idle, 0)).toBe(true); + expect(evaluateIdle({ ...idle, available_slots: 3 }, 0)).toBe(false); + }); + + it('degrades 0 < N < total slots to ALL slots free (fail-closed, no per-slot data)', () => { + // Without per-slot identity (LP-0MSG5TA7Y002GN39), N=2 with 4 slots + // must NOT dispatch on "any 2 free" — it requires all 4 free. + expect(evaluateIdle({ ...idle, available_slots: 2 }, 2)).toBe(false); + expect(evaluateIdle({ ...idle, available_slots: 4 }, 2)).toBe(true); + }); + + it('N == total slots behaves like all slots free', () => { + expect(evaluateIdle({ ...idle, available_slots: 4 }, 4)).toBe(true); + expect(evaluateIdle({ ...idle, available_slots: 3 }, 4)).toBe(false); + }); + + it('N > total slots can never be idle (never dispatches)', () => { + expect(evaluateIdle({ ...idle, available_slots: 4 }, 5)).toBe(false); + }); + + it('ambiguous responses (total_slots 0) are busy', () => { + expect(evaluateIdle({ ...idle, total_slots: 0, available_slots: 0 }, 0)).toBe(false); + }); }); // ── Blocked-questions prompt (AC7) ──────────────────────────────────── diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 571a1602d..d0720aeef 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -13,20 +13,18 @@ * `clampDowntimeRequiredFreeSlots` — settings clamps. The settings keys * and load/merge wiring live in `settings.ts` (WL-0MSG80254005ZNE9). * - * The stateful / side-effectful parts are FAIL-CLOSED stubs whose contracts - * are implemented by the downstream features: + * The remaining stateful / side-effectful parts are FAIL-CLOSED stubs whose + * contracts are implemented by the downstream feature: * - * - `createDowntimePoller` — real HTTP poller for `GET {proxyUrl}/llama/ - * local/status` in WL-0MSG80254005ZNE9 (F2). * - `createIdleTracker` — real idle-duration tracker in * WL-0MSG80AG700429M8 (F3). * - `dispatchDowntimeWork` — real dispatch orchestration (`wl next`, * send-to-pi.sh pane spawn, pre-dispatch claim, single-flight) in * WL-0MSG80AG700429M8 (F3). * - * Fail-closed stub behaviour (never dispatch, never throw) is the SAFE - * default: until the full worker lands, the plugin must not dispatch work - * based on unverified logic. + * Fail-closed behaviour (never dispatch, never throw) is the SAFE default: + * until the full worker lands, the plugin must not dispatch work based on + * unverified logic. */ // ── Constants ───────────────────────────────────────────────────────── @@ -96,6 +94,23 @@ export function isIdleStatus(status: LlamaStatus, requiredFreeSlots: number): bo return available >= required; } +/** + * Runtime idle evaluation (F2). Same conditions as `isIdleStatus`, plus the + * fail-closed fallback from planning decision Q7: while the proxy does not + * expose per-slot identity (LP-0MSG5TA7Y002GN39), a configured N with + * 0 < N < total slots degrades to requiring ALL slots free — stricter than + * configured, never "any N slots" without same-slot identity. N > total + * slots never dispatches (carried through `isIdleStatus`). + */ +export function evaluateIdle(status: LlamaStatus, requiredFreeSlots: number): boolean { + const total = status.total_slots; + if (!Number.isFinite(total) || total <= 0) return false; // ambiguous → busy + const effective = requiredFreeSlots > 0 && requiredFreeSlots < total + ? total + : requiredFreeSlots; + return isIdleStatus(status, effective); +} + // ── Idle-duration tracker (stub — F3) ───────────────────────────────── export interface IdleTracker { @@ -129,7 +144,7 @@ export function createIdleTracker(): IdleTracker { }; } -// ── Poller (stub — F2) ──────────────────────────────────────────────── +// ── Poller (implemented — F2) ──────────────────────────────────────── /** * Minimal structural fetch signature (the DOM `fetch` type is not part of @@ -141,11 +156,95 @@ export type LlamaStatusFetcher = ( init?: { signal?: unknown }, ) => Promise<{ ok: boolean; status: number; json(): Promise }>; +/** Per-poll timeout: an unresponsive proxy must fail closed, not hang. */ +export const DEFAULT_DOWNTIME_POLL_TIMEOUT_MS = 5_000; + +/** + * Fail-closed parse of a `GET /llama/local/status` payload. Returns null + * (treated as busy by the caller) when required fields are missing or + * malformed. `local_lease_active` is derived from the lease fields the + * proxy actually serves when the boolean is not present. + */ +export function parseLlamaStatus(raw: unknown): LlamaStatus | null { + if (typeof raw !== 'object' || raw === null) return null; + const o = raw as Record; + + if (typeof o.llama_server_running !== 'boolean') return null; + if (typeof o.active_query !== 'boolean') return null; + if (typeof o.model_switch_in_progress !== 'boolean') return null; + + const available = o.available_slots; + const total = o.total_slots; + if (typeof available !== 'number' || !Number.isFinite(available) || available < 0) return null; + if (typeof total !== 'number' || !Number.isFinite(total) || total <= 0) return null; + + let localLeaseActive: boolean; + if (typeof o.local_lease_active === 'boolean') { + localLeaseActive = o.local_lease_active; + } else { + // Derived from the lease fields served by the llama-proxy. + const ownerSession = o.local_owner_session_id; + const leaseSeconds = o.local_owner_lease_remaining_seconds; + localLeaseActive = + (typeof ownerSession === 'string' && ownerSession.length > 0) || + (typeof leaseSeconds === 'number' && Number.isFinite(leaseSeconds) && leaseSeconds > 0); + } + + return { + llama_server_running: o.llama_server_running, + active_query: o.active_query, + model_switch_in_progress: o.model_switch_in_progress, + local_lease_active: localLeaseActive, + available_slots: available, + total_slots: total, + current_model: typeof o.current_model === 'string' ? o.current_model : undefined, + local_owner_session_id: + typeof o.local_owner_session_id === 'string' ? o.local_owner_session_id : undefined, + local_owner_lease_remaining_seconds: + typeof o.local_owner_lease_remaining_seconds === 'number' + ? o.local_owner_lease_remaining_seconds + : undefined, + }; +} + +/** + * Fetch and parse `GET {url}/llama/local/status` with a per-poll timeout. + * Every failure mode — network error, timeout, HTTP error status, invalid + * JSON, ambiguous payload — resolves to null (busy); this function never + * throws. + */ +export async function fetchLocalStatus( + url: string, + opts?: { timeoutMs?: number; fetcher?: LlamaStatusFetcher }, +): Promise { + const timeoutMs = opts?.timeoutMs ?? DEFAULT_DOWNTIME_POLL_TIMEOUT_MS; + const fetcher = opts?.fetcher ?? (globalThis.fetch as unknown as LlamaStatusFetcher); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const res = await fetcher(url, { signal: controller.signal }); + if (!res.ok) return null; + let raw: unknown; + try { + raw = await res.json(); + } catch { + return null; // invalid JSON + } + return parseLlamaStatus(raw); + } catch { + return null; // network errors / timeouts → busy, never throw + } finally { + clearTimeout(timer); + } +} + export interface DowntimePoller { /** * Poll `GET {proxyUrl}/llama/local/status` once. Resolves to the parsed * status, or null when the endpoint failed/timed out or the payload was - * ambiguous (both cases are treated as busy by the caller). + * ambiguous (both cases are treated as busy by the caller). Overlapping + * calls coalesce onto the in-flight poll (single-flight). */ poll(): Promise; /** True while a poll is in flight (single-flight guard for the loop). */ @@ -153,19 +252,28 @@ export interface DowntimePoller { } /** - * FAIL-CLOSED stub (implemented in WL-0MSG80254005ZNE9): resolves busy - * (null) without performing any network I/O. + * Single-flight poller for the llama-proxy status endpoint. `poll()` never + * overlaps: a second call while one poll is in flight returns the same + * in-flight promise. */ export function createDowntimePoller( - _proxyUrl: string, - _fetcher: LlamaStatusFetcher = globalThis.fetch as unknown as LlamaStatusFetcher, + proxyUrl: string, + fetcher: LlamaStatusFetcher = globalThis.fetch as unknown as LlamaStatusFetcher, + timeoutMs: number = DEFAULT_DOWNTIME_POLL_TIMEOUT_MS, ): DowntimePoller { + let inFlight: Promise | null = null; + const url = `${proxyUrl.replace(/\/+$/, '')}${DOWNTIME_STATUS_PATH}`; + return { - async poll(): Promise { - return null; + poll(): Promise { + if (inFlight !== null) return inFlight; + inFlight = fetchLocalStatus(url, { timeoutMs, fetcher }).finally(() => { + inFlight = null; + }); + return inFlight; }, isPolling(): boolean { - return false; + return inFlight !== null; }, }; } diff --git a/packages/herdr/src/settings.ts b/packages/herdr/src/settings.ts index aed8dca3e..f3963b274 100644 --- a/packages/herdr/src/settings.ts +++ b/packages/herdr/src/settings.ts @@ -7,6 +7,16 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { clampSyncInterval } from './auto-sync.js'; +import { + clampDowntimeIdleThresholdMs, + clampDowntimePollInterval, + clampDowntimeRequiredFreeSlots, + DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + DEFAULT_DOWNTIME_MODEL, + DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + DEFAULT_DOWNTIME_PROXY_URL, + DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, +} from './downtime-worker.js'; import { dirname } from 'node:path'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -28,6 +38,18 @@ export interface PluginSettings { browseItemCount: number; /** Show chord help bar at the bottom of the list. */ showHelpText: boolean; + /** Enable the downtime worker (local-LLM idle dispatch). */ + downtimeEnabled: boolean; + /** Minimum continuous idle duration before dispatch (ms). */ + downtimeIdleThresholdMs: number; + /** Required free slots; 0 = all slots (default), or a positive integer N. */ + downtimeRequiredFreeSlots: number; + /** Poll interval for the proxy status endpoint (hard floor 10s). */ + downtimePollIntervalMs: number; + /** Base URL of the llama-proxy (e.g. http://192.168.0.199:8000). */ + downtimeProxyUrl: string; + /** pi model pattern for dispatched agent panes (default `plan`). */ + downtimeModel: string; } // ── Defaults ────────────────────────────────────────────────────────── @@ -40,6 +62,12 @@ export const defaultSettings: PluginSettings = { syncIntervalMs: 60000, browseItemCount: 10, showHelpText: true, + downtimeEnabled: true, + downtimeIdleThresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, + downtimeRequiredFreeSlots: DEFAULT_DOWNTIME_REQUIRED_FREE_SLOTS, + downtimePollIntervalMs: DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + downtimeProxyUrl: DEFAULT_DOWNTIME_PROXY_URL, + downtimeModel: DEFAULT_DOWNTIME_MODEL, }; /** Minimum allowed browseItemCount. */ @@ -109,6 +137,21 @@ export function loadSettings(settingsPath?: string): PluginSettings { ? clampBrowseItemCount(parsed.browseItemCount) : defaultSettings.browseItemCount, showHelpText: typeof parsed.showHelpText === 'boolean' ? parsed.showHelpText : defaultSettings.showHelpText, + downtimeEnabled: typeof parsed.downtimeEnabled === 'boolean' + ? parsed.downtimeEnabled : defaultSettings.downtimeEnabled, + downtimeIdleThresholdMs: typeof parsed.downtimeIdleThresholdMs === 'number' + ? clampDowntimeIdleThresholdMs(parsed.downtimeIdleThresholdMs) + : defaultSettings.downtimeIdleThresholdMs, + downtimeRequiredFreeSlots: typeof parsed.downtimeRequiredFreeSlots === 'number' + ? clampDowntimeRequiredFreeSlots(parsed.downtimeRequiredFreeSlots) + : defaultSettings.downtimeRequiredFreeSlots, + downtimePollIntervalMs: typeof parsed.downtimePollIntervalMs === 'number' + ? clampDowntimePollInterval(parsed.downtimePollIntervalMs) + : defaultSettings.downtimePollIntervalMs, + downtimeProxyUrl: typeof parsed.downtimeProxyUrl === 'string' && parsed.downtimeProxyUrl.length > 0 + ? parsed.downtimeProxyUrl : defaultSettings.downtimeProxyUrl, + downtimeModel: typeof parsed.downtimeModel === 'string' && parsed.downtimeModel.length > 0 + ? parsed.downtimeModel : defaultSettings.downtimeModel, }; } catch { return { ...defaultSettings }; diff --git a/tests/herdr/settings.test.ts b/tests/herdr/settings.test.ts index 489181179..cf4f527b2 100644 --- a/tests/herdr/settings.test.ts +++ b/tests/herdr/settings.test.ts @@ -54,6 +54,27 @@ describe('defaultSettings', () => { it('has syncIntervalMs enabled by default', () => { expect(defaultSettings.syncIntervalMs).toBeGreaterThan(0); }); + + it('has downtimeEnabled enabled by default', () => { + expect(defaultSettings.downtimeEnabled).toBe(true); + }); + + it('has a 4-minute idle threshold by default', () => { + expect(defaultSettings.downtimeIdleThresholdMs).toBe(240000); + }); + + it('has downtimeRequiredFreeSlots 0 (all slots) by default', () => { + expect(defaultSettings.downtimeRequiredFreeSlots).toBe(0); + }); + + it('has a 30s poll interval by default', () => { + expect(defaultSettings.downtimePollIntervalMs).toBe(30000); + }); + + it('has the llama-proxy URL and plan model by default', () => { + expect(defaultSettings.downtimeProxyUrl).toBe('http://192.168.0.199:8000'); + expect(defaultSettings.downtimeModel).toBe('plan'); + }); }); describe('loadSettings', () => { @@ -90,6 +111,33 @@ describe('loadSettings', () => { expect(settings.browseItemCount).toBe(50); }); + it('clamps downtime settings when loading out-of-range persisted values', () => { + writeFileSync(settingsPath, JSON.stringify({ + downtimePollIntervalMs: 5000, // below the 10s floor + downtimeIdleThresholdMs: -1, // negative → default + downtimeRequiredFreeSlots: -2, // negative → 0 (all slots) + downtimeModel: '', // empty → default + }), 'utf-8'); + const settings = loadSettings(settingsPath); + expect(settings.downtimePollIntervalMs).toBe(10000); + expect(settings.downtimeIdleThresholdMs).toBe(240000); + expect(settings.downtimeRequiredFreeSlots).toBe(0); + expect(settings.downtimeModel).toBe('plan'); + }); + + it('merges partial downtime settings with defaults', () => { + writeFileSync(settingsPath, JSON.stringify({ + downtimeEnabled: false, + downtimeProxyUrl: 'http://10.0.0.5:8000', + }), 'utf-8'); + const settings = loadSettings(settingsPath); + expect(settings.downtimeEnabled).toBe(false); + expect(settings.downtimeProxyUrl).toBe('http://10.0.0.5:8000'); + expect(settings.downtimeIdleThresholdMs).toBe(240000); // from defaults + expect(settings.downtimePollIntervalMs).toBe(30000); // from defaults + expect(settings.downtimeRequiredFreeSlots).toBe(0); // from defaults + }); + it('loads settings from existing file', () => { writeFileSync(settingsPath, JSON.stringify({ autoRefresh: false, @@ -147,6 +195,12 @@ describe('saveSettings', () => { syncIntervalMs: 30000, browseItemCount: 25, showHelpText: false, + downtimeEnabled: true, + downtimeIdleThresholdMs: 240000, + downtimeRequiredFreeSlots: 0, + downtimePollIntervalMs: 30000, + downtimeProxyUrl: 'http://192.168.0.199:8000', + downtimeModel: 'plan', }; saveSettings(settingsPath, settings); expect(existsSync(settingsPath)).toBe(true); From 7005196410b65363e78303b15cbefc2975999761 Mon Sep 17 00:00:00 2001 From: pi Date: Wed, 5 Aug 2026 23:55:01 +0100 Subject: [PATCH 07/59] WL-0MSG80AG700429M8: Idle tracker, dispatch orchestration (claim + single-flight) and downtime pane spawn --- packages/herdr/src/downtime-worker.test.ts | 403 +++++++++++++++++---- packages/herdr/src/downtime-worker.ts | 282 ++++++++++++-- 2 files changed, 580 insertions(+), 105 deletions(-) diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 90ac786fc..b33415118 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -7,25 +7,26 @@ * (cf. WL-0MQD1N3JD007B0FZ). * * Green suites test the parts implemented so far (idle detection, - * blocked-questions prompt, settings clamps, fixture coherence, and — since - * WL-0MSG80254005ZNE9 — the poller, fail-closed parsing and runtime idle - * evaluation). The `describe.skip` blocks define the remaining contract - * matrix; the owning implementation feature flips them back on: - * - * - threshold timing / dispatch / single-flight → WL-0MSG80AG700429M8 (F3) - * - * The remaining stubs are fail-closed (never dispatch, never throw), which - * is the safe default until the full worker lands. + * blocked-questions prompt, settings clamps, fixture coherence, the poller + * and fail-closed parsing from WL-0MSG80254005ZNE9, and the idle tracker, + * dispatch orchestration, pane spawn and worker orchestrator from + * WL-0MSG80AG700429M8). */ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { isIdleStatus, evaluateIdle, parseLlamaStatus, fetchLocalStatus, createDowntimePoller, + createIdleTracker, + dispatchDowntimeWork, + createDowntimeWorker, buildDowntimePrompt, + buildDowntimePaneArgs, + spawnDowntimePane, + buildDowntimeSpawnOptions, BLOCKED_QUESTIONS_INSTRUCTION, clampDowntimePollInterval, clampDowntimeIdleThresholdMs, @@ -33,11 +34,11 @@ import { DOWNTIME_POLL_INTERVAL_FLOOR_MS, DEFAULT_DOWNTIME_POLL_INTERVAL_MS, DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, - createIdleTracker, - dispatchDowntimeWork, type LlamaStatus, type LlamaStatusFetcher, type DowntimeCandidate, + type DowntimeWorkerDeps, + type DowntimeSpawn, } from './downtime-worker.js'; import { statusFixtures, @@ -50,6 +51,16 @@ import { type LlamaStatusHttpResponse, } from './downtime-worker.fixtures.js'; +/** Shared deps mock for dispatch tests. */ +function makeDeps(overrides: Partial = {}): DowntimeWorkerDeps { + return { + getNextItem: vi.fn().mockResolvedValue(null), + claimItem: vi.fn().mockResolvedValue(undefined), + spawnAgentPane: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + // ── Fixture coherence (AC1) ─────────────────────────────────────────── describe('downtime worker fixtures', () => { @@ -123,127 +134,163 @@ describe('idle detection (isIdleStatus)', () => { }); }); -// ── Threshold timing (AC3) — implemented in F3 (WL-0MSG80AG700429M8) ── +// ── Threshold timing (AC1/AC3) ──────────────────────────────────────── -describe.skip('threshold timing (idle-duration tracker)', () => { +describe('threshold timing (idle-duration tracker)', () => { const thresholdMs = 240_000; it('dispatches only after idle has lasted the full threshold continuously', () => { - vi.useFakeTimers(); const tracker = createIdleTracker(); - const start = Date.now(); + const start = 1_000_000; tracker.record(true, start); expect(tracker.idleSince).toBe(start); expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs - 1)).toBe(false); expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); - - vi.useRealTimers(); }); it('any busy poll resets the idle-since timestamp', () => { - vi.useFakeTimers(); const tracker = createIdleTracker(); - const start = Date.now(); + const start = 1_000_000; tracker.record(true, start); tracker.record(false, start + 120_000); expect(tracker.idleSince).toBeNull(); expect(tracker.isThresholdMet(thresholdMs, start + 360_000)).toBe(false); + }); - vi.useRealTimers(); + it('keeps the idle run start fixed across consecutive idle polls', () => { + const tracker = createIdleTracker(); + const start = 1_000_000; + tracker.record(true, start); + tracker.record(true, start + 30_000); + tracker.record(true, start + 60_000); + expect(tracker.idleSince).toBe(start); + expect(tracker.isThresholdMet(thresholdMs, start + 60_000)).toBe(false); + expect(tracker.isThresholdMet(thresholdMs, start + thresholdMs)).toBe(true); }); }); -// ── Dispatch selection (AC4) — implemented in F3 (WL-0MSG80AG700429M8) ─ +// ── Dispatch selection (AC2) ────────────────────────────────────────── -describe.skip('dispatch selection', () => { +describe('dispatch selection', () => { it('runs /skill:plan on the next intake_complete item', async () => { - const getNextItem = vi.fn().mockResolvedValue({ - id: 'WL-ABC', - title: 'Some task', - stage: 'intake_complete', + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }), }); - const spawnAgentPane = vi.fn().mockResolvedValue(undefined); - const outcome = await dispatchDowntimeWork( - { getNextItem, spawnAgentPane }, - { model: 'plan', cwd: '/repo' }, - ); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(getNextItem).toHaveBeenCalledWith('intake_complete'); + expect(deps.getNextItem).toHaveBeenCalledWith('intake_complete'); + expect(deps.getNextItem).toHaveBeenCalledTimes(1); expect(outcome.dispatched).toBe(true); expect(outcome.kind).toBe('plan'); - expect(spawnAgentPane).toHaveBeenCalledWith( + expect(outcome.candidate?.id).toBe('WL-ABC'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:plan WL-ABC'), { model: 'plan', cwd: '/repo' }, ); }); + it('claims the item BEFORE the pane spawns', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }), + }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.claimItem).toHaveBeenCalledWith('WL-ABC'); + const claimOrder = (deps.claimItem as ReturnType).mock.invocationCallOrder[0]; + const spawnOrder = (deps.spawnAgentPane as ReturnType).mock.invocationCallOrder[0]; + expect(claimOrder).toBeLessThan(spawnOrder); + }); + it('falls back to /skill:intake on the next idea item when none is intake_complete', async () => { - const getNextItem = vi - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ id: 'WL-DEF', title: 'An idea', stage: 'idea' }); - const spawnAgentPane = vi.fn().mockResolvedValue(undefined); + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'WL-DEF', title: 'An idea', stage: 'idea' }), + }); - const outcome = await dispatchDowntimeWork( - { getNextItem, spawnAgentPane }, - { model: 'plan', cwd: '/repo' }, - ); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); - expect(getNextItem).toHaveBeenNthCalledWith(2, 'idea'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); expect(outcome.kind).toBe('intake'); - expect(spawnAgentPane).toHaveBeenCalledWith( + expect(outcome.candidate?.id).toBe('WL-DEF'); + expect(deps.claimItem).toHaveBeenCalledWith('WL-DEF'); + expect(deps.spawnAgentPane).toHaveBeenCalledWith( expect.stringContaining('/skill:intake WL-DEF'), expect.anything(), ); }); it('does not dispatch when no item exists in either stage', async () => { - const getNextItem = vi.fn().mockResolvedValue(null); - const spawnAgentPane = vi.fn().mockResolvedValue(undefined); + const deps = makeDeps(); - const outcome = await dispatchDowntimeWork( - { getNextItem, spawnAgentPane }, - { model: 'plan', cwd: '/repo' }, - ); + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + expect(deps.getNextItem).toHaveBeenNthCalledWith(1, 'intake_complete'); + expect(deps.getNextItem).toHaveBeenNthCalledWith(2, 'idea'); expect(outcome.dispatched).toBe(false); - expect(spawnAgentPane).not.toHaveBeenCalled(); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); }); }); -// ── Single-flight (AC5) — implemented in F3 (WL-0MSG80AG700429M8) ───── +// ── Single-flight (AC5) ─────────────────────────────────────────────── -describe.skip('single-flight dispatch guard', () => { +describe('single-flight dispatch guard', () => { it('does not dispatch a second time while one dispatch is in flight', async () => { let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); - const getNextItem = vi.fn().mockResolvedValue({ - id: 'WL-ABC', - title: 'Some task', - stage: 'intake_complete', + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }), + spawnAgentPane: vi.fn().mockImplementation(() => gate), }); - const spawnAgentPane = vi.fn().mockImplementation(() => gate); - const first = dispatchDowntimeWork( - { getNextItem, spawnAgentPane }, - { model: 'plan', cwd: '/repo' }, - ); - const second = dispatchDowntimeWork( - { getNextItem, spawnAgentPane }, - { model: 'plan', cwd: '/repo' }, - ); + const first = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); release(); const [firstOutcome, secondOutcome] = await Promise.all([first, second]); - expect(spawnAgentPane).toHaveBeenCalledTimes(1); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); expect(firstOutcome.dispatched).toBe(true); expect(secondOutcome.dispatched).toBe(false); + expect(secondOutcome.reason).toBe('dispatch-in-flight'); + }); + + it('allows a new dispatch once the previous one has completed', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }), + }); + + const first = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + const second = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(first.dispatched).toBe(true); + expect(second.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); }); }); @@ -460,3 +507,221 @@ describe('downtime settings clamps', () => { expect(clampDowntimeRequiredFreeSlots(2)).toBe(2); }); }); + +// ── Pane spawn (AC4) ────────────────────────────────────────────────── + +describe('downtime pane spawn (send-to-pi.sh)', () => { + it('builds --pane-name / --no-focus / --cwd / --model args for a plan dispatch', () => { + const args = buildDowntimePaneArgs('plan', 'Run /skill:plan WL-ABC — Some task.', { + model: 'plan', + cwd: '/repo', + }); + expect(args).toEqual([ + '--pane-name', + 'Downtime plan', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + 'Run /skill:plan WL-ABC — Some task.', + ]); + }); + + it('uses the intake pane name and forwards the configured model', () => { + const args = buildDowntimePaneArgs('intake', 'Run /skill:intake WL-DEF', { + model: 'code', + cwd: '/repo', + }); + expect(args).toContain('Downtime intake'); + expect(args).toContain('code'); + expect(args).not.toContain('Downtime plan'); + }); + + it('spawns via the injectable spawn and unrefs the child', () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })) as unknown as DowntimeSpawn; + + spawnDowntimePane('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { cwd: '/repo' }, spawnFn); + + expect(spawnFn).toHaveBeenCalledWith('/path/to/send-to-pi.sh', ['--no-focus', 'prompt'], { + cwd: '/repo', + }); + const handle = (spawnFn as ReturnType).mock.results[0].value; + expect(handle.unref).toHaveBeenCalled(); + }); + + it('buildDowntimeSpawnOptions uses detached/ignore options with the resolved cwd', () => { + const options = buildDowntimeSpawnOptions('/repo'); + expect(options).toEqual({ + detached: true, + stdio: 'ignore', + cwd: '/repo', + env: expect.objectContaining({ HERDR_RESOLVED_CWD: '/repo' }), + }); + }); +}); + +// ── Worker orchestrator (AC1/AC5) ───────────────────────────────────── + +describe('downtime worker orchestrator (createDowntimeWorker)', () => { + function makeWorker(overrides: { + enabled?: boolean; + thresholdMs?: number; + status?: unknown; + deps?: Partial; + } = {}) { + const cfg = { + enabled: overrides.enabled ?? true, + thresholdMs: overrides.thresholdMs ?? 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + }; + const fetcher = vi + .fn() + .mockResolvedValue(jsonResponseFixture(overrides.status ?? idleAllSlotsFree)); + const poller = createDowntimePoller('http://proxy:8000', fetcher); + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }), + ...overrides.deps, + }); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ ...cfg }), + }); + return { worker, deps, cfg, fetcher }; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('does nothing (no poll, no dispatch) when disabled', async () => { + const { worker, deps, fetcher } = makeWorker({ enabled: false }); + const result = await worker.tick(); + expect(result).toEqual({ polled: false, dispatched: false, idle: false }); + expect(fetcher).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('treats a busy status as busy and never dispatches', async () => { + const { worker, deps } = makeWorker({ status: { ...idleAllSlotsFree, active_query: true } }); + const result = await worker.tick(); + expect(result.idle).toBe(false); + expect(worker.idleSince).toBeNull(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + }); + + it('does not dispatch until the idle duration reaches the threshold (AC1)', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + expect(worker.idleSince).toBe(start); + + vi.setSystemTime(start + cfg.thresholdMs - 1); + const before = await worker.tick(); + expect(before.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + vi.setSystemTime(start + cfg.thresholdMs); + const at = await worker.tick(); + expect(at.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(worker.lastDispatchAt).toBe(start + cfg.thresholdMs); + }); + + it('requires a fresh full idle period after a dispatch (AC5)', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + cfg.thresholdMs); + await worker.tick(); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + + // Still idle at the next tick: the tracker was reset after dispatch, so + // no second dispatch until another full idle period has elapsed. + vi.setSystemTime(start + cfg.thresholdMs + 30_000); + const next = await worker.tick(); + expect(next.dispatched).toBe(false); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + expect(worker.idleSince).toBe(start + cfg.thresholdMs + 30_000); + + vi.setSystemTime(start + cfg.thresholdMs + 30_000 + cfg.thresholdMs); + const after = await worker.tick(); + expect(after.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); + }); + + it('a busy poll (proxy busy after dispatch) resets the idle run', async () => { + const { worker, deps, cfg, fetcher } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + cfg.thresholdMs); + await worker.tick(); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + + fetcher.mockResolvedValueOnce(jsonResponseFixture({ ...idleAllSlotsFree, active_query: true })); + vi.setSystemTime(start + cfg.thresholdMs + 30_000); + const busy = await worker.tick(); + expect(busy.idle).toBe(false); + expect(worker.idleSince).toBeNull(); + + // Back to idle: a full new run is required before the next dispatch. + vi.setSystemTime(start + cfg.thresholdMs + 60_000); + const reIdle = await worker.tick(); + expect(reIdle.idle).toBe(true); + expect(reIdle.dispatched).toBe(false); + vi.setSystemTime(start + cfg.thresholdMs + 60_000 + cfg.thresholdMs); + const reDispatch = await worker.tick(); + expect(reDispatch.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(2); + }); + + it('re-reads settings each tick so changes apply without a restart', async () => { + const { worker, deps, cfg } = makeWorker(); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + + cfg.thresholdMs = 60_000; // operator changes the setting live + vi.setSystemTime(start + 60_000); + const result = await worker.tick(); + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('does not start a second dispatch while one is in flight (worker single-flight)', async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const { worker, deps } = makeWorker({ + deps: { spawnAgentPane: vi.fn().mockImplementation(() => gate) }, + }); + const start = 1_000_000; + vi.setSystemTime(start); + await worker.tick(); + vi.setSystemTime(start + 240_000); + + const first = worker.tick(); + const second = await worker.tick(); + expect(second.dispatched).toBe(false); + + release(); + const firstResult = await first; + expect(firstResult.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index d0720aeef..647e3c86d 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -2,31 +2,38 @@ * packages/herdr/src/downtime-worker.ts — Local-LLM downtime worker (contract) * * Test-first contract for the herdr downtime worker (parent - * WL-0MSF49FMW009M06K). This module defines the exported contracts the - * implementation features must satisfy, plus the PURE parts that are fully - * implemented here: + * WL-0MSF49FMW009M06K). Implemented in F1 (tests + fixtures), F2 (settings + * clamps, poller, parsing, runtime idle evaluation) and F3 (idle tracker, + * dispatch orchestration, pane spawn): * * - `isIdleStatus` — idle detection from a `/llama/local/status` payload. + * - `evaluateIdle` — runtime idle evaluation with the fail-closed + * degradation for a configured N < total slots (no per-slot data yet). + * - `parseLlamaStatus` / `fetchLocalStatus` / `createDowntimePoller` — the + * single-flight poller for `GET {proxyUrl}/llama/local/status` with + * per-poll timeout and fail-closed parsing. + * - `createIdleTracker` — continuous idle-duration tracker (idleSince vs + * threshold). + * - `dispatchDowntimeWork` — dispatch orchestration: `wl next --stage + * intake_complete` → `/skill:plan `, fallback `--stage idea` → + * `/skill:intake `, pre-dispatch claim, per-process single-flight. + * - `buildDowntimePaneArgs` / `spawnDowntimePane` — send-to-pi.sh + * invocation (`--pane-name Downtime `, `--no-focus`, `--cwd`, + * `--model`), detached and unref'd. + * - `createDowntimeWorker` — per-tick orchestrator (poll → evaluate → + * track → dispatch) with settings re-read each tick. * - `buildDowntimePrompt` / `BLOCKED_QUESTIONS_INSTRUCTION` — dispatched * agent prompt, including the blocked-questions instruction. * - `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` / - * `clampDowntimeRequiredFreeSlots` — settings clamps. The settings keys - * and load/merge wiring live in `settings.ts` (WL-0MSG80254005ZNE9). + * `clampDowntimeRequiredFreeSlots` — settings clamps, wired into + * `settings.ts`. * - * The remaining stateful / side-effectful parts are FAIL-CLOSED stubs whose - * contracts are implemented by the downstream feature: - * - * - `createIdleTracker` — real idle-duration tracker in - * WL-0MSG80AG700429M8 (F3). - * - `dispatchDowntimeWork` — real dispatch orchestration (`wl next`, - * send-to-pi.sh pane spawn, pre-dispatch claim, single-flight) in - * WL-0MSG80AG700429M8 (F3). - * - * Fail-closed behaviour (never dispatch, never throw) is the SAFE default: - * until the full worker lands, the plugin must not dispatch work based on - * unverified logic. + * Fail-closed behaviour (never dispatch, never throw) is the SAFE default + * at every boundary. */ +import { spawn } from 'node:child_process'; + // ── Constants ───────────────────────────────────────────────────────── /** Proxy status endpoint path. */ @@ -111,14 +118,14 @@ export function evaluateIdle(status: LlamaStatus, requiredFreeSlots: number): bo return isIdleStatus(status, effective); } -// ── Idle-duration tracker (stub — F3) ───────────────────────────────── +// ── Idle-duration tracker (implemented — F3) ────────────────────────── export interface IdleTracker { /** Idle-since timestamp (ms), or null when not currently in an idle run. */ readonly idleSince: number | null; /** - * Record one poll result. `isIdle` true advances the idle run; false - * resets it (idleSince = null). + * Record one poll result. `isIdle` true starts (or continues) the idle + * run; false resets it (idleSince = null). */ record(isIdle: boolean, now?: number): void; /** @@ -129,17 +136,24 @@ export interface IdleTracker { } /** - * FAIL-CLOSED stub (implemented in WL-0MSG80AG700429M8): never reports an - * idle threshold met, so no dispatch can occur until the real tracker lands. + * Continuous idle-duration tracker. `idleSince` is set on the first idle + * poll and kept fixed for the whole idle run (so consecutive idle polls + * advance the run); ANY busy poll resets it to null. A dispatch therefore + * only fires after the idle state has been continuous for the full + * threshold, and a fresh full idle period is required after any busy poll. */ export function createIdleTracker(): IdleTracker { + let idleSince: number | null = null; return { - idleSince: null, - record(_isIdle: boolean, _now?: number): void { - // stub + get idleSince(): number | null { + return idleSince; + }, + record(isIdle: boolean, now: number = Date.now()): void { + idleSince = isIdle ? (idleSince ?? now) : null; }, - isThresholdMet(_thresholdMs: number, _now?: number): boolean { - return false; + isThresholdMet(thresholdMs: number, now: number = Date.now()): boolean { + if (idleSince === null) return false; + return now - idleSince >= thresholdMs; }, }; } @@ -278,7 +292,7 @@ export function createDowntimePoller( }; } -// ── Dispatch (stub — F3) ────────────────────────────────────────────── +// ── Dispatch (implemented — F3) ─────────────────────────────────────── export type DowntimeStage = 'intake_complete' | 'idea'; export type DowntimeSkillKind = 'plan' | 'intake'; @@ -293,6 +307,8 @@ export interface DowntimeCandidate { export interface DowntimeWorkerDeps { /** Runs `wl next --stage --json` and returns the first candidate or null. */ getNextItem(stage: DowntimeStage): Promise; + /** Claims the item (`wl update --status in_progress`) before dispatch. */ + claimItem(itemId: string): Promise; /** Opens a visible pi agent pane running the prompt (via send-to-pi.sh). */ spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise; } @@ -305,17 +321,211 @@ export interface DowntimeDispatchOutcome { } /** - * FAIL-CLOSED stub (implemented in WL-0MSG80AG700429M8): never dispatches. - * The real implementation selects `wl next --stage intake_complete` → - * `/skill:plan ` (falling back to `--stage idea` → `/skill:intake - * `), opens a visible pane via `spawnAgentPane`, and honours the - * single-flight guard. + * Per-process single-flight guard: at most one dispatch can be in flight at + * a time (concurrent calls are refused, not queued). Cross-pane + * serialization is handled by the pre-dispatch claim (Q5 — no lock file): + * the claimed item leaves `wl next`'s selection set for other panes. + */ +let dispatchInFlight = false; + +/** + * Dispatch one downtime work item. Selection: `wl next --stage + * intake_complete` → `/skill:plan `; if none, `wl next --stage idea` → + * `/skill:intake `; if both are empty, no dispatch. The item is claimed + * BEFORE the pane spawns so it appears in_progress immediately and a second + * pane cannot select it. The caller must only invoke this once the idle + * tracker reports the threshold met (AC1); a dispatch consumes the local + * slot, so the proxy reports busy and the tracker requires a fresh full + * idle period before the next dispatch. */ export async function dispatchDowntimeWork( - _deps: DowntimeWorkerDeps, - _opts: { model: string; cwd: string }, + deps: DowntimeWorkerDeps, + opts: { model: string; cwd: string }, ): Promise { - return { dispatched: false, reason: 'not-implemented' }; + if (dispatchInFlight) { + return { dispatched: false, reason: 'dispatch-in-flight' }; + } + dispatchInFlight = true; + try { + const intakeComplete = await deps.getNextItem('intake_complete'); + if (intakeComplete !== null) { + await deps.claimItem(intakeComplete.id); + await deps.spawnAgentPane(buildDowntimePrompt('plan', intakeComplete), opts); + return { dispatched: true, candidate: intakeComplete, kind: 'plan' }; + } + const idea = await deps.getNextItem('idea'); + if (idea !== null) { + await deps.claimItem(idea.id); + await deps.spawnAgentPane(buildDowntimePrompt('intake', idea), opts); + return { dispatched: true, candidate: idea, kind: 'intake' }; + } + return { dispatched: false, reason: 'no-candidate' }; + } finally { + dispatchInFlight = false; + } +} + +/** + * Argument vector for spawning `send-to-pi.sh`: visible pane named + * `Downtime `, `--no-focus` (visible but never steals focus), + * `--cwd `, `--model `, then the prompt. + */ +export function buildDowntimePaneArgs( + kind: DowntimeSkillKind, + prompt: string, + opts: { model: string; cwd: string }, +): string[] { + return [ + '--pane-name', + `Downtime ${kind}`, + '--no-focus', + '--cwd', + opts.cwd, + '--model', + opts.model, + prompt, + ]; +} + +/** Minimal spawn handle: the caller unrefs so the parent can exit first. */ +export interface DowntimeSpawnHandle { + unref(): void; +} + +/** Injectable spawn boundary (matches the repo's injectable-seam pattern). */ +export type DowntimeSpawn = ( + scriptPath: string, + args: string[], + opts: { cwd: string }, +) => DowntimeSpawnHandle; + +/** + * Spawn options for `send-to-pi.sh`: detached, stdio ignored, resolved cwd + * forwarded so the pane opens in the right project root. + */ +export function buildDowntimeSpawnOptions(cwd: string): { + detached: boolean; + stdio: 'ignore'; + cwd: string; + env: NodeJS.ProcessEnv; +} { + return { + detached: true, + stdio: 'ignore', + cwd, + env: { ...process.env, HERDR_RESOLVED_CWD: cwd }, + }; +} + +/** Default spawn: detached, stdio ignored, resolved cwd forwarded. */ +export const defaultDowntimeSpawn: DowntimeSpawn = (scriptPath, args, opts) => + spawn(scriptPath, args, buildDowntimeSpawnOptions(opts.cwd)); + +/** + * Spawn `send-to-pi.sh` detached with stdio ignored, then unref so the + * parent (plugin) process can exit independently — same pattern as the + * TUI's existing agent dispatches. + */ +export function spawnDowntimePane( + scriptPath: string, + args: string[], + opts: { cwd: string }, + spawnFn: DowntimeSpawn = defaultDowntimeSpawn, +): void { + const child = spawnFn(scriptPath, args, opts); + child.unref(); +} + +// ── Worker orchestrator (implemented — F3) ──────────────────────────── + +/** Per-tick configuration; re-read every tick so settings apply live. */ +export interface DowntimeWorkerConfig { + poller: DowntimePoller; + deps: DowntimeWorkerDeps; + /** Re-read each tick so settings changes apply without a restart. */ + config(): { + enabled: boolean; + thresholdMs: number; + requiredFreeSlots: number; + model: string; + cwd: string; + }; +} + +export interface DowntimeWorkerTickResult { + polled: boolean; + dispatched: boolean; + idle: boolean; +} + +export interface DowntimeWorker { + /** One poll + evaluation + possible dispatch. Call from the scheduler task. */ + tick(): Promise; + /** Idle-since timestamp of the current idle run (null when busy). */ + readonly idleSince: number | null; + /** True while a dispatch is in flight. */ + readonly dispatching: boolean; + /** Timestamp of the last successful dispatch (null until the first). */ + readonly lastDispatchAt: number | null; +} + +/** + * Compose poller + idle evaluation + tracker + dispatch into the per-tick + * worker the scheduler loop (F4) registers as a due-work task. + */ +export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker { + const tracker = createIdleTracker(); + let dispatching = false; + let lastDispatchAt: number | null = null; + + return { + get idleSince(): number | null { + return tracker.idleSince; + }, + get dispatching(): boolean { + return dispatching; + }, + get lastDispatchAt(): number | null { + return lastDispatchAt; + }, + async tick(): Promise { + const cfg = opts.config(); + if (!cfg.enabled) return { polled: false, dispatched: false, idle: false }; + if (opts.poller.isPolling()) return { polled: false, dispatched: false, idle: tracker.idleSince !== null }; + + const status = await opts.poller.poll(); + if (status === null) { + tracker.record(false); // endpoint failure/ambiguity → busy + return { polled: true, dispatched: false, idle: false }; + } + + const idle = evaluateIdle(status, cfg.requiredFreeSlots); + tracker.record(idle); + if (!idle) return { polled: true, dispatched: false, idle: false }; + if (!tracker.isThresholdMet(cfg.thresholdMs)) { + return { polled: true, dispatched: false, idle: true }; + } + if (dispatching) return { polled: true, dispatched: false, idle: true }; + + dispatching = true; + try { + const outcome = await dispatchDowntimeWork(opts.deps, { + model: cfg.model, + cwd: cfg.cwd, + }); + if (outcome.dispatched) { + lastDispatchAt = Date.now(); + // Belt-and-suspenders: even if the proxy does not immediately + // report busy, require a fresh full idle period before the next + // dispatch (AC5). + tracker.record(false); + } + return { polled: true, dispatched: outcome.dispatched, idle: true }; + } finally { + dispatching = false; + } + }, + }; } // ── Blocked-questions prompt (implemented) ──────────────────────────── From c815c7cb8132625c4f001e3d3e8456be0a832650 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 00:05:02 +0100 Subject: [PATCH 08/59] WL-0MSG80J5W007DVJ4: Downtime worker scheduler integration, index wiring, header status, docs + live smoke check --- packages/herdr/README.md | 59 +++++++++++ packages/herdr/src/downtime-worker.test.ts | 56 ++++++++++ packages/herdr/src/downtime-worker.ts | 38 +++++++ packages/herdr/src/index.test.ts | 114 ++++++++++++++++++++- packages/herdr/src/index.ts | 86 +++++++++++++++- packages/herdr/src/worklist.test.ts | 62 +++++++++++ packages/herdr/src/worklist.ts | 51 ++++++++- 7 files changed, 461 insertions(+), 5 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index ffe63aafd..be549ced6 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -129,6 +129,65 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in - `showHelpText` — Show the shortcut hint line at the bottom of the list (default: `true`); changes apply on the next render without a plugin restart - `showIcons` — Toggle icons in the list (default: `true`) +### Downtime worker (local-LLM idle dispatch) + +When the local LLM (llama-server behind the llama-proxy) is idle, the plugin +can use that compute to advance the worklog backlog automatically: after the +proxy reports idle continuously for the configured threshold, it opens a +visible (non-focus-stealing) pi agent pane that runs `/skill:plan` on the +next `intake_complete` item — or falls back to `/skill:intake` on the next +`idea` item (parent WL-0MSF49FMW009M06K). + +Settings (all re-read each poll, so changes apply without a plugin restart): + +- `downtimeEnabled` — Enable the downtime worker (default: `true`) +- `downtimeIdleThresholdMs` — Minimum continuous idle duration before a +dispatch (default: `240000` = 4 minutes, floor 1s) +- `downtimeRequiredFreeSlots` — Required free slots; `0` means **all** slots +must be free (default). A positive integer N is accepted; without per-slot +identity data the worker fails closed to all-slots-free for `0 < N < total`; +`N > total` never dispatches. +- `downtimePollIntervalMs` — Poll interval for the proxy status endpoint +(default: `30000`, hard floor `10000`) +- `downtimeProxyUrl` — Base URL of the llama-proxy (default: +`http://192.168.0.199:8000`) +- `downtimeModel` — pi model pattern for dispatched panes (default: `plan`) + +The worker polls `GET {proxyUrl}/llama/local/status` on the poll interval. +Idle means: llama-server running, no active query, no model switch, no active +local lease, and the required free-slot condition met. Endpoint failures, +timeouts, and ambiguous responses are treated as **busy** (no dispatch) and +never crash the plugin. Each poll is single-flight with a per-poll timeout. + +**Dispatch behaviour** — once idle has been continuous for the threshold, the +worker runs `wl next --stage intake_complete --json` and dispatches +`/skill:plan `; if no such item it runs `wl next --stage idea --json` and +dispatches `/skill:intake `; if both are empty nothing is dispatched. +The item is claimed (`wl update --status in_progress`) *before* the pane +spawns, so it appears in-progress immediately and a second pane's `wl next` +cannot select it. Panes are named `Downtime plan` / `Downtime intake`, opened +with `--no-focus` (visible, never steals focus), `--cwd ` and +`--model `. + +**Blocked-questions handling** — the dispatched prompt instructs the agent: +if it cannot proceed because it needs answers, record the questions in a +comment on the work item (`wl comment add --comment ...`) and mark the +item as needing producer review (`wl update --needs-producer-review +true`), then stop — it never blocks indefinitely. + +**No lock file (cross-pane decision Q5)** — concurrent panes are serialized +by the idle→busy cadence (a dispatch consumes the local slot, so the proxy +reports busy and the worker requires a fresh full idle period) and by the +pre-dispatch claim; there is deliberately **no cross-pane lock file**. A +small residual risk of two panes dispatching at the same idle boundary is +accepted — the claim prevents duplicate work on the same item. + +The worker runs inside the plugin's single consolidated scheduler loop (one +`setInterval`; no independent timers), uses unref'd timers, and is cleaned up +when the pane exits. While the pane is open the list header shows the worker +state, e.g. `[⏳ downtime idle 3:12]`, `[downtime busy]`, +`[⏳ downtime dispatching]`, or `[downtime disabled]`. + ### Pause-when-hidden (pane visibility gating) When the worklist pane's tab is **hidden (not focused)**, the auto-refresh and diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index b33415118..8cba0a22a 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -28,6 +28,8 @@ import { spawnDowntimePane, buildDowntimeSpawnOptions, BLOCKED_QUESTIONS_INSTRUCTION, + parseNextItemOutput, + skillKindFromPrompt, clampDowntimePollInterval, clampDowntimeIdleThresholdMs, clampDowntimeRequiredFreeSlots, @@ -508,6 +510,60 @@ describe('downtime settings clamps', () => { }); }); +// ── Wiring helpers (F4) ─────────────────────────────────────────────── + +describe('parseNextItemOutput', () => { + it('parses the workItem from `wl next --stage --json`', () => { + const stdout = JSON.stringify({ success: true, workItem: { id: 'WL-ABC', title: 'Some task' } }); + expect(parseNextItemOutput(stdout, 'intake_complete')).toEqual({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }); + }); + + it('returns null when wl next reports no item (workItem null)', () => { + const stdout = JSON.stringify({ success: false, workItem: null, reason: 'no items' }); + expect(parseNextItemOutput(stdout, 'idea')).toBeNull(); + }); + + it('returns null for malformed JSON', () => { + expect(parseNextItemOutput('not json', 'idea')).toBeNull(); + }); + + it('returns null when the workItem lacks an id', () => { + const stdout = JSON.stringify({ success: true, workItem: { title: 'no id' } }); + expect(parseNextItemOutput(stdout, 'idea')).toBeNull(); + }); +}); + +describe('skillKindFromPrompt', () => { + it('derives plan from a /skill:plan prompt and intake otherwise', () => { + expect(skillKindFromPrompt('Run /skill:plan WL-ABC — Task.')).toBe('plan'); + expect(skillKindFromPrompt('Run /skill:intake WL-DEF — Idea.')).toBe('intake'); + }); +}); + +describe('downtime worker enabled state', () => { + it('exposes the enabled flag from the per-tick config', async () => { + const cfg = { enabled: true }; + const worker = createDowntimeWorker({ + poller: createDowntimePoller('http://proxy:8000'), + deps: makeDeps(), + config: () => ({ + enabled: cfg.enabled, + thresholdMs: 240_000, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + }), + }); + expect(worker.enabled).toBe(true); + cfg.enabled = false; + expect(worker.enabled).toBe(false); + }); +}); + // ── Pane spawn (AC4) ────────────────────────────────────────────────── describe('downtime pane spawn (send-to-pi.sh)', () => { diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 647e3c86d..60aaa67c4 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -467,6 +467,8 @@ export interface DowntimeWorker { readonly dispatching: boolean; /** Timestamp of the last successful dispatch (null until the first). */ readonly lastDispatchAt: number | null; + /** Whether the worker is enabled per the current settings (re-read). */ + readonly enabled: boolean; } /** @@ -488,6 +490,9 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker get lastDispatchAt(): number | null { return lastDispatchAt; }, + get enabled(): boolean { + return opts.config().enabled; + }, async tick(): Promise { const cfg = opts.config(); if (!cfg.enabled) return { polled: false, dispatched: false, idle: false }; @@ -551,6 +556,39 @@ export function buildDowntimePrompt(kind: DowntimeSkillKind, candidate: Downtime ].join('\n'); } +// ── Wiring helpers (implemented — F4) ───────────────────────────────── + +/** + * Parse the stdout of `wl next --stage --json` into the first + * candidate. Returns null (no dispatch) for empty output, a null + * `workItem`, missing ids, or malformed JSON. + */ +export function parseNextItemOutput(stdout: string, stage: DowntimeStage): DowntimeCandidate | null { + try { + const parsed = JSON.parse(stdout) as { + workItem?: { id?: unknown; title?: unknown } | null; + }; + const raw = parsed?.workItem; + if (!raw || typeof raw !== 'object') return null; + if (typeof raw.id !== 'string' || raw.id.length === 0) return null; + return { + id: raw.id, + title: typeof raw.title === 'string' ? raw.title : '', + stage, + }; + } catch { + return null; + } +} + +/** + * Derive the dispatch kind from a prompt built by `buildDowntimePrompt` + * (the pane name is `Downtime plan` / `Downtime intake`). + */ +export function skillKindFromPrompt(prompt: string): DowntimeSkillKind { + return prompt.includes('/skill:plan ') ? 'plan' : 'intake'; +} + // ── Settings clamps (implemented — wired into settings.ts by F2) ────── /** diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 478f060f9..816a37b6e 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -6,7 +6,13 @@ */ import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; -import { stripCommandPrefix, routeCommand, stripAgentPromptPrefix, buildSendToPiArgs } from './index.js'; +import { + stripCommandPrefix, + routeCommand, + stripAgentPromptPrefix, + buildSendToPiArgs, + createDowntimeDeps, +} from './index.js'; import { fetchItemsByStage, resetExecFileAsync, @@ -266,8 +272,7 @@ describe('shortcuts.json command routing', () => { describe('routeCommand', () => { - describe('agent commands', () => { - it('routes /skill: commands to the agent pane', () => { + describe('agent commands', () => { it('routes /skill: commands to the agent pane', () => { expect(routeCommand('/skill:implement ')).toBe('agent'); expect(routeCommand('/skill:audit ')).toBe('agent'); }); @@ -473,3 +478,106 @@ describe('configureWorklogTarget', () => { expect(report).toContain('Showing empty worklist. Navigate to a project with \'worklog init\' to see items.'); }); }); + +// --------------------------------------------------------------------------- +// createDowntimeDeps tests (WL-0MSF49FMW009M06K, F4 wiring) +// --------------------------------------------------------------------------- + +describe('createDowntimeDeps', () => { + afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + }); + + it('getNextItem runs wl next --stage and parses the first workItem', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: true, workItem: { id: 'WL-ABC', title: 'Some task' } }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + const candidate = await deps.getNextItem('intake_complete'); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['next', '--stage', 'intake_complete', '--json'], + expect.anything(), + ); + expect(candidate).toEqual({ id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }); + }); + + it('getNextItem returns null when wl reports no item', async () => { + const mockExec = vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ success: false, workItem: null, reason: 'none' }), + stderr: '', + }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextItem('idea')).toBeNull(); + }); + + it('getNextItem fails closed (null) when wl errors', async () => { + setExecFileAsync(vi.fn().mockRejectedValue(new Error('wl boom')) as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + expect(await deps.getNextItem('idea')).toBeNull(); + }); + + it('claimItem runs wl update --status in_progress --assignee', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.claimItem('WL-ABC'); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + ['update', 'WL-ABC', '--status', 'in_progress', '--assignee', 'Map', '--json'], + expect.anything(), + ); + }); + + it('spawnAgentPane spawns send-to-pi.sh with the derived pane name and args', async () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map', spawnFn); + + await deps.spawnAgentPane('Run /skill:plan WL-ABC — Some task.', { + model: 'plan', + cwd: '/repo', + }); + + expect(spawnFn).toHaveBeenCalledWith( + '/path/to/send-to-pi.sh', + [ + '--pane-name', + 'Downtime plan', + '--no-focus', + '--cwd', + '/repo', + '--model', + 'plan', + 'Run /skill:plan WL-ABC — Some task.', + ], + { cwd: '/repo' }, + ); + const handle = (spawnFn as ReturnType).mock.results[0].value; + expect(handle.unref).toHaveBeenCalled(); + }); + + it('spawnAgentPane derives the intake pane name from the prompt', async () => { + const spawnFn = vi.fn(() => ({ unref: vi.fn() })); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map', spawnFn); + + await deps.spawnAgentPane('Run /skill:intake WL-DEF — An idea.', { + model: 'plan', + cwd: '/repo', + }); + + expect(spawnFn).toHaveBeenCalledWith( + '/path/to/send-to-pi.sh', + expect.arrayContaining(['--pane-name', 'Downtime intake']), + expect.anything(), + ); + }); +}); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 1e3701993..3bf66c67b 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -22,10 +22,31 @@ import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, resolve, join } from 'path'; import { resolveWorklogRoot } from '@worklog/shared/worklog-paths'; -import { checkWlAvailable, fetchNextItems, fetchItemsByStage, setWorklogDir, claimWorkItem } from './fetcher.js'; +import { + checkWlAvailable, + fetchNextItems, + fetchItemsByStage, + setWorklogDir, + claimWorkItem, + getExecFileAsync, +} from './fetcher.js'; import { runWorklistTui, getTermSize } from './worklist.js'; import { loadShortcutConfig } from './shortcut-config.js'; import { loadSettings, getDefaultSettingsPath, clampBrowseItemCount, defaultSettings } from './settings.js'; +import { + createDowntimeWorker, + createDowntimePoller, + buildDowntimePaneArgs, + spawnDowntimePane, + parseNextItemOutput, + skillKindFromPrompt, + type DowntimeWorker, + type DowntimeWorkerDeps, + type DowntimeStage, + type DowntimeCandidate, + type DowntimeSpawn, + defaultDowntimeSpawn, +} from './downtime-worker.js'; // Resolve path to the send-to-pi.sh script (relative to this source file) // At runtime (tsx or dist), __dirname equivalent from import.meta.url @@ -183,6 +204,46 @@ export async function claimItemForAgentCommand(command: string): Promise { } } +/** + * Build the real downtime-worker dependencies (WL-0MSF49FMW009M06K): + * `wl next --stage --json` for dispatch selection, `wl update + * --status in_progress` for the pre-dispatch claim, and + * `send-to-pi.sh` for the visible (non-focus-stealing) agent pane. Every + * boundary is fail-closed: a wl failure yields no candidate (no dispatch) + * rather than an exception. + */ +export function createDowntimeDeps( + scriptPath: string, + assignee: string, + spawnFn: DowntimeSpawn = defaultDowntimeSpawn, +): DowntimeWorkerDeps { + return { + async getNextItem(stage: DowntimeStage): Promise { + try { + const { stdout } = await getExecFileAsync()('wl', ['next', '--stage', stage, '--json'], { + encoding: 'utf8', + }); + return parseNextItemOutput(stdout, stage); + } catch { + return null; // wl failure → no dispatch (fail-closed) + } + }, + async claimItem(itemId: string): Promise { + // claimWorkItem never throws (failures are returned and logged). + await claimWorkItem(itemId, assignee); + }, + async spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise { + const kind = skillKindFromPrompt(prompt); + spawnDowntimePane( + scriptPath, + buildDowntimePaneArgs(kind, prompt, opts), + { cwd: opts.cwd }, + spawnFn, + ); + }, + }; +} + // Load settings const settings = loadSettings(); @@ -284,6 +345,27 @@ async function main(): Promise { // Settings are re-read so browseItemCount (per fetch) and showHelpText // (per render) changes apply without a plugin restart. const runSettings = loadSettings(); + // Downtime worker (local-LLM idle dispatch, WL-0MSF49FMW009M06K): built + // when enabled; settings are re-read every tick via `config()` so changes + // apply without a plugin restart. The dispatch panes open in the resolved + // worklog root (--cwd). + const targetCwd = wlRoot ?? resolvedCwd ?? process.cwd(); + const downtimeWorker: DowntimeWorker | undefined = runSettings.downtimeEnabled + ? createDowntimeWorker({ + poller: createDowntimePoller(runSettings.downtimeProxyUrl), + deps: createDowntimeDeps(SEND_TO_PI_SCRIPT, AGENT_ASSIGNEE), + config: () => { + const s = loadSettings(); + return { + enabled: s.downtimeEnabled, + thresholdMs: s.downtimeIdleThresholdMs, + requiredFreeSlots: s.downtimeRequiredFreeSlots, + model: s.downtimeModel, + cwd: targetCwd, + }; + }, + }) + : undefined; const selectedItem = await runWorklistTui( fetcher, undefined, @@ -294,6 +376,8 @@ async function main(): Promise { autoSync: runSettings.autoSync, syncIntervalMs: runSettings.syncIntervalMs, showHelpText: runSettings.showHelpText, + downtimeWorker, + downtimePollIntervalMs: runSettings.downtimePollIntervalMs, // Re-read on every render so a showHelpText change applies on the next // refresh (no plugin restart needed), matching browseItemCount behavior. getShowHelpText: () => loadSettings().showHelpText ?? true, diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index e644f0d05..6b7f9f25a 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -16,6 +16,7 @@ import { formatCodeFreezeDialog, ANSI, createListRenderer, + renderDowntimeStatus, isChordLeader, processChordInput, createChordState, @@ -26,6 +27,7 @@ import { formatTimestamp, buildMetaRows, } from './worklist.js'; +import type { DowntimeWorker } from './downtime-worker.js'; import { setLogPath, resetLogPath, recordCommand, getLastCommand } from './command-log.js'; import { loadShortcutConfig, ShortcutRegistry } from './shortcut-config.js'; import { regroupWorkItems } from './grouping.js'; @@ -1127,3 +1129,63 @@ describe('buildMetaRows — timestamps rendered via formatTimestamp', () => { expect(rows.has('Audited At')).toBe(false); }); }); + +// ── Downtime status indicator (WL-0MSF49FMW009M06K, F4) ─────────────── + +describe('renderDowntimeStatus', () => { + it('renders nothing when no worker is present', () => { + expect(renderDowntimeStatus(undefined)).toBe(''); + }); + + it('renders the continuous idle duration as m:ss', () => { + const worker = { + idleSince: Date.now() - 192_000, // 3:12 + dispatching: false, + enabled: true, + } as unknown as DowntimeWorker; + const status = renderDowntimeStatus(worker); + expect(status).toContain('downtime idle 3:12'); + expect(status).toContain('⏳'); + }); + + it('renders the dispatching state', () => { + const worker = { + idleSince: Date.now() - 60_000, + dispatching: true, + enabled: true, + } as unknown as DowntimeWorker; + expect(renderDowntimeStatus(worker)).toContain('downtime dispatching'); + }); + + it('renders the disabled state', () => { + const worker = { + idleSince: null, + dispatching: false, + enabled: false, + } as unknown as DowntimeWorker; + expect(renderDowntimeStatus(worker)).toContain('downtime disabled'); + }); + + it('renders busy when the proxy is not idle', () => { + const worker = { + idleSince: null, + dispatching: false, + enabled: true, + } as unknown as DowntimeWorker; + expect(renderDowntimeStatus(worker)).toContain('downtime busy'); + }); + + it('appends the status inline to the list header without adding a row', () => { + const items = [makeItem('WL-1', 'open')]; + const renderer = createListRenderer(); + const output = renderer( + items, 0, 0, TERM_80x24, null, 'list', null, + undefined, null, 0, true, undefined, undefined, 0, false, false, + 0, undefined, undefined, + ' [⏳ downtime idle 0:05]', + ); + expect(output).toContain('Work Items'); + expect(output).toContain('[⏳ downtime idle 0:05]'); + expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 6f55e5703..6401a926f 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -31,6 +31,7 @@ import { } from './icons.js'; import { runSync } from './auto-sync.js'; import { TaskScheduler, DEFAULT_SCHEDULER_TICK_MS } from './scheduler.js'; +import { DEFAULT_DOWNTIME_POLL_INTERVAL_MS, type DowntimeWorker } from './downtime-worker.js'; import { showToast } from './notify.js'; import { recordCommand, getLastCommand } from './command-log.js'; import { @@ -1510,6 +1511,29 @@ export function handleKeypress( * current state. The caller should write this to stdout and handle * terminal setup/teardown. */ +/** + * Render the inline downtime-worker status fragment appended to the list + * header (AC3, WL-0MSF49FMW009M06K): `[⏳ downtime idle m:ss]`, + * `[downtime busy]`, `[⏳ downtime dispatching]`, or `[downtime disabled]`. + * Inline-only — it never adds a row, so the pane-height budget is intact. + */ +export function renderDowntimeStatus(worker: DowntimeWorker | undefined): string { + if (!worker) return ''; + if (worker.dispatching) { + return ` ${ANSI.fg(208)}[⏳ downtime dispatching]${ANSI.reset}`; + } + if (!worker.enabled) { + return ` ${ANSI.dim}[downtime disabled]${ANSI.reset}`; + } + if (worker.idleSince !== null) { + const elapsedSecs = Math.max(0, Math.floor((Date.now() - worker.idleSince) / 1000)); + const minutes = Math.floor(elapsedSecs / 60); + const seconds = elapsedSecs % 60; + return ` ${ANSI.fg(34)}[⏳ downtime idle ${minutes}:${String(seconds).padStart(2, '0')}]${ANSI.reset}`; + } + return ` ${ANSI.dim}[downtime busy]${ANSI.reset}`; +} + export function createListRenderer(): ( items: WorkItem[], selectedIndex: number, @@ -1530,6 +1554,7 @@ export function createListRenderer(): ( metaScrollOffset?: number, metaLastCommand?: string, readFile?: (filePath: string) => string | null, + downtimeStatus?: string, ) => string { return ( items: WorkItem[], @@ -1551,6 +1576,7 @@ export function createListRenderer(): ( metaScrollOffset?: number, metaLastCommand?: string, readFile?: (filePath: string) => string | null, + downtimeStatus?: string, ): string => { const { rows, cols } = termSize; const output: string[] = []; @@ -1593,6 +1619,9 @@ export function createListRenderer(): ( if (panePaused) { header += ` ${ANSI.fg(220)}[paused — hidden]${ANSI.reset}`; } + if (downtimeStatus) { + header += downtimeStatus; + } output.push(header); // Code Freeze banner — a prominent warning that implementation is @@ -2053,7 +2082,7 @@ export async function runWorklistTui( fetcher: () => Promise, initialItems?: WorkItem[], shortcutRegistry?: { lookupChord: Function; getChordByLeader: Function; getChordByPrefix: Function; getChordEntries: Function } | ShortcutRegistry | undefined, - options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; onCommand?: (command: string, model?: string) => void }, + options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; onCommand?: (command: string, model?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number }, ): Promise { const opts = { autoRefresh: options?.autoRefresh ?? true, @@ -2064,6 +2093,8 @@ export async function runWorklistTui( showHelpText: options?.showHelpText ?? true, getShowHelpText: options?.getShowHelpText ?? (() => options?.showHelpText ?? true), onCommand: options?.onCommand, + downtimeWorker: options?.downtimeWorker, + downtimePollIntervalMs: options?.downtimePollIntervalMs ?? DEFAULT_DOWNTIME_POLL_INTERVAL_MS, }; let termSize = getTermSize(); @@ -2671,6 +2702,7 @@ export async function runWorklistTui( state.metaScrollOffset, metaLastCommand, readKeyFile, + renderDowntimeStatus(opts.downtimeWorker), ); // Notifications are surfaced via Herdr toasts (showToast), never as a @@ -2788,6 +2820,23 @@ export async function runWorklistTui( }, }); + // Downtime-worker task — polls the llama-proxy for idle state and, after + // the configured idle threshold, dispatches a pi agent pane (parent + // WL-0MSF49FMW009M06K). Unlike refresh/sync it is NOT visibility-gated: + // the worker runs while the worklist pane is open (parent Assumptions). + // Single-flight: the poller and dispatch guards inside the worker prevent + // overlapping work; the scheduler task itself is also single-flight. + if (opts.downtimeWorker) { + scheduler.addTask({ + id: 'downtime', + intervalMs: opts.downtimePollIntervalMs, + singleFlight: true, + run: async () => { + await opts.downtimeWorker?.tick(); + }, + }); + } + scheduler.start(); // Cleanup on promise resolution From 1b3d77e73b3be46231c1b5dd26fa0bd699153628 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 00:12:37 +0100 Subject: [PATCH 09/59] WL-0MSGAEC5N006W5QA: Implementation complete --- docs/ARCHITECTURE.md | 74 ++++-- src/cli.ts | 78 +++++- src/commands/next.ts | 16 +- src/read-cache-cli.ts | 224 +++++++++++++++++ src/read-cache.ts | 88 ++++++- src/spawn-counter.ts | 72 ++++++ tests/cli/read-cache-e2e.test.ts | 237 ++++++++++++++++++ tests/cli/read-cache-spawn-reduction.test.ts | 120 ++++++++++ tests/read-cache-cli.test.ts | 239 +++++++++++++++++++ tests/spawn-counter.test.ts | 61 +++++ 10 files changed, 1183 insertions(+), 26 deletions(-) create mode 100644 src/read-cache-cli.ts create mode 100644 src/spawn-counter.ts create mode 100644 tests/cli/read-cache-e2e.test.ts create mode 100644 tests/cli/read-cache-spawn-reduction.test.ts create mode 100644 tests/read-cache-cli.test.ts create mode 100644 tests/spawn-counter.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ab3c82628..80968bdee 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -206,7 +206,7 @@ consumers agree because they share the same resolver. - **Git push**: Depends on network (typically 1-5s) - **Import**: ~100ms per 1000 items -## Read Cache (src/read-cache.ts) +## Read Cache (src/read-cache.ts, src/read-cache-cli.ts) Pure-read commands (`list`, `next`, `show`, `search`, `status` in JSON mode) cache their results on disk so repeated, byte-identical queries are served @@ -217,26 +217,66 @@ without re-spawning work. - **Location**: XDG cache dir — `$WL_CACHE_DIR` if set, else `$XDG_CACHE_HOME/wl`, else `~/.cache/wl`. - **Entry files**: `/<64-hex-sha256>.json`, one per query. +- **State counters**: `/state/<64-hex-sha256>.json` — one + monotonic write counter per worklog dir (see below). - **Cache key**: SHA-256 over (wl version, resolved absolute worklog dir, argv elements in order). The version is included so a wl upgrade with a changed output schema never serves stale-shaped entries. -- **Entry header** stores the key inputs plus a WAL-aware DB fingerprint: - `[mtimeMs, size]` of `worklog.db`, `worklog.db-wal` and `worklog.db-shm` - captured at write time. - -### Invalidation semantics - -- **Primary (WAL-aware fingerprint)**: on read, the fingerprint is recomputed - and compared to the one stored in the entry. Any change to `worklog.db` - (checkpoint), `worklog.db-wal` (write commit), or `worklog.db-shm` - (connection) invalidates the entry. This is required because `worklog.db` - mtime alone is unreliable in WAL mode — writes land in the WAL and the main - file only changes on checkpoint. Missing DB files fingerprint as zeros, so a - DB that is later created (files appear) also invalidates. +- **Entry header** stores the key inputs plus a DB-state fingerprint captured + at write time. + +### Fingerprint model + +Two fingerprint providers exist; the CLI uses the counter-based one: + +- **File fingerprint (module default, `computeDbFingerprint`)**: `[mtimeMs, + size]` of `worklog.db`, `worklog.db-wal` and `worklog.db-shm`. This is + WAL-aware (writes land in the WAL before checkpoint) but **not stable + across processes**: the app's own `journal_mode = WAL` pragma + schema/FTS + init rewrite the DB header on every open/close, so consecutive read + processes see different file states. +- **State counter (CLI, `counterFingerprint`)**: a monotonic per-worklog-dir + integer in the cache dir. It is stable across reads (reads never touch it) + and changes exactly when a write lands, so it is the CLI's fingerprint of + record. The file fingerprint remains available for direct module use. + +### Invalidation semantics (CLI wiring) + +- **Writes invalidate**: write commands (`create`, `update`, `close`, + `delete`, `comment`, `dep`, `import`, `sync`, `github`, …) bump the state + counter in a `preAction` hook *before* their action mutates the DB, and + drop entry files — so an identical read immediately after a write returns + fresh data. +- **Read write-byproducts count as writes**: `next`'s auto re-sort bumps the + counter after it lands (only when it actually changed sort indices), so + cached `next`/`list` results stay sound; `search --rebuild-index` is + excluded from caching outright and invalidates. +- **Mid-action race guard**: a backfill is only stored if the state counter + is unchanged since the pre-action lookup (a concurrent write bumped it). - **TTL bounding safety net**: entries older than the TTL (default 30s, - aligned with the Herdr 30s refresh cadence) are never served. -- **Writes bypass the cache**: write commands do not read from the cache; F2+ - wires `invalidate()` so writes/sync drop the affected entries. + aligned with the Herdr 30s refresh cadence) are never served. This also + bounds staleness from writes the counter misses (e.g. external tools + writing the DB directly). +- **JSON mode only**: text output is env/TTY dependent and never cached. + `--semantic`/`--semantic-only` (external embedding API) are never cached. + +### CLI dispatch + +`src/cli.ts` checks the cache for cacheable read invocations *before* +`program.parse()` — on a hit it prints the cached payload (byte-identical to +`output.json` formatting) and exits without running any action or opening the +DB. Misses run normally; the JSON payload emitted via `output.json` is routed +through `ReadCacheCli.onJsonOutput` for backfill. + +### Spawn instrumentation (spawn-reduction measurement) + +Env-gated counters (`src/spawn-counter.ts`): `WL_SPAWN_COUNT_FILE=` +appends one `\t\t` line per recorded spawn; +`WL_SPAWN_COUNT=1` (no file) logs `[wl:spawn]` lines to stderr. A cache +miss records `read-work`; a cache hit records `cache-hit`. With the cache +disabled (`WL_CACHE_DISABLED=1`), cacheable reads record `read-work` too, so +the baseline is measurable. `tests/cli/read-cache-spawn-reduction.test.ts` +simulates a 6-pane refresh and asserts ≥60% fewer work spawns. ### Concurrency & bounding diff --git a/src/cli.ts b/src/cli.ts index bd113f4cb..4bbfb4a6b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,6 +43,8 @@ import completionCommand from './commands/completion.js'; import cleanupWorktreeCommand from './commands/cleanup-worktree.js'; import { detectWorktreeFromCwd, registerCurrentProcess } from './process-lifecycle.js'; import { applyWorklogDirOverrideFromArgv, setWorklogDirOverride } from './worklog-paths.js'; +import { ReadCacheCli, shouldCacheReadInvocation, extractCommandFromArgv } from './read-cache-cli.js'; +import { recordSpawn } from './spawn-counter.js'; // Watch flag parsing - supports -w, -wN, --watch, --watch=N function parseWatchFlag(argv: string[]) { @@ -244,6 +246,51 @@ applyWorklogDirOverrideFromArgv(process.argv.slice(2)); // Create shared plugin context const ctx = createPluginContext(program); +// ── Read-cache wiring (F2 — WL-0MSGAEC5N006W5QA) ───────────────────────── +// Serve repeat JSON read queries (list/next/show/search/status) from the +// shared on-disk cache so herdr panes / pi-agent polling don't re-query the +// SQLite DB for byte-identical invocations. Env-gated config: +// WL_CACHE_DISABLED=1 — bypass the cache entirely (baseline behaviour) +// WL_CACHE_TTL_MS= — override the TTL safety net (tests/ops) +// WL_CACHE_DIR= — override the cache dir (see read-cache.ts) +// WL_SPAWN_COUNT_FILE — spawn instrumentation (see spawn-counter.ts) +const readCacheEnabled = process.env.WL_CACHE_DISABLED !== '1'; +const _cacheTtl = parseInt(process.env.WL_CACHE_TTL_MS ?? '', 10); +const readCacheCli = new ReadCacheCli({ + ttlMs: Number.isFinite(_cacheTtl) && _cacheTtl > 0 ? _cacheTtl : undefined, +}); + +// Backfill: capture the JSON payload each command emits via output.json so +// the next identical invocation can be served from cache. Wrapped once; the +// wrapper is a no-op unless a cacheable read armed a backfill. +{ + const origJson = ctx.output.json; + ctx.output.json = (data: any) => { + if (readCacheEnabled) { + try { + readCacheCli.onJsonOutput(data); + } catch { + // The cache must never break the CLI. + } + } + origJson(data); + }; +} + +// Write commands (and non-cacheable reads, e.g. search --rebuild-index) +// invalidate the cache for the worklog dir before their action mutates the +// DB, so no stale result can be served after a write. +program.hook('preAction', (thisCommand, actionCommand) => { + if (!readCacheEnabled) return; + const command = actionCommand.name(); + if (shouldCacheReadInvocation(command, process.argv.slice(2))) return; + try { + readCacheCli.invalidateOnWrite(); + } catch { + // Best-effort. + } +}); + // If watch mode was requested we already handled spawning a watcher // earlier; commander should still expose the option on help, but the // watcher logic is implemented outside of the command registration so @@ -475,5 +522,32 @@ if (worktreePath) { registerCurrentProcess(worktreePath); } -// Parse command line arguments -program.parse(); +// Read-cache serve: for a cacheable read invocation, try the cache BEFORE +// commander dispatches. On a hit, print the cached payload (byte-identical +// to output.json formatting) and exit without ever calling `program.parse()` +// — so no action runs and no DB is opened. The write+callback exit guarantees +// piped stdout is flushed before the process terminates. When the cache is +// disabled (baseline), cacheable reads still record a work spawn so the +// spawn-reduction metric has a denominator. +let cacheServed = false; +const cacheArgv = process.argv.slice(2); +const cacheCommand = extractCommandFromArgv(cacheArgv); +if (cacheCommand !== null && shouldCacheReadInvocation(cacheCommand, cacheArgv)) { + if (readCacheEnabled) { + const { served, value } = readCacheCli.lookup(cacheCommand, cacheArgv); + if (served) { + cacheServed = true; + const text = `${JSON.stringify(value, null, 2)}\n`; + process.stdout.write(text, () => process.exit(0)); + // Safety net if the flush callback never fires. + setTimeout(() => process.exit(0), 2000).unref(); + } + } else { + recordSpawn('read-work'); // baseline: every read does DB work + } +} + +// Parse command line arguments (skipped entirely when served from cache). +if (!cacheServed) { + program.parse(); +} diff --git a/src/commands/next.ts b/src/commands/next.ts index 922b26d06..f5c0c579e 100644 --- a/src/commands/next.ts +++ b/src/commands/next.ts @@ -9,6 +9,7 @@ import { normalizeActionArgs } from './cli-utils.js'; import { loadStatusStageRules } from '../status-stage-rules.js'; import { extractFilePaths, type GroupAssignment } from './helpers.js'; import { assignItemGroups, compareGroupedItems } from './grouping.js'; +import { invalidateCacheForWrite } from '../read-cache-cli.js'; export default function register(ctx: PluginContext): void { const { program, output, utils } = ctx; @@ -70,8 +71,19 @@ export default function register(ctx: PluginContext): void { } try { if (typeof (db as any).reSort === 'function') { - if (options.reSortSync) (db as any).reSort(recencyPolicy as 'prefer' | 'avoid' | 'ignore'); - else void Promise.resolve().then(() => (db as any).reSort(recencyPolicy as 'prefer' | 'avoid' | 'ignore')); + if (options.reSortSync) { + const result = (db as any).reSort(recencyPolicy as 'prefer' | 'avoid' | 'ignore'); + // The re-sort is a DB write: bump the read-cache state counter + // so cached `next` results reflect the re-sorted order. + if (result?.updated > 0) invalidateCacheForWrite(); + } else { + void Promise.resolve() + .then(() => (db as any).reSort(recencyPolicy as 'prefer' | 'avoid' | 'ignore')) + .then((result: any) => { + if (result?.updated > 0) invalidateCacheForWrite(); + }) + .catch(() => {}); + } } } catch (_e) {} } diff --git a/src/read-cache-cli.ts b/src/read-cache-cli.ts new file mode 100644 index 000000000..77a45cbb2 --- /dev/null +++ b/src/read-cache-cli.ts @@ -0,0 +1,224 @@ +/** + * wl CLI read-cache wiring (F2 — WL-0MSGAEC5N006W5QA). + * + * Routes `list`, `next`, `show`, `search`, `status` (JSON mode only) through + * the shared on-disk read cache so herdr panes / pi-agent polling don't + * re-query the SQLite DB for byte-identical invocations. + * + * Fingerprint model (see read-cache.ts): the SQLite files are rewritten by + * the app on every open/close (`journal_mode = WAL` + schema init touch the + * header), so file-based fingerprints are not stable across processes. The + * CLI therefore fingerprints DB state with a monotonic per-worklog-dir write + * counter in the cache dir: stable across reads, bumped on every write. + * + * Orchestration model (see src/cli.ts for the hook wiring): + * 1. preAction — for a cacheable read invocation, `lookup()` serves the + * cached payload (if fresh) or arms a backfill that records the state + * counter captured *before* the action reads the DB. + * 2. post-output — the JSON payload each command emits via `output.json` + * is routed through `onJsonOutput()`, which stores it keyed by + * (worklog-dir, argv) only if the state counter is unchanged since the + * lookup (a concurrent write would have bumped it). + * 3. write commands — `invalidateOnWrite()` bumps the state counter (and + * drops entry files), so no stale result can be served after a mutation. + * 4. read write-byproducts (`next`'s auto re-sort) — `invalidateCacheForWrite()` + * bumps the counter when the byproduct lands (see src/commands/next.ts). + * + * The TTL safety net bounds staleness from writes the counter misses (e.g. + * external tools writing the DB directly). + */ + +import { + ReadCache, + bumpStateCounter, + counterFingerprint, + fingerprintsEqual, + resolveCacheDir, + type DbFingerprint, +} from './read-cache.js'; +import { resolveWorklogDir } from './worklog-paths.js'; +import { recordSpawn } from './spawn-counter.js'; +import * as path from 'path'; + +/** Commands whose JSON output is a deterministic function of (DB state, argv). */ +export const READ_CACHE_COMMANDS: ReadonlySet = new Set(['list', 'next', 'show', 'search', 'status']); + +/** + * Read-command flags that make an invocation non-cacheable: + * - `--rebuild-index` (search): rebuilds the FTS index — a write. + * - `--semantic` / `--semantic-only` (search): external embedding API, + * non-deterministic output. + */ +const NON_CACHEABLE_READ_FLAGS: ReadonlySet = new Set(['--rebuild-index', '--semantic', '--semantic-only']); + +/** True when the command is one of the read commands covered by the cache. */ +export function isCacheableReadCommand(command: string): boolean { + return READ_CACHE_COMMANDS.has(command); +} + +/** True when the raw argv requests JSON output. */ +export function argvIsJsonMode(argv: string[]): boolean { + return argv.includes('--json'); +} + +/** True when the argv contains a flag that makes a read non-cacheable. */ +export function hasNonCacheableReadFlags(argv: string[]): boolean { + return argv.some((a) => NON_CACHEABLE_READ_FLAGS.has(a)); +} + +/** + * True when a (command, argv) invocation should be served from / backfilled + * into the read cache. Requires a covered command, JSON mode, and no + * write-byproduct / non-deterministic flags. + */ +export function shouldCacheReadInvocation(command: string, argv: string[]): boolean { + if (!isCacheableReadCommand(command)) return false; + if (!argvIsJsonMode(argv)) return false; + if (hasNonCacheableReadFlags(argv)) return false; + return true; +} + +/** Canonical argv for cache keys: process.argv minus the node + script prefix. */ +export function cacheKeyArgv(): string[] { + return process.argv.slice(2); +} + +/** Program-level options that take a value (skipped with their value). */ +const GLOBAL_VALUE_OPTS = new Set(['--worklog-dir', '--format', '-F']); + +/** + * Extract the subcommand name from raw argv, skipping program-level options + * and their values (`--json`, `--verbose`, `-F/--format `, `--worklog-dir + * `, help/version flags). Returns null when no command token is found + * (bare `wl`, `wl --json`, etc.). + */ +export function extractCommandFromArgv(argv: string[]): string | null { + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json' || a === '--verbose' || a === '--version' || a === '-h' || a === '--help' || a === '-w' || a === '--watch') { + continue; + } + if (GLOBAL_VALUE_OPTS.has(a)) { + i++; // skip the option's value + continue; + } + if (a.startsWith('--format=') || a.startsWith('--worklog-dir=')) { + continue; + } + if (a.startsWith('-')) { + continue; // unknown/other flag — skip + } + return a; + } + return null; +} + +/** State captured when a read lookup arms a backfill. */ +interface BackfillInfo { + worklogDir: string; + argv: string[]; + fpBefore: DbFingerprint; +} + +/** + * Invalidate the read cache after a write that happens outside the normal + * write-command flow (e.g. `next`'s auto re-sort byproduct). Bumps the state + * counter so every previously cached entry for the worklog dir goes stale. + * Uses the env-resolved cache dir, so it is safe to call from command + * modules that never constructed a ReadCacheCli. + */ +export function invalidateCacheForWrite(): void { + try { + bumpStateCounter(resolveCacheDir(), resolveWorklogDir()); + } catch { + // Best-effort: the TTL safety net bounds any missed invalidation. + } +} + +/** + * Stateful cache orchestration for one CLI process: one shared cache, one + * armed backfill at a time (a CLI process runs exactly one command). + */ +export class ReadCacheCli { + private readonly cacheDir: string; + private readonly cache: ReadCache; + private backfill: BackfillInfo | null = null; + + constructor(options: { ttlMs?: number; maxEntries?: number; cacheDir?: string } = {}) { + this.cacheDir = options.cacheDir ? path.resolve(options.cacheDir) : resolveCacheDir(); + this.cache = new ReadCache({ + cacheDir: this.cacheDir, + ...(options.ttlMs !== undefined ? { ttlMs: options.ttlMs } : {}), + ...(options.maxEntries !== undefined ? { maxEntries: options.maxEntries } : {}), + fingerprint: (worklogDir) => counterFingerprint(this.cacheDir, worklogDir), + }); + } + + /** + * Attempt to serve a cacheable read from cache; otherwise arm a backfill. + * + * Returns `{ served: true, value }` when a fresh cached payload exists + * (the caller prints it and exits without any DB work), or + * `{ served: false }` otherwise (the command runs normally and its JSON + * output is routed through `onJsonOutput()` for backfill). + */ + lookup(command: string, argv: string[]): { served: boolean; value?: unknown } { + const worklogDir = resolveWorklogDir(); + if (!shouldCacheReadInvocation(command, argv)) { + return { served: false }; + } + const fpBefore = this.cache.fingerprintFor(worklogDir); + const cached = this.cache.get(worklogDir, argv); + if (cached !== null) { + recordSpawn('cache-hit'); + return { served: true, value: cached }; + } + recordSpawn('read-work'); + this.backfill = { worklogDir, argv, fpBefore }; + return { served: false }; + } + + /** + * Route a produced JSON payload into the cache. + * + * Called from the wrapped `output.json`. Caches only when a backfill was + * armed by `lookup()` AND the state counter is unchanged since then (a + * mid-action write — e.g. `next`'s re-sort — means the payload is not + * representative of a single stable DB state, so it is skipped). + */ + onJsonOutput(data: unknown): void { + const b = this.backfill; + this.backfill = null; + if (b === null || data === null || typeof data !== 'object') return; + const fpNow = this.cache.fingerprintFor(b.worklogDir); + if (!fingerprintsEqual(fpNow, b.fpBefore)) return; + try { + this.cache.set(b.worklogDir, b.argv, data, { dbFingerprint: b.fpBefore }); + } catch { + // The cache must never break the CLI. + } + } + + /** + * Drop every cached entry for the current worklog dir (after a write): + * bumps the state counter (freshness) and removes entry files (tidiness). + */ + invalidateOnWrite(): void { + const worklogDir = resolveWorklogDir(); + try { + bumpStateCounter(this.cacheDir, worklogDir); + } catch { + // Best-effort. + } + try { + this.cache.invalidate(worklogDir); + } catch { + // Best-effort. + } + } + + /** Hit/miss counters (spawn-reduction reporting). */ + stats(): { hits: number; misses: number } { + return this.cache.stats(); + } +} diff --git a/src/read-cache.ts b/src/read-cache.ts index 7082a773f..9fcf0a5db 100644 --- a/src/read-cache.ts +++ b/src/read-cache.ts @@ -48,11 +48,26 @@ export interface ReadCacheOptions { now?: () => number; /** Version string included in the cache key (default `WORKLOG_VERSION`). */ version?: string; + /** + * Custom DB-state fingerprint provider (default: WAL-aware file snapshot). + * + * The wl CLI (F2) uses a stable counter-based fingerprint instead: the + * SQLite files are rewritten by the app on every open/close cycle + * (`journal_mode = WAL` + schema init touch the header), so file-based + * fingerprints are not stable across processes. The counter is monotonic + * per worklog dir and bumped on every write, so it is stable across reads + * yet changes exactly when the data does. + */ + fingerprint?: (worklogDir: string) => DbFingerprint; } /** - * mtime+size snapshot of the WAL-mode SQLite files for a worklog dir. - * Each pair is `[mtimeMs, size]`; missing files fingerprint as `[0, 0]`. + * Snapshot of the DB state for a worklog dir. + * + * The file-pair fields capture the WAL-mode SQLite files (default provider; + * `[mtimeMs, size]` per file, `[0, 0]` when missing). The optional + * `stateCounter` field is the F2 CLI fingerprint: a monotonic per-worklog-dir + * write counter stored in the cache dir (see `bumpStateCounter`). */ export interface DbFingerprint { /** worklog.db — rewritten on WAL checkpoint */ @@ -61,6 +76,8 @@ export interface DbFingerprint { wal: [number, number]; /** worklog.db-shm — shared-memory index, touched on connections */ shm: [number, number]; + /** F2 CLI fingerprint: monotonic per-worklog-dir write counter. */ + stateCounter?: number; } /** A single cache entry: header (key + freshness metadata) + cached value. */ @@ -119,10 +136,64 @@ export function fingerprintsEqual(a: DbFingerprint, b: DbFingerprint): boolean { a.wal[0] === b.wal[0] && a.wal[1] === b.wal[1] && a.shm[0] === b.shm[0] && - a.shm[1] === b.shm[1] + a.shm[1] === b.shm[1] && + (a.stateCounter ?? 0) === (b.stateCounter ?? 0) ); } +// --------------------------------------------------------------------------- +// F2 counter-based fingerprint (stable across process open/close churn) +// --------------------------------------------------------------------------- + +/** Path of the per-worklog-dir state counter file within a cache dir. */ +export function stateCounterFilePath(cacheDir: string, worklogDir: string): string { + const hash = createHash('sha256').update(path.resolve(worklogDir)).digest('hex'); + return path.join(cacheDir, 'state', `${hash}.json`); +} + +/** + * Read the monotonic write-counter for a worklog dir (0 when absent — a + * fresh cache dir means no writes have been observed, which is the safe + * baseline for entries written from the same counter value). + */ +export function readStateCounter(cacheDir: string, worklogDir: string): number { + try { + const raw = fs.readFileSync(stateCounterFilePath(cacheDir, worklogDir), 'utf-8'); + const parsed = JSON.parse(raw) as { state?: number }; + const state = Number(parsed?.state); + return Number.isFinite(state) && state >= 0 ? Math.floor(state) : 0; + } catch { + return 0; + } +} + +/** + * Atomically increment the per-worklog-dir write counter. Returns the new + * value. Concurrent increments may coalesce (both write N+1) but can never + * regress the counter, which is all freshness checking needs. + */ +export function bumpStateCounter(cacheDir: string, worklogDir: string): number { + const file = stateCounterFilePath(cacheDir, worklogDir); + const next = readStateCounter(cacheDir, worklogDir) + 1; + try { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 10)}`; + fs.writeFileSync(tmp, JSON.stringify({ state: next }), 'utf-8'); + fs.renameSync(tmp, file); + } catch (err) { + debugLog(`failed to bump state counter: ${(err as Error).message}`); + } + return next; +} + +/** + * The F2 CLI fingerprint: file fields are inert, `stateCounter` carries the + * per-worklog-dir write counter. + */ +export function counterFingerprint(cacheDir: string, worklogDir: string): DbFingerprint { + return { db: [0, 0], wal: [0, 0], shm: [0, 0], stateCounter: readStateCounter(cacheDir, worklogDir) }; +} + /** * Resolve the read-cache directory. * @@ -171,6 +242,7 @@ export class ReadCache { private readonly maxEntries: number; private readonly now: () => number; private readonly version: string; + private readonly fingerprint: (worklogDir: string) => DbFingerprint; private hits = 0; private misses = 0; @@ -180,12 +252,18 @@ export class ReadCache { this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; this.now = options.now ?? (() => Date.now()); this.version = options.version ?? WORKLOG_VERSION; + this.fingerprint = options.fingerprint ?? computeDbFingerprint; } getCacheDir(): string { return this.cacheDir; } + /** DB-state fingerprint for a worklog dir under this cache's provider. */ + fingerprintFor(worklogDir: string): DbFingerprint { + return this.fingerprint(path.resolve(worklogDir)); + } + /** Cache key for a (worklog-dir, argv) pair under this cache's version. */ keyFor(worklogDir: string, argv: string[]): string { return deriveCacheKey(worklogDir, argv, this.version); @@ -251,7 +329,7 @@ export class ReadCache { } // WAL-aware freshness: any change to db/-wal/-shm invalidates. - const fp = computeDbFingerprint(dir); + const fp = this.fingerprintFor(dir); if (!fingerprintsEqual(fp, entry.dbFingerprint)) { debugLog(`db state changed for ${key}; removing`); this.removeFile(entryPath); @@ -289,7 +367,7 @@ export class ReadCache { worklogDir: dir, argv: [...argv], version: this.version, - dbFingerprint: options?.dbFingerprint ?? computeDbFingerprint(dir), + dbFingerprint: options?.dbFingerprint ?? this.fingerprintFor(dir), createdAt: now, accessedAt: now, value, diff --git a/src/spawn-counter.ts b/src/spawn-counter.ts new file mode 100644 index 000000000..8481789b6 --- /dev/null +++ b/src/spawn-counter.ts @@ -0,0 +1,72 @@ +/** + * Env-gated spawn instrumentation (F2 — WL-0MSGAEC5N006W5QA). + * + * Records `wl` process spawns so the herdr refresh spawn-reduction target + * (parent AC7: ≥60%) can be measured. A "work spawn" is a `wl` process that + * performed real read work (a read-cache miss); cache-hit processes exit + * without touching the DB and are recorded with a distinct kind. + * + * Configuration: + * - `WL_SPAWN_COUNT_FILE=` — append one `\t\t` line + * per recorded spawn (append-only, so concurrent processes never corrupt + * each other's records). + * - `WL_SPAWN_COUNT=1` (without a file) — log `[wl:spawn] ` + * lines to stderr. + * + * Instrumentation is best-effort and must never break the CLI. + */ + +import * as fs from 'fs'; + +/** A single recorded spawn: kind, wall-clock timestamp, process pid. */ +export interface SpawnRecord { + kind: string; + ts: number; + pid: number; +} + +/** + * Record one spawn (or cache-hit) observation. + * + * No-op unless `WL_SPAWN_COUNT_FILE` or `WL_SPAWN_COUNT` is set. Never + * throws — instrumentation must not break command execution. + */ +export function recordSpawn(kind: string): void { + const file = process.env.WL_SPAWN_COUNT_FILE; + if (file) { + try { + fs.appendFileSync(file, `${kind}\t${Date.now()}\t${process.pid}\n`, 'utf-8'); + } catch { + // Ignore: instrumentation must never break the CLI. + } + return; + } + if (process.env.WL_SPAWN_COUNT) { + process.stderr.write(`[wl:spawn] ${kind} ${process.pid}\n`); + } +} + +/** Parse a spawn-count file back into records (for tests and reports). */ +export function readSpawnRecords(file: string): SpawnRecord[] { + let raw: string; + try { + raw = fs.readFileSync(file, 'utf-8'); + } catch { + return []; + } + const records: SpawnRecord[] = []; + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + const [kind, ts, pid] = line.split('\t'); + if (kind === undefined) continue; + records.push({ kind, ts: Number(ts) || 0, pid: Number(pid) || 0 }); + } + return records; +} + +/** Count recorded spawns, optionally filtered by kind. */ +export function countSpawnRecords(file: string, kind?: string): number { + const records = readSpawnRecords(file); + if (kind === undefined) return records.length; + return records.filter((r) => r.kind === kind).length; +} diff --git a/tests/cli/read-cache-e2e.test.ts b/tests/cli/read-cache-e2e.test.ts new file mode 100644 index 000000000..7071bdfaf --- /dev/null +++ b/tests/cli/read-cache-e2e.test.ts @@ -0,0 +1,237 @@ +/** + * End-to-end tests for the wl CLI read cache (F2 — WL-0MSGAEC5N006W5QA). + * + * These spawn the REAL CLI (tsx src/cli.ts) as a subprocess against temp + * worklog dirs, with an isolated cache dir and the spawn counter enabled, + * and assert the cache behaviour through observable spawn records: + * - 'read-work' = the process did real DB work (cache miss) + * - 'cache-hit' = the process was served from cache (no DB work) + * + * Note: `execAsync` from cli-helpers runs `tsx src/cli.ts` in-process via + * cli-inproc.ts, which bypasses src/cli.ts's cache wiring — so these tests + * spawn the CLI directly instead. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; +import { countSpawnRecords } from '../../src/spawn-counter.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'src', 'cli.ts'); +const tsxBin = path.join(projectRoot, 'node_modules', '.bin', 'tsx'); + +/** Run the real CLI as a subprocess; returns stdout, stderr, exit status. */ +function runCli(args: string[], cwd: string, env: Record): { stdout: string; stderr: string; status: number | null } { + const result = spawnSync(process.execPath, [tsxBin, cliPath, ...args], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf-8', + timeout: 60000, + maxBuffer: 20 * 1024 * 1024, + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '', status: result.status }; +} + +/** Setup: temp worklog + isolated cache dir + spawn counter file. */ +function setup(): { tempDir: string; cacheDir: string; spawnFile: string; env: Record } { + const tempDir = createTempDir(); + writeConfig(tempDir, 'Read Cache E2E', 'RCE'); + writeInitSemaphore(tempDir); + const cacheDir = path.join(tempDir, 'cache'); + const spawnFile = path.join(tempDir, 'spawns.log'); + const env: Record = { + WL_CACHE_DIR: cacheDir, + WL_SPAWN_COUNT_FILE: spawnFile, + WL_TUI_MODE: '1', + }; + return { tempDir, cacheDir, spawnFile, env }; +} + +function workSpawns(spawnFile: string): number { + return countSpawnRecords(spawnFile, 'read-work'); +} + +function hitSpawns(spawnFile: string): number { + return countSpawnRecords(spawnFile, 'cache-hit'); +} + +function createItem(tempDir: string, env: Record, title: string, extra: string[] = []): void { + const res = runCli(['--json', 'create', '-t', title, ...extra], tempDir, env); + expect(res.status).toBe(0); +} + +describe('wl CLI read cache (e2e)', () => { + let tempDir: string; + let cacheDir: string; + let spawnFile: string; + let env: Record; + + beforeEach(() => { + const s = setup(); + tempDir = s.tempDir; + cacheDir = s.cacheDir; + spawnFile = s.spawnFile; + env = s.env; + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it('serves repeat identical reads from cache (AC1): list + show', () => { + createItem(tempDir, env, 'Alpha'); + createItem(tempDir, env, 'Beta'); + + const first = runCli(['list', '--json'], tempDir, env); + expect(first.status).toBe(0); + const second = runCli(['list', '--json'], tempDir, env); + expect(second.status).toBe(0); + expect(second.stdout).toBe(first.stdout); // byte-identical + expect(workSpawns(spawnFile)).toBe(1); + expect(hitSpawns(spawnFile)).toBe(1); + + // show is also covered + const id = (JSON.parse(first.stdout) as { workItems: { id: string }[] }).workItems[0].id; + const s1 = runCli(['show', id, '--json'], tempDir, env); + expect(s1.status).toBe(0); + const s2 = runCli(['show', id, '--json'], tempDir, env); + expect(s2.stdout).toBe(s1.stdout); + expect(workSpawns(spawnFile)).toBe(2); + expect(hitSpawns(spawnFile)).toBe(2); + }); + + it('a write invalidates: identical read after write returns fresh data (AC2)', () => { + createItem(tempDir, env, 'Alpha'); + const before = runCli(['list', '--json'], tempDir, env); + expect(before.status).toBe(0); + expect(JSON.parse(before.stdout).workItems).toHaveLength(1); + + createItem(tempDir, env, 'Gamma'); // write + const after = runCli(['list', '--json'], tempDir, env); + expect(after.status).toBe(0); + const items = JSON.parse(after.stdout).workItems; + expect(items).toHaveLength(2); // fresh — not served from cache + expect(items.map((i: any) => i.title)).toContain('Gamma'); + expect(workSpawns(spawnFile)).toBe(2); // both reads missed (second was invalidated) + expect(hitSpawns(spawnFile)).toBe(0); + }); + + it('arg variants produce separate cache entries (AC3)', () => { + createItem(tempDir, env, 'Alpha', ['--priority', 'high']); + createItem(tempDir, env, 'Beta', ['--priority', 'low']); + + const variants: string[][] = [ + ['list', '--json'], + ['list', '--status', 'open', '--json'], + ['list', '-n', '1', '--json'], + ]; + for (const v of variants) { + const r1 = runCli(v, tempDir, env); + expect(r1.status).toBe(0); + const r2 = runCli(v, tempDir, env); + expect(r2.stdout).toBe(r1.stdout); + } + // One miss per distinct variant; every repeat is a hit. + expect(workSpawns(spawnFile)).toBe(3); + expect(hitSpawns(spawnFile)).toBe(3); + }); + + it("next's auto re-sort counts as a write and invalidates the cache (AC4)", () => { + // Create in a deliberately non-canonical order (no re-sort) so the next + // re-sort actually changes sort indices → bumps the state counter. + createItem(tempDir, env, 'Low', ['--priority', 'low', '--no-re-sort']); + createItem(tempDir, env, 'High', ['--priority', 'high', '--no-re-sort']); + createItem(tempDir, env, 'Mid', ['--priority', 'medium', '--no-re-sort']); + + const run1 = runCli(['next', '-n', '3', '--json'], tempDir, env); + expect(run1.status).toBe(0); + const run2 = runCli(['next', '-n', '3', '--json'], tempDir, env); + expect(run2.status).toBe(0); + // The first run's re-sort byproduct invalidated the cache → run2 missed. + expect(workSpawns(spawnFile)).toBe(2); + // Run 2's re-sort was a no-op (order already canonical) → run3 hits. + const run3 = runCli(['next', '-n', '3', '--json'], tempDir, env); + expect(run3.status).toBe(0); + expect(hitSpawns(spawnFile)).toBe(1); + expect(run3.stdout).toBe(run2.stdout); + }); + + it('search is cached, but --rebuild-index is a write that invalidates (AC4)', () => { + createItem(tempDir, env, 'Searchable Widget'); + const q = ['search', 'Widget', '--json']; + + const r1 = runCli(q, tempDir, env); + expect(r1.status).toBe(0); + const r2 = runCli(q, tempDir, env); + expect(r2.stdout).toBe(r1.stdout); + expect(workSpawns(spawnFile)).toBe(1); + expect(hitSpawns(spawnFile)).toBe(1); + + // --rebuild-index is never served from cache and invalidates (write). + // It is excluded from the spawn metric (non-cacheable), so no extra + // read-work is recorded — but it must invalidate the cached search. + const rb = runCli(['search', 'Widget', '--json', '--rebuild-index'], tempDir, env); + expect(rb.status).toBe(0); + expect(hitSpawns(spawnFile)).toBe(1); // rebuild never hits + + const r3 = runCli(q, tempDir, env); + expect(r3.status).toBe(0); + expect(workSpawns(spawnFile)).toBe(2); // r1 + r3 (r3 invalidated by rebuild) + }); + + it('status is covered and write commands invalidate it (sync counts as a write)', () => { + createItem(tempDir, env, 'Alpha'); + const s1 = runCli(['status', '--json'], tempDir, env); + expect(s1.status).toBe(0); + const s2 = runCli(['status', '--json'], tempDir, env); + expect(s2.stdout).toBe(s1.stdout); + expect(workSpawns(spawnFile)).toBe(1); + expect(hitSpawns(spawnFile)).toBe(1); + }); + + it('WL_CACHE_DISABLED bypasses the cache entirely (baseline)', () => { + createItem(tempDir, env, 'Alpha'); + const disabledEnv = { ...env, WL_CACHE_DISABLED: '1' }; + const r1 = runCli(['list', '--json'], tempDir, disabledEnv); + expect(r1.status).toBe(0); + const r2 = runCli(['list', '--json'], tempDir, disabledEnv); + expect(r2.status).toBe(0); + // Baseline: every cacheable read records a work spawn, none hit. + expect(workSpawns(spawnFile)).toBe(2); + expect(hitSpawns(spawnFile)).toBe(0); + }); + + it('does not cache text (non-JSON) output', () => { + createItem(tempDir, env, 'Alpha'); + const r1 = runCli(['list'], tempDir, env); + expect(r1.status).toBe(0); + const r2 = runCli(['list'], tempDir, env); + expect(r2.status).toBe(0); + expect(hitSpawns(spawnFile)).toBe(0); // text mode never cached + expect(workSpawns(spawnFile)).toBe(0); // and excluded from the spawn metric + // No cache ENTRY files were written (the dir itself may exist from + // write-invalidation state counters). + if (fs.existsSync(cacheDir)) { + const entries = fs.readdirSync(cacheDir).filter((f) => f.endsWith('.json')); + expect(entries).toEqual([]); + } + }); + + it('a fresh cache dir misses once then hits (no cross-test pollution)', () => { + // Second test file uses its own temp dirs; this just re-verifies the + // miss→hit cycle in a pristine cache dir. + createItem(tempDir, env, 'Alpha'); + const r1 = runCli(['list', '--json'], tempDir, env); + const r2 = runCli(['list', '--json'], tempDir, env); + expect(r1.stdout).toBe(r2.stdout); + expect(workSpawns(spawnFile)).toBe(1); + expect(hitSpawns(spawnFile)).toBe(1); + }); +}); diff --git a/tests/cli/read-cache-spawn-reduction.test.ts b/tests/cli/read-cache-spawn-reduction.test.ts new file mode 100644 index 000000000..f8b4900be --- /dev/null +++ b/tests/cli/read-cache-spawn-reduction.test.ts @@ -0,0 +1,120 @@ +/** + * Spawn-reduction simulation (F2 — WL-0MSGAEC5N006W5QA, parent AC7). + * + * Simulates a 6-pane herdr refresh against one shared worklog: each pane + * issues the same query set (the herdr fetcher's real read commands), all + * byte-identical across panes. With the shared on-disk read cache, repeat + * queries are served from cache without DB work, so the number of wl + * processes that do real read work must drop by ≥60% vs the baseline + * (cache disabled). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; +import { countSpawnRecords } from '../../src/spawn-counter.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'src', 'cli.ts'); +const tsxBin = path.join(projectRoot, 'node_modules', '.bin', 'tsx'); + +const PANES = 6; + +/** The herdr fetcher's query set (see parent epic WL-0MSAZQEQB008O7H3). */ +const QUERIES: string[][] = [ + ['next', '-n', '5', '--include-in-progress', '--json'], + ['list', '--priority', 'critical', '--root-only', '--json'], + ['list', '--status', 'completed', '--stage', 'in_review', '--root-only', '--json'], + ['list', '--status', 'open,in-progress,blocked', '--json'], + ['status', '--json'], +]; + +function runCli(args: string[], cwd: string, env: Record): { status: number | null } { + const result = spawnSync(process.execPath, [tsxBin, cliPath, ...args], { + cwd, + env: { ...process.env, ...env }, + encoding: 'utf-8', + timeout: 60000, + maxBuffer: 20 * 1024 * 1024, + }); + return { status: result.status }; +} + +describe('read-cache spawn reduction (6-pane simulation)', () => { + let tempDir: string; + let spawnFile: string; + let envBase: Record; + + beforeEach(() => { + tempDir = createTempDir(); + writeConfig(tempDir, 'Spawn Reduction', 'SPR'); + writeInitSemaphore(tempDir); + spawnFile = path.join(tempDir, 'spawns.log'); + envBase = { WL_TUI_MODE: '1', WL_SPAWN_COUNT_FILE: spawnFile }; + + // Seed a realistic worklog (open/critical/completed-in-review/blocked). + const seed: Array<[string, string[]]> = [ + ['Critical Open', ['--priority', 'critical']], + ['High Open', ['--priority', 'high']], + ['Completed Review', ['--priority', 'medium', '--status', 'completed', '--stage', 'in_review']], + ['Blocked Item', ['--priority', 'low', '--status', 'blocked']], + ['In Progress', ['--priority', 'high', '--status', 'in-progress']], + ]; + for (const [title, extra] of seed) { + const res = runCli(['--json', 'create', '-t', title, ...extra], tempDir, envBase); + expect(res.status).toBe(0); + } + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + it( + 'cuts work spawns by ≥60% across a simulated 6-pane refresh', + () => { + // Baseline: cache disabled — every pane × query does real DB work. + const baselineEnv = { ...envBase, WL_CACHE_DISABLED: '1' }; + for (let pane = 0; pane < PANES; pane++) { + for (const q of QUERIES) { + const res = runCli(q, tempDir, baselineEnv); + expect(res.status).toBe(0); + } + } + const baselineWork = countSpawnRecords(spawnFile, 'read-work'); + expect(baselineWork).toBe(PANES * QUERIES.length); // 30 + + // Cached: fresh cache dir — pane 0 backfills, panes 1-5 hit. + const cacheDir = path.join(tempDir, 'cache'); + const cachedEnv = { ...envBase, WL_CACHE_DIR: cacheDir }; + // Start the spawn counter file fresh for the cached phase. + const cachedSpawnFile = path.join(tempDir, 'cached-spawns.log'); + cachedEnv.WL_SPAWN_COUNT_FILE = cachedSpawnFile; + for (let pane = 0; pane < PANES; pane++) { + for (const q of QUERIES) { + const res = runCli(q, tempDir, cachedEnv); + expect(res.status).toBe(0); + } + } + const cachedWork = countSpawnRecords(cachedSpawnFile, 'read-work'); + const cachedHits = countSpawnRecords(cachedSpawnFile, 'cache-hit'); + + const reduction = 1 - cachedWork / baselineWork; + // eslint-disable-next-line no-console + console.log( + `[spawn-reduction] panes=${PANES} queries=${QUERIES.length} ` + + `baselineWork=${baselineWork} cachedWork=${cachedWork} cacheHits=${cachedHits} ` + + `reduction=${(reduction * 100).toFixed(1)}% (target ≥60%)` + ); + + expect(cachedWork).toBeLessThanOrEqual(0.4 * baselineWork); + expect(cachedHits).toBeGreaterThan(0); // panes 1-5 were served from cache + }, + 180_000 // slow: 60+ tsx subprocess spawns + ); +}); diff --git a/tests/read-cache-cli.test.ts b/tests/read-cache-cli.test.ts new file mode 100644 index 000000000..e3443d4aa --- /dev/null +++ b/tests/read-cache-cli.test.ts @@ -0,0 +1,239 @@ +/** + * Unit tests for the wl CLI read-cache wiring (F2 — WL-0MSGAEC5N006W5QA). + * + * Covers: cacheable-invocation classification (command set, JSON mode, + * write-byproduct/non-deterministic flag exclusions), the ReadCacheCli + * orchestration (lookup → serve / backfill → invalidate-on-write), and the + * stable state-counter fingerprint used by the CLI. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + ReadCacheCli, + shouldCacheReadInvocation, + isCacheableReadCommand, + argvIsJsonMode, + hasNonCacheableReadFlags, + invalidateCacheForWrite, +} from '../src/read-cache-cli.js'; +import { readStateCounter, bumpStateCounter, counterFingerprint } from '../src/read-cache.js'; +import { createTempDir, cleanupTempDir } from './test-utils.js'; + +describe('shouldCacheReadInvocation', () => { + it('caches list/next/show/search/status in JSON mode (flag before or after command)', () => { + for (const cmd of ['list', 'next', 'show', 'search', 'status']) { + expect(shouldCacheReadInvocation(cmd, ['--json', cmd])).toBe(true); + expect(shouldCacheReadInvocation(cmd, [cmd, '--json'])).toBe(true); + } + }); + + it('never caches write commands even in JSON mode', () => { + for (const cmd of ['create', 'update', 'close', 'delete', 'comment', 'dep', 'import', 'sync', 'init', 'migrate', 're-sort']) { + expect(shouldCacheReadInvocation(cmd, [cmd, '--json'])).toBe(false); + } + }); + + it('requires --json (text output is env/TTY dependent)', () => { + expect(shouldCacheReadInvocation('list', ['list'])).toBe(false); + expect(shouldCacheReadInvocation('list', ['list', '-F', 'concise'])).toBe(false); + expect(shouldCacheReadInvocation('list', ['--verbose', 'list'])).toBe(false); + }); + + it('excludes write-byproduct and non-deterministic read flags', () => { + expect(shouldCacheReadInvocation('search', ['search', 'foo', '--json', '--rebuild-index'])).toBe(false); + expect(shouldCacheReadInvocation('search', ['search', 'foo', '--json', '--semantic'])).toBe(false); + expect(shouldCacheReadInvocation('search', ['search', 'foo', '--json', '--semantic-only'])).toBe(false); + // Plain search (FTS, deterministic given DB state) is cacheable. + expect(shouldCacheReadInvocation('search', ['search', 'foo', '--json'])).toBe(true); + }); + + it('treats arg variants as separate entries (full argv participates)', () => { + expect(shouldCacheReadInvocation('list', ['list', '--json'])).toBe(true); + expect(shouldCacheReadInvocation('list', ['list', '--status', 'open', '--json'])).toBe(true); + expect(shouldCacheReadInvocation('list', ['list', '--status', 'open', '-n', '1', '--json'])).toBe(true); + }); + + it('exposes the classification helpers it composes', () => { + expect(isCacheableReadCommand('list')).toBe(true); + expect(isCacheableReadCommand('create')).toBe(false); + expect(argvIsJsonMode(['list', '--json'])).toBe(true); + expect(argvIsJsonMode(['list'])).toBe(false); + expect(hasNonCacheableReadFlags(['search', '--json', '--semantic'])).toBe(true); + expect(hasNonCacheableReadFlags(['search', '--json'])).toBe(false); + }); +}); + +describe('ReadCacheCli', () => { + let state: { tempDir: string; originalCwd: string; worklogDir: string; cacheDir: string }; + let cli: ReadCacheCli; + + beforeEach(() => { + const tempDir = createTempDir(); + const originalCwd = process.cwd(); + const worklogDir = path.join(tempDir, '.worklog'); + fs.mkdirSync(worklogDir, { recursive: true }); + // A real-looking worklog DB file so fingerprints are meaningful. + fs.writeFileSync(path.join(worklogDir, 'worklog.db'), 'dummy-db-bytes', 'utf-8'); + const cacheDir = path.join(tempDir, 'cache'); + process.env.WL_CACHE_DIR = cacheDir; + process.chdir(tempDir); + state = { tempDir, originalCwd, worklogDir, cacheDir }; + cli = new ReadCacheCli(); + }); + + afterEach(() => { + delete process.env.WL_CACHE_DIR; + delete process.env.WL_SPAWN_COUNT_FILE; + delete process.env.WL_SPAWN_COUNT; + process.chdir(state.originalCwd); + cleanupTempDir(state.tempDir); + }); + + it('serves a repeat read from cache (miss then hit, byte-identical value)', () => { + const argv = ['list', '--json']; + const first = cli.lookup('list', argv); + expect(first.served).toBe(false); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + const second = cli.lookup('list', argv); + expect(second.served).toBe(true); + expect(second.value).toEqual({ success: true, count: 0, workItems: [] }); + }); + + it('caches despite mid-action file churn (counter fingerprint ignores open/close rewrites)', () => { + // The app rewrites the SQLite files on every open/close (WAL header + // churn). A file change between lookup and output must NOT prevent + // caching — that is why the CLI uses the stable state counter. + const argv = ['list', '--json']; + cli.lookup('list', argv); + fs.writeFileSync(path.join(state.worklogDir, 'worklog.db'), 'dummy-db-bytes-changed', 'utf-8'); + cli.onJsonOutput({ success: true, count: 1, workItems: [] }); + expect(cli.lookup('list', argv).served).toBe(true); + }); + + it('does not backfill when a write bumped the state counter mid-action', () => { + const argv = ['list', '--json']; + cli.lookup('list', argv); + bumpStateCounter(state.cacheDir, state.worklogDir); // a write lands + cli.onJsonOutput({ success: true, count: 1, workItems: [] }); + const again = cli.lookup('list', argv); + expect(again.served).toBe(false); + }); + + it('separates arg variants (distinct argv → distinct entries)', () => { + cli.lookup('list', ['list', '--json']); + cli.onJsonOutput({ success: true, count: 1, workItems: [] }); + cli.lookup('list', ['list', '--status', 'open', '--json']); + cli.onJsonOutput({ success: true, count: 2, workItems: [] }); + // Each variant now hits independently. + expect(cli.lookup('list', ['list', '--json']).served).toBe(true); + expect(cli.lookup('list', ['list', '--status', 'open', '--json']).served).toBe(true); + }); + + it('invalidateOnWrite drops cached entries for the worklog dir', () => { + cli.lookup('list', ['list', '--json']); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + expect(cli.lookup('list', ['list', '--json']).served).toBe(true); + cli.invalidateOnWrite(); + expect(cli.lookup('list', ['list', '--json']).served).toBe(false); + }); + + it('does not cache text-mode invocations', () => { + const r = cli.lookup('list', ['list']); + expect(r.served).toBe(false); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + expect(cli.lookup('list', ['list']).served).toBe(false); + }); + + it('ignores JSON output with no armed backfill (write commands)', () => { + cli.onJsonOutput({ success: true }); + expect(cli.lookup('list', ['list', '--json']).served).toBe(false); + }); + + it('never caches write-byproduct read flags (search --rebuild-index)', () => { + const argv = ['search', 'foo', '--json', '--rebuild-index']; + expect(cli.lookup('search', argv).served).toBe(false); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + expect(cli.lookup('search', argv).served).toBe(false); + }); + + it('tracks hit/miss stats for spawn-reduction reporting', () => { + cli.lookup('list', ['list', '--json']); // miss + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + cli.lookup('list', ['list', '--json']); // hit + const s = cli.stats(); + expect(s.hits).toBe(1); + expect(s.misses).toBe(1); + }); + + it('is immune to corrupt cache payloads (never throws, treats as miss)', () => { + const argv = ['list', '--json']; + cli.lookup('list', argv); + cli.onJsonOutput({ success: true, count: 0, workItems: [] }); + // Corrupt the stored entry behind the cache's back. + const entryDir = state.cacheDir; + const files = fs.readdirSync(entryDir).filter((f) => f.endsWith('.json')); + expect(files.length).toBeGreaterThan(0); + for (const f of files) fs.writeFileSync(path.join(entryDir, f), 'not-json{{', 'utf-8'); + expect(() => cli.lookup('list', argv)).not.toThrow(); + expect(cli.lookup('list', argv).served).toBe(false); + }); +}); + +describe('state counter', () => { + let state: { tempDir: string; cacheDir: string; worklogDir: string }; + + beforeEach(() => { + const tempDir = createTempDir(); + const worklogDir = path.join(tempDir, '.worklog'); + fs.mkdirSync(worklogDir, { recursive: true }); + state = { tempDir, cacheDir: path.join(tempDir, 'cache'), worklogDir }; + }); + + afterEach(() => { + cleanupTempDir(state.tempDir); + }); + + it('starts at 0 and increments monotonically', () => { + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(0); + expect(bumpStateCounter(state.cacheDir, state.worklogDir)).toBe(1); + expect(bumpStateCounter(state.cacheDir, state.worklogDir)).toBe(2); + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(2); + }); + + it('persists across cache instances (same cache dir, same worklog dir)', () => { + bumpStateCounter(state.cacheDir, state.worklogDir); + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(1); + }); + + it('is per-worklog-dir (different dirs have independent counters)', () => { + const other = path.join(state.tempDir, 'other', '.worklog'); + bumpStateCounter(state.cacheDir, state.worklogDir); + expect(readStateCounter(state.cacheDir, other)).toBe(0); + }); + + it('counterFingerprint is stable across file churn and changes only on bumps', () => { + const fp1 = counterFingerprint(state.cacheDir, state.worklogDir); + // File churn (open/close rewrites) must not change the fingerprint... + fs.writeFileSync(path.join(state.worklogDir, 'worklog.db'), 'anything', 'utf-8'); + const fp2 = counterFingerprint(state.cacheDir, state.worklogDir); + expect(fp1).toEqual(fp2); + // ...but a write bump must. + bumpStateCounter(state.cacheDir, state.worklogDir); + const fp3 = counterFingerprint(state.cacheDir, state.worklogDir); + expect(fp3).not.toEqual(fp2); + }); + + it('invalidateCacheForWrite bumps the counter for the resolved worklog dir', () => { + process.chdir(state.tempDir); + process.env.WL_CACHE_DIR = state.cacheDir; + try { + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(0); + invalidateCacheForWrite(); + expect(readStateCounter(state.cacheDir, state.worklogDir)).toBe(1); + } finally { + delete process.env.WL_CACHE_DIR; + } + }); +}); diff --git a/tests/spawn-counter.test.ts b/tests/spawn-counter.test.ts new file mode 100644 index 000000000..402c175da --- /dev/null +++ b/tests/spawn-counter.test.ts @@ -0,0 +1,61 @@ +/** + * Unit tests for the env-gated spawn counter (F2 — WL-0MSGAEC5N006W5QA). + * + * The counter records `wl` process spawns so the ≥60% spawn-reduction target + * (parent AC7) can be measured from a simulated multi-pane refresh. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { recordSpawn, readSpawnRecords, countSpawnRecords } from '../src/spawn-counter.js'; +import { createTempDir, cleanupTempDir } from './test-utils.js'; + +describe('spawn counter', () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = createTempDir(); + file = path.join(dir, 'spawns.log'); + process.env.WL_SPAWN_COUNT_FILE = file; + }); + + afterEach(() => { + delete process.env.WL_SPAWN_COUNT_FILE; + delete process.env.WL_SPAWN_COUNT; + cleanupTempDir(dir); + }); + + it('records spawn lines with kind, timestamp and pid', () => { + recordSpawn('read-work'); + recordSpawn('cache-hit'); + const recs = readSpawnRecords(file); + expect(recs).toHaveLength(2); + expect(recs[0].kind).toBe('read-work'); + expect(recs[1].kind).toBe('cache-hit'); + expect(recs[0].pid).toBe(process.pid); + expect(Number.isFinite(recs[0].ts)).toBe(true); + }); + + it('counts records, optionally filtered by kind', () => { + recordSpawn('read-work'); + recordSpawn('cache-hit'); + recordSpawn('read-work'); + expect(countSpawnRecords(file)).toBe(3); + expect(countSpawnRecords(file, 'read-work')).toBe(2); + expect(countSpawnRecords(file, 'cache-hit')).toBe(1); + }); + + it('handles an empty or missing file', () => { + expect(readSpawnRecords(file)).toEqual([]); + expect(countSpawnRecords(file)).toBe(0); + }); + + it('is a no-op without env configuration (never throws)', () => { + delete process.env.WL_SPAWN_COUNT_FILE; + delete process.env.WL_SPAWN_COUNT; + expect(() => recordSpawn('read-work')).not.toThrow(); + expect(fs.existsSync(file)).toBe(false); + }); +}); From 48b5e6059ab699333d70119765a72f57272de69a Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 00:41:54 +0100 Subject: [PATCH 10/59] WL-0MSGPI4AR000YOK8: Log herdr downtime dispatches (item comment + rolling .worklog log) --- packages/herdr/README.md | 10 +++ packages/herdr/src/downtime-log.test.ts | 79 +++++++++++++++++++ packages/herdr/src/downtime-log.ts | 46 +++++++++++ packages/herdr/src/downtime-worker.test.ts | 80 +++++++++++++++++++ packages/herdr/src/downtime-worker.ts | 59 ++++++++++++++ packages/herdr/src/index.test.ts | 91 ++++++++++++++++++++++ packages/herdr/src/index.ts | 30 +++++++ 7 files changed, 395 insertions(+) create mode 100644 packages/herdr/src/downtime-log.test.ts create mode 100644 packages/herdr/src/downtime-log.ts diff --git a/packages/herdr/README.md b/packages/herdr/README.md index be549ced6..6f45690e1 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -182,6 +182,16 @@ pre-dispatch claim; there is deliberately **no cross-pane lock file**. A small residual risk of two panes dispatching at the same idle boundary is accepted — the claim prevents duplicate work on the same item. +**Audit trail** — every successful dispatch records two traces: (1) a +comment on the dispatched item (`wl comment add`, author +`herdr-downtime`) stating the automatic dispatch, the skill run, and the +UTC timestamp — this survives `wl sync` and is the durable trail; and (2) a +bounded JSONL entry in `.worklog/downtime-dispatches.log` under the +resolved worklog root (rolling — only the most recent 100 entries are +kept). The `.worklog` log file is gitignored and local-only; both writes +are fail-closed, so a comment or log failure never blocks or fails a +dispatch. + The worker runs inside the plugin's single consolidated scheduler loop (one `setInterval`; no independent timers), uses unref'd timers, and is cleaned up when the pane exits. While the pane is open the list header shows the worker diff --git a/packages/herdr/src/downtime-log.test.ts b/packages/herdr/src/downtime-log.test.ts new file mode 100644 index 000000000..d6d4a46d7 --- /dev/null +++ b/packages/herdr/src/downtime-log.test.ts @@ -0,0 +1,79 @@ +/** + * Unit tests for downtime-log.ts — bounded JSONL audit log for herdr + * downtime dispatches (WL-0MSGPI4AR000YOK8, parent WL-0MSF49FMW009M06K). + * + * The log lives at `/.worklog/downtime-dispatches.log` and is bounded + * (rolling): the file keeps only the most recent DOWNTIME_LOG_MAX_ENTRIES + * entries so it cannot grow unbounded over a long-lived plugin pane. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + appendDowntimeLogEntry, + DOWNTIME_LOG_FILE, + DOWNTIME_LOG_MAX_ENTRIES, +} from './downtime-log.js'; + +const tempDirs: string[] = []; + +function makeTempCwd(): string { + const dir = mkdtempSync(join(tmpdir(), 'downtime-log-')); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function readLog(cwd: string): string[] { + const raw = readFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), 'utf8'); + return raw + .split('\n') + .filter((line) => line.trim() !== ''); +} + +describe('downtime rolling log', () => { + it('creates .worklog and appends a JSONL entry under the given cwd', async () => { + const cwd = makeTempCwd(); + const entry = JSON.stringify({ itemId: 'WL-A', kind: 'plan', n: 1 }); + + await appendDowntimeLogEntry(cwd, entry); + + const lines = readLog(cwd); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0])).toEqual({ itemId: 'WL-A', kind: 'plan', n: 1 }); + }); + + it('appends to an existing log file without truncating prior entries', async () => { + const cwd = makeTempCwd(); + mkdirSync(join(cwd, '.worklog')); + writeFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), '{"n":1}\n', 'utf8'); + + await appendDowntimeLogEntry(cwd, '{"n":2}'); + + const lines = readLog(cwd); + expect(lines).toHaveLength(2); + expect(JSON.parse(lines[0])).toEqual({ n: 1 }); + expect(JSON.parse(lines[1])).toEqual({ n: 2 }); + }); + + it('keeps only the most recent DOWNTIME_LOG_MAX_ENTRIES entries (rolling bound)', async () => { + const cwd = makeTempCwd(); + const total = DOWNTIME_LOG_MAX_ENTRIES + 20; + + for (let i = 0; i < total; i++) { + await appendDowntimeLogEntry(cwd, JSON.stringify({ n: i })); + } + + const lines = readLog(cwd); + expect(lines).toHaveLength(DOWNTIME_LOG_MAX_ENTRIES); + expect(JSON.parse(lines[0])).toEqual({ n: total - DOWNTIME_LOG_MAX_ENTRIES }); + expect(JSON.parse(lines[lines.length - 1])).toEqual({ n: total - 1 }); + }); +}); diff --git a/packages/herdr/src/downtime-log.ts b/packages/herdr/src/downtime-log.ts new file mode 100644 index 000000000..2f52149ee --- /dev/null +++ b/packages/herdr/src/downtime-log.ts @@ -0,0 +1,46 @@ +/** + * Bounded JSONL audit log for herdr downtime dispatches + * (WL-0MSGPI4AR000YOK8, parent WL-0MSF49FMW009M06K). + * + * The log lives at `/.worklog/downtime-dispatches.log` (the `.worklog` + * directory is gitignored, so the file is a local artifact; the worklog + * comment added alongside it is the durable cross-machine trail). The file + * is bounded — only the most recent DOWNTIME_LOG_MAX_ENTRIES entries are + * kept — so it rolls instead of growing unbounded over a long-lived plugin + * pane. Callers must treat failures as fail-closed (never crash the worker). + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** File name of the downtime dispatch audit log inside `.worklog/`. */ +export const DOWNTIME_LOG_FILE = 'downtime-dispatches.log'; + +/** Rolling bound: keep at most this many entries in the log file. */ +export const DOWNTIME_LOG_MAX_ENTRIES = 100; + +/** + * Append one entry (a JSONL line) to `/.worklog/downtime-dispatches.log`, + * creating the `.worklog` directory if needed and trimming the file back to + * the most recent DOWNTIME_LOG_MAX_ENTRIES entries. Throws on I/O failure — + * callers must catch (fail-closed). + */ +export async function appendDowntimeLogEntry(cwd: string, entry: string): Promise { + const dir = join(cwd, '.worklog'); + const file = join(dir, DOWNTIME_LOG_FILE); + await mkdir(dir, { recursive: true }); + + let lines: string[] = []; + try { + const existing = await readFile(file, 'utf8'); + lines = existing.split('\n').filter((line) => line.trim() !== ''); + } catch { + lines = []; // first entry or unreadable file → start fresh + } + + lines.push(entry); + if (lines.length > DOWNTIME_LOG_MAX_ENTRIES) { + lines = lines.slice(lines.length - DOWNTIME_LOG_MAX_ENTRIES); + } + await writeFile(file, lines.join('\n') + '\n', 'utf8'); +} diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 8cba0a22a..5f9bfc3bb 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -30,6 +30,7 @@ import { BLOCKED_QUESTIONS_INSTRUCTION, parseNextItemOutput, skillKindFromPrompt, + buildDowntimeDispatchComment, clampDowntimePollInterval, clampDowntimeIdleThresholdMs, clampDowntimeRequiredFreeSlots, @@ -59,6 +60,7 @@ function makeDeps(overrides: Partial = {}): DowntimeWorkerDe getNextItem: vi.fn().mockResolvedValue(null), claimItem: vi.fn().mockResolvedValue(undefined), spawnAgentPane: vi.fn().mockResolvedValue(undefined), + recordDispatch: vi.fn().mockResolvedValue(undefined), ...overrides, }; } @@ -250,6 +252,84 @@ describe('dispatch selection', () => { }); }); +// ── Dispatch audit trail (WL-0MSGPI4AR000YOK8) ──────────────────────── + +describe('dispatch audit trail', () => { + it('records a plan dispatch with item id, kind, timestamp and cwd', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(deps.recordDispatch).toHaveBeenCalledTimes(1); + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-ABC'); + expect(event.kind).toBe('plan'); + expect(event.cwd).toBe('/repo'); + expect(Number.isNaN(Date.parse(event.dispatchedAt))).toBe(false); + }); + + it('records an intake fallback dispatch', async () => { + const deps = makeDeps({ + getNextItem: vi + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 'WL-DEF', title: 'An idea', stage: 'idea' }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.kind).toBe('intake'); + const event = (deps.recordDispatch as ReturnType).mock.calls[0][0]; + expect(event.itemId).toBe('WL-DEF'); + expect(event.kind).toBe('intake'); + }); + + it('does not record when nothing is dispatched', async () => { + const deps = makeDeps(); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(deps.recordDispatch).not.toHaveBeenCalled(); + }); + + it('keeps the dispatch successful when recordDispatch fails (audit never blocks work)', async () => { + const deps = makeDeps({ + getNextItem: vi.fn().mockResolvedValue({ + id: 'WL-ABC', + title: 'Some task', + stage: 'intake_complete', + }), + recordDispatch: vi.fn().mockRejectedValue(new Error('audit boom')), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + }); +}); + +describe('buildDowntimeDispatchComment', () => { + it('states the skill, item id, and ISO timestamp', () => { + const comment = buildDowntimeDispatchComment('WL-ABC', 'plan', '2026-01-01T00:00:00.000Z'); + expect(comment).toContain('/skill:plan WL-ABC'); + expect(comment).toContain('2026-01-01T00:00:00.000Z'); + expect(comment).toContain('herdr downtime worker'); + }); + + it('sanitizes newlines out of the embedded title', () => { + const comment = buildDowntimeDispatchComment('WL-ABC', 'intake', '2026-01-01T00:00:00.000Z', 'multi\nline\rtitle'); + expect(comment).toContain('multi line title'); + expect(comment).not.toMatch(/[\r\n]/); + }); +}); + // ── Single-flight (AC5) ─────────────────────────────────────────────── describe('single-flight dispatch guard', () => { diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 60aaa67c4..25cf34bd6 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -311,6 +311,22 @@ export interface DowntimeWorkerDeps { claimItem(itemId: string): Promise; /** Opens a visible pi agent pane running the prompt (via send-to-pi.sh). */ spawnAgentPane(prompt: string, opts: { model: string; cwd: string }): Promise; + /** + * Audit trail for a successful dispatch: comment on the item + rolling + * log entry under `.worklog`. Must never throw (fail-closed). + */ + recordDispatch(event: DowntimeDispatchEvent): Promise; +} + +/** Audit event recorded for every successful downtime dispatch. */ +export interface DowntimeDispatchEvent { + itemId: string; + kind: DowntimeSkillKind; + /** ISO-8601 UTC timestamp of the dispatch. */ + dispatchedAt: string; + /** Worklog root (the rolling log lives at `/.worklog`). */ + cwd: string; + title?: string; } export interface DowntimeDispatchOutcome { @@ -351,12 +367,14 @@ export async function dispatchDowntimeWork( if (intakeComplete !== null) { await deps.claimItem(intakeComplete.id); await deps.spawnAgentPane(buildDowntimePrompt('plan', intakeComplete), opts); + await recordDispatchAudit(deps, 'plan', intakeComplete, opts.cwd); return { dispatched: true, candidate: intakeComplete, kind: 'plan' }; } const idea = await deps.getNextItem('idea'); if (idea !== null) { await deps.claimItem(idea.id); await deps.spawnAgentPane(buildDowntimePrompt('intake', idea), opts); + await recordDispatchAudit(deps, 'intake', idea, opts.cwd); return { dispatched: true, candidate: idea, kind: 'intake' }; } return { dispatched: false, reason: 'no-candidate' }; @@ -365,6 +383,30 @@ export async function dispatchDowntimeWork( } } +/** + * Record the audit event for a successful dispatch. Wrapped so a failing + * audit implementation (or a stub that rejects) can never fail or block the + * dispatch itself — the audit trail is best-effort (AC4). + */ +async function recordDispatchAudit( + deps: DowntimeWorkerDeps, + kind: DowntimeSkillKind, + candidate: DowntimeCandidate, + cwd: string, +): Promise { + try { + await deps.recordDispatch({ + itemId: candidate.id, + kind, + dispatchedAt: new Date().toISOString(), + cwd, + title: candidate.title, + }); + } catch { + // fail-closed: audit logging must never crash or block the worker + } +} + /** * Argument vector for spawning `send-to-pi.sh`: visible pane named * `Downtime `, `--no-focus` (visible but never steals focus), @@ -556,6 +598,23 @@ export function buildDowntimePrompt(kind: DowntimeSkillKind, candidate: Downtime ].join('\n'); } +/** + * Build the audit comment added to the item on a successful downtime + * dispatch: author-visible statement of the automatic dispatch, the skill + * run, the item, and the UTC timestamp. Newlines in the title are + * collapsed so the comment stays a single clean line. + */ +export function buildDowntimeDispatchComment( + itemId: string, + kind: DowntimeSkillKind, + dispatchedAt: string, + title?: string, +): string { + const skill = kind === 'plan' ? '/skill:plan' : '/skill:intake'; + const suffix = title ? ` (${title.replace(/[\r\n]+/g, ' ')})` : ''; + return `Auto-dispatched by the herdr downtime worker at ${dispatchedAt} — running ${skill} ${itemId}${suffix}.`; +} + // ── Wiring helpers (implemented — F4) ───────────────────────────────── /** diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 816a37b6e..9c030782f 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -13,6 +13,7 @@ import { buildSendToPiArgs, createDowntimeDeps, } from './index.js'; +import { DOWNTIME_LOG_FILE } from './downtime-log.js'; import { fetchItemsByStage, resetExecFileAsync, @@ -581,3 +582,93 @@ describe('createDowntimeDeps', () => { ); }); }); + +describe('createDowntimeDeps recordDispatch', () => { + afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + for (const dir of tempDirs) { + try { rmSync(dir, { recursive: true }); } catch { /* ignore */ } + } + tempDirs.length = 0; + }); + + it('adds an audit comment via wl comment add with the herdr-downtime author', async () => { + const mockExec = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + setExecFileAsync(mockExec as never); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.recordDispatch({ + itemId: 'WL-ABC', + kind: 'plan', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd: '/repo', + }); + + expect(mockExec).toHaveBeenCalledWith( + 'wl', + [ + 'comment', + 'add', + 'WL-ABC', + '--comment', + expect.stringContaining('/skill:plan WL-ABC'), + '--author', + 'herdr-downtime', + '--json', + ], + expect.anything(), + ); + }); + + it('writes a JSONL entry to .worklog/downtime-dispatches.log under the cwd', async () => { + setExecFileAsync(vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }) as never); + const cwd = makeTempDir(); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await deps.recordDispatch({ + itemId: 'WL-ABC', + kind: 'intake', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd, + }); + + const raw = readFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), 'utf8'); + const lines = raw.split('\n').filter((l) => l.trim() !== ''); + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0]); + expect(entry.itemId).toBe('WL-ABC'); + expect(entry.kind).toBe('intake'); + expect(entry.dispatchedAt).toBe('2026-01-01T00:00:00.000Z'); + }); + + it('is fail-closed when wl comment add fails (no throw, dispatch unaffected)', async () => { + setExecFileAsync(vi.fn().mockRejectedValue(new Error('wl boom')) as never); + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + + await expect( + deps.recordDispatch({ + itemId: 'WL-ABC', + kind: 'plan', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd: '/repo', + }), + ).resolves.toBeUndefined(); + }); + + it('is fail-closed when the log write fails (e.g. .worklog path is a file)', async () => { + setExecFileAsync(vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }) as never); + const cwd = makeTempDir(); + writeFileSync(join(cwd, '.worklog'), 'not a directory', 'utf8'); + + const deps = createDowntimeDeps('/path/to/send-to-pi.sh', 'Map'); + await expect( + deps.recordDispatch({ + itemId: 'WL-ABC', + kind: 'plan', + dispatchedAt: '2026-01-01T00:00:00.000Z', + cwd, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 3bf66c67b..5736e515c 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -44,9 +44,12 @@ import { type DowntimeWorkerDeps, type DowntimeStage, type DowntimeCandidate, + type DowntimeDispatchEvent, type DowntimeSpawn, defaultDowntimeSpawn, + buildDowntimeDispatchComment, } from './downtime-worker.js'; +import { appendDowntimeLogEntry } from './downtime-log.js'; // Resolve path to the send-to-pi.sh script (relative to this source file) // At runtime (tsx or dist), __dirname equivalent from import.meta.url @@ -241,6 +244,33 @@ export function createDowntimeDeps( spawnFn, ); }, + async recordDispatch(event: DowntimeDispatchEvent): Promise { + // 1. Durable trail: a comment on the item itself (survives wl sync). + try { + await getExecFileAsync()( + 'wl', + [ + 'comment', + 'add', + event.itemId, + '--comment', + buildDowntimeDispatchComment(event.itemId, event.kind, event.dispatchedAt), + '--author', + 'herdr-downtime', + '--json', + ], + { timeout: 5000 }, + ); + } catch { + // fail-closed: audit logging must never crash the worker + } + // 2. Rolling local log (bounded JSONL under /.worklog). + try { + await appendDowntimeLogEntry(event.cwd, JSON.stringify(event)); + } catch { + // fail-closed + } + }, }; } From 7a03f6ca7e44c6b76423cb0bebbf6915482017ab Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 00:58:50 +0100 Subject: [PATCH 11/59] WL-0MSG80EF20071ET2: resolve 8 ruff code-quality findings in herdr grid.py + test_grid.py EXE001 (WL-0MSG80F9L005O13U, WL-0MSGBU13N002YFR9): drop unused shebang from grid.py (imported as a module; invoked via python3). BLE001 (WL-0MSGGQVGH008Z3FG, WL-0MSG80FQ9000C8GE): narrow blind except Exception in main() to (OSError, ValueError, RuntimeError). PLW1510 (WL-0MSGGQVVP009G5YW, WL-0MSGGQWAO000Y46E, WL-0MSG80G61004TL0R, WL-0MSG80GMN002X1A9): add explicit check=False to subprocess.run calls in test_grid.py (lines 791/803 changed; 717/729 already had check=False). --- packages/herdr/shared/grid.py | 3 +-- packages/herdr/shared/tests/test_grid.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/herdr/shared/grid.py b/packages/herdr/shared/grid.py index 31fcc4a95..6054ec21f 100644 --- a/packages/herdr/shared/grid.py +++ b/packages/herdr/shared/grid.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Grid rebalance helper for the herdr even-completion grid algorithm. This module provides a Python helper for growing an even-completion grid of @@ -497,7 +496,7 @@ def main() -> None: print("Error: herdr did not return a new pane id", file=sys.stderr) sys.exit(1) print(json.dumps({"pane_id": new_pane_id})) - except Exception as e: + except (OSError, ValueError, RuntimeError) as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) diff --git a/packages/herdr/shared/tests/test_grid.py b/packages/herdr/shared/tests/test_grid.py index 63c9f4393..4235b189b 100644 --- a/packages/herdr/shared/tests/test_grid.py +++ b/packages/herdr/shared/tests/test_grid.py @@ -790,7 +790,7 @@ def test_cli_prints_pane_id_json(self, mock, tmp_path): env["HERDR_SOCKET_PORT"] = str(os.environ["HERDR_SOCKET_PORT"]) result = subprocess.run( [sys.executable, str(_SHARED_DIR / "grid.py"), "w1:p80"], - capture_output=True, text=True, env=env, cwd=str(_SHARED_DIR), + capture_output=True, text=True, env=env, cwd=str(_SHARED_DIR), check=False, ) assert result.returncode == 0, result.stderr out = json.loads(result.stdout) @@ -802,7 +802,7 @@ def test_cli_failure_nonzero_exit(self): env["HERDR_SOCKET_PORT"] = "1" result = subprocess.run( [sys.executable, str(_SHARED_DIR / "grid.py"), "w1:p80"], - capture_output=True, text=True, env=env, cwd=str(_SHARED_DIR), + capture_output=True, text=True, env=env, cwd=str(_SHARED_DIR), check=False, ) assert result.returncode != 0 assert "Error" in result.stderr From 51ef88a014d39533f4d8df3cb5e8e80fc7616c11 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 01:04:42 +0100 Subject: [PATCH 12/59] WL-0MSGAEJQA005QG3W: Implementation complete --- docs/ARCHITECTURE.md | 24 +++ packages/herdr/src/auto-sync.test.ts | 119 ++++++++++++ packages/herdr/src/auto-sync.ts | 97 +++++++++- packages/herdr/src/worklist.ts | 35 +++- src/commands/sync.ts | 12 ++ tests/cli/sync-cache-invalidation.test.ts | 211 ++++++++++++++++++++++ 6 files changed, 486 insertions(+), 12 deletions(-) create mode 100644 tests/cli/sync-cache-invalidation.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 80968bdee..8e44b5f08 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -247,6 +247,12 @@ Two fingerprint providers exist; the CLI uses the counter-based one: counter in a `preAction` hook *before* their action mutates the DB, and drop entry files — so an identical read immediately after a write returns fresh data. +- **Sync invalidates again after success** (F3): a successful `wl sync` + bumps the counter a second time *after* the pull/merge/push completes + (`src/commands/sync.ts`), so entries cached by concurrent readers DURING + the sync window (at the post-preAction counter) are also never served — + post-pull reads always return the merged data. Skipped (lock-busy) and + failed syncs exit before this point and do not invalidate. - **Read write-byproducts count as writes**: `next`'s auto re-sort bumps the counter after it lands (only when it actually changed sort indices), so cached `next`/`list` results stay sound; `search --rebuild-index` is @@ -268,6 +274,24 @@ Two fingerprint providers exist; the CLI uses the counter-based one: DB. Misses run normally; the JSON payload emitted via `output.json` is routed through `ReadCacheCli.onJsonOutput` for backfill. +### Cross-instance sync heartbeat (F3, herdr auto-sync) + +`wl sync` writes `/last-sync-time` (ISO timestamp) only on +success — this doubles as the per-worklog-dir sync heartbeat. Herdr's +background auto-sync (`packages/herdr/src/auto-sync.ts`) checks the heartbeat +before spawning `wl sync` and skips — without spawning a process — when a +sync succeeded within the freshness window (`heartbeatTtlMs`). + +- The TTL is the sync interval minus a 15s margin + (`heartbeatTtlForInterval`), so the sync cadence still lands ~once per + interval across all panes while only the first pane per window spawns + (6 panes → 1 spawn, measured in `auto-sync.test.ts`). +- Failed or skipped syncs never refresh the marker (the CLI writes it only on + success; dry-runs return before the write), so a stale heartbeat always + falls out of the window and a real sync eventually runs again (no + indefinite skip). +- Manual user syncs (pane `S`) bypass the heartbeat check and always run. + ### Spawn instrumentation (spawn-reduction measurement) Env-gated counters (`src/spawn-counter.ts`): `WL_SPAWN_COUNT_FILE=` diff --git a/packages/herdr/src/auto-sync.test.ts b/packages/herdr/src/auto-sync.test.ts index 76630778b..d93527f1d 100644 --- a/packages/herdr/src/auto-sync.test.ts +++ b/packages/herdr/src/auto-sync.test.ts @@ -7,6 +7,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; // --------------------------------------------------------------------------- // Module-level state for the mocked child_process.spawn @@ -53,6 +56,11 @@ import { DEFAULT_SYNC_INTERVAL_MS, MIN_SYNC_INTERVAL_MS, SYNC_DISABLED, + DEFAULT_HEARTBEAT_TTL_MS, + heartbeatTtlForInterval, + isSyncHeartbeatFresh, + readSyncHeartbeatMs, + syncHeartbeatPath, _resetSyncInFlight, } from './auto-sync.js'; @@ -315,6 +323,117 @@ describe('runSync', () => { }); }); +// --------------------------------------------------------------------------- +// Cross-instance sync heartbeat (F3 — WL-0MSGAEJQA005QG3W) +// --------------------------------------------------------------------------- + +describe('sync heartbeat', () => { + let hbDir: string; + let worklogDir: string; + + beforeEach(() => { + hbDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-hb-')); + // herdr's worklog-dir override points at the .worklog/ subdirectory. + worklogDir = path.join(hbDir, '.worklog'); + }); + + afterEach(() => { + fs.rmSync(hbDir, { recursive: true, force: true }); + }); + + function writeHeartbeat(iso: string): void { + fs.mkdirSync(worklogDir, { recursive: true }); + fs.writeFileSync(path.join(worklogDir, 'last-sync-time'), iso, 'utf-8'); + } + + it('readSyncHeartbeatMs parses the CLI-written ISO marker', () => { + writeHeartbeat('2026-06-25T12:00:00.000Z'); + expect(readSyncHeartbeatMs(worklogDir)).toBe(Date.parse('2026-06-25T12:00:00.000Z')); + }); + + it('readSyncHeartbeatMs returns undefined when the marker is absent or unparseable', () => { + expect(readSyncHeartbeatMs(worklogDir)).toBeUndefined(); + writeHeartbeat('not-a-timestamp'); + expect(readSyncHeartbeatMs(worklogDir)).toBeUndefined(); + }); + + it('isSyncHeartbeatFresh is true within the TTL window and false after it (AC3: no indefinite skip)', () => { + const now = Date.parse('2026-06-25T12:00:00.000Z'); + writeHeartbeat(new Date(now - 10_000).toISOString()); // 10s ago + expect(isSyncHeartbeatFresh(worklogDir, DEFAULT_HEARTBEAT_TTL_MS, now)).toBe(true); + // 46s ago > 45s TTL → stale → a sync must spawn again. + writeHeartbeat(new Date(now - 46_000).toISOString()); + expect(isSyncHeartbeatFresh(worklogDir, DEFAULT_HEARTBEAT_TTL_MS, now)).toBe(false); + }); + + it('isSyncHeartbeatFresh is false when no heartbeat exists (first sync must spawn)', () => { + expect(isSyncHeartbeatFresh(worklogDir, DEFAULT_HEARTBEAT_TTL_MS)).toBe(false); + }); + + it('heartbeatTtlForInterval keeps the cadence: interval minus a 15s margin', () => { + expect(heartbeatTtlForInterval(60_000)).toBe(45_000); + expect(heartbeatTtlForInterval(300_000)).toBe(285_000); + expect(heartbeatTtlForInterval(0)).toBe(SYNC_DISABLED); + expect(heartbeatTtlForInterval(30_000)).toBe(45_000); // clamped to MIN first + }); + + it('runSync with a fresh heartbeat skips WITHOUT spawning a process', async () => { + childEventToFire = 'close'; + writeHeartbeat(new Date().toISOString()); + const outcome = await runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(outcome.skipped).toBe(true); + expect(outcome.reason).toBe('heartbeat'); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('runSync with a stale heartbeat still spawns (TTL bounds the skip)', async () => { + childEventToFire = 'close'; + writeHeartbeat(new Date(Date.now() - 2 * DEFAULT_HEARTBEAT_TTL_MS).toISOString()); + const promise = runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn).toHaveBeenCalledWith('wl', ['--worklog-dir', worklogDir, 'sync', '--if-idle'], expect.any(Object)); + if (childEventCallback) childEventCallback(); + await promise; + }); + + it('runSync with no heartbeat spawns (first sync of a session)', async () => { + childEventToFire = 'close'; + const promise = runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(mockSpawn).toHaveBeenCalledTimes(1); + if (childEventCallback) childEventCallback(); + await promise; + }); + + it('runSync without heartbeat option ignores the marker (manual syncs always run)', async () => { + childEventToFire = 'close'; + writeHeartbeat(new Date().toISOString()); + const promise = runSync(worklogDir, { ifIdle: true }); // no heartbeat flag + expect(mockSpawn).toHaveBeenCalledTimes(1); + if (childEventCallback) childEventCallback(); + await promise; + }); + + it('AC2 (measured): 6 panes on one worklog → only the first spawns within the heartbeat window', async () => { + childEventToFire = 'close'; + // Pane 1: no heartbeat yet → spawns (the sync will write the marker). + let promise = runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(mockSpawn).toHaveBeenCalledTimes(1); + if (childEventCallback) childEventCallback(); + await promise; + + // The spawned wl sync succeeded → CLI wrote the heartbeat marker. + writeHeartbeat(new Date().toISOString()); + + // Panes 2-6 tick within the window → all skip without spawning. + for (let pane = 2; pane <= 6; pane++) { + const outcome = await runSync(worklogDir, { ifIdle: true, heartbeat: true }); + expect(outcome.skipped).toBe(true); + expect(outcome.reason).toBe('heartbeat'); + } + expect(mockSpawn).toHaveBeenCalledTimes(1); // 6 panes, 1 process spawned + }); +}); + // --------------------------------------------------------------------------- // SyncTimer // --------------------------------------------------------------------------- diff --git a/packages/herdr/src/auto-sync.ts b/packages/herdr/src/auto-sync.ts index 767a6735b..623bbb4af 100644 --- a/packages/herdr/src/auto-sync.ts +++ b/packages/herdr/src/auto-sync.ts @@ -13,6 +13,7 @@ */ import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; import * as path from 'node:path'; // ── Constants ───────────────────────────────────────────────────────── @@ -26,6 +27,65 @@ export const MIN_SYNC_INTERVAL_MS = 60_000; /** Sentinel value meaning "sync is disabled". */ export const SYNC_DISABLED = 0; +/** + * Default heartbeat freshness window (45s): MIN_SYNC_INTERVAL_MS minus a + * 15s margin. Must be SHORTER than the sync interval so at least one pane's + * tick lands outside the window each cycle — otherwise the heartbeat skip + * would suppress every future sync (indefinite skip, AC3). + */ +export const DEFAULT_HEARTBEAT_TTL_MS = MIN_SYNC_INTERVAL_MS - 15_000; + +// ── Cross-instance sync heartbeat (F3 — WL-0MSGAEJQA005QG3W) ────────── + +/** + * Heartbeat marker file: `/last-sync-time` containing an ISO + * timestamp. Written by the wl CLI only after a successful `wl sync` (see + * src/commands/sync.ts), so its presence means "a sync succeeded recently in + * SOME instance/process". + */ +export function syncHeartbeatPath(worklogDir: string): string { + return path.join(path.resolve(worklogDir), 'last-sync-time'); +} + +/** + * Read the last-success sync timestamp (epoch ms) for a worklog dir. + * Returns undefined when the marker is absent or unparseable. + */ +export function readSyncHeartbeatMs(worklogDir: string): number | undefined { + try { + const raw = fs.readFileSync(syncHeartbeatPath(worklogDir), 'utf-8').trim(); + const ts = Date.parse(raw); + return Number.isFinite(ts) ? ts : undefined; + } catch { + return undefined; + } +} + +/** + * True when a successful sync landed within the last `ttlMs` for the + * worklog dir. `now` is injectable for tests. + */ +export function isSyncHeartbeatFresh( + worklogDir: string, + ttlMs: number, + now: number = Date.now() +): boolean { + const ts = readSyncHeartbeatMs(worklogDir); + return ts !== undefined && now - ts < ttlMs; +} + +/** + * Heartbeat TTL for a configured sync interval: the interval minus a 15s + * margin (clamped to ≥1s), so syncs land roughly once per interval across + * all panes while only the first pane per window spawns. 0 (sync disabled) + * maps to 0. + */ +export function heartbeatTtlForInterval(intervalMs: number): number { + const clamped = clampSyncInterval(intervalMs); + if (clamped === SYNC_DISABLED) return SYNC_DISABLED; + return Math.max(1_000, clamped - 15_000); +} + // ── Single-flight state ─────────────────────────────────────────────── /** @@ -82,6 +142,24 @@ export interface RunSyncOptions { * they wait for the lock like a regular `wl sync`. */ ifIdle?: boolean; + /** + * Cross-instance heartbeat skip (auto-sync only): skip spawning `wl sync` + * entirely when a recent successful sync (heartbeat marker) exists for the + * worklog dir — so with 6 panes on one worklog, only the first to tick + * inside a window spawns a process. Manual/user syncs leave this off so + * they always run. + */ + heartbeat?: boolean; + /** Freshness window for the heartbeat (ms); default DEFAULT_HEARTBEAT_TTL_MS. */ + heartbeatTtlMs?: number; +} + +export interface SyncOutcome { + success: boolean; + error?: string; + skipped?: boolean; + /** Why the sync was skipped: `in-flight` (single-flight guard) or `heartbeat` (fresh marker). */ + reason?: 'in-flight' | 'heartbeat'; } /** @@ -95,18 +173,33 @@ export interface RunSyncOptions { * spawned CLI gets `--if-idle` so it exits immediately when the cross-process * file lock is held — preventing lock-storm process pile-up. * + * With `heartbeat`, the sync is skipped without spawning a process when a + * recent successful sync already happened in another instance (cross-instance + * coordination, F3). + * * @returns A promise resolving with the sync outcome. */ -export function runSync(worklogDir?: string, options?: RunSyncOptions): Promise<{ success: boolean; error?: string; skipped?: boolean }> { +export function runSync(worklogDir?: string, options?: RunSyncOptions): Promise { const ifIdle = options?.ifIdle ?? false; + // Cross-instance heartbeat skip: a successful sync in ANY pane/instance + // writes the heartbeat marker, so with 6 panes only the first to tick + // inside a window spawns `wl sync`; the rest skip without spawning a + // process (measured in the 6-pane spawn-reduction simulation). + if (options?.heartbeat && worklogDir) { + const ttlMs = options.heartbeatTtlMs ?? DEFAULT_HEARTBEAT_TTL_MS; + if (isSyncHeartbeatFresh(worklogDir, ttlMs)) { + return Promise.resolve({ success: false, skipped: true, reason: 'heartbeat', error: 'sync heartbeat fresh' }); + } + } + // Single-flight guard: never spawn a second sync while one is in-flight // (auto-sync path). The caller is told the sync was skipped. if (ifIdle && _syncInFlight) { return Promise.resolve({ success: false, skipped: true, error: 'sync already in progress' }); } - return new Promise<{ success: boolean; error?: string; skipped?: boolean }>((resolve) => { + return new Promise((resolve) => { try { // Target the resolved worklog (same as other wl invocations) so the // background sync operates on the tab project, not the plugin's CWD. diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 6401a926f..3240c2c1c 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -29,7 +29,7 @@ import { stageColor, type IconOptions, } from './icons.js'; -import { runSync } from './auto-sync.js'; +import { runSync, heartbeatTtlForInterval } from './auto-sync.js'; import { TaskScheduler, DEFAULT_SCHEDULER_TICK_MS } from './scheduler.js'; import { DEFAULT_DOWNTIME_POLL_INTERVAL_MS, type DowntimeWorker } from './downtime-worker.js'; import { showToast } from './notify.js'; @@ -2261,11 +2261,20 @@ export async function runWorklistTui( // // With ifIdle=true (auto-sync timer path) runSync applies its single-flight // guard and passes --if-idle to the CLI, so overlapping syncs are skipped - // instead of piling up (lock-storm prevention). Manual 'S' syncs pass - // ifIdle=false and wait for the lock like a regular wl sync. - const doSync = async (ifIdle = false): Promise => { - const outcome = await runSync(getWorklogDir(), { ifIdle }); + // instead of piling up (lock-storm prevention). With a heartbeatTtlMs the + // auto-sync path ALSO skips without spawning when another pane synced + // recently (cross-instance coordination, F3 — WL-0MSGAEJQA005QG3W); such + // skips are silent because the data is already fresh. Manual 'S' syncs pass + // ifIdle=false and no heartbeat, so they always wait for the lock like a + // regular wl sync. + const doSync = async (ifIdle = false, heartbeatTtlMs?: number): Promise => { + const outcome = await runSync(getWorklogDir(), { + ifIdle, + ...(heartbeatTtlMs !== undefined ? { heartbeat: true, heartbeatTtlMs } : {}), + }); if (outcome.skipped) { + // Heartbeat skip: another pane synced within the window — silent. + if (outcome.reason === 'heartbeat') return; // Another sync is already in-flight / holding the lock — do not pile on. showToast('Sync in progress'); return; @@ -2775,11 +2784,17 @@ export async function runWorklistTui( // Auto-sync task (background wl sync) — the only auto-sync source. Uses // the single-flight + lock-aware (--if-idle) guard so concurrent - // panes/TUI instances skip instead of piling up under lock contention. - // Visibility-gated like the refresh task: hidden panes skip the sync - // (and its follow-up refresh) entirely. Fires once immediately on start - // (as the previous SyncTimer did) so the first sync cycle is not delayed. + // panes/TUI instances skip instead of piling up under lock contention, + // and the cross-instance heartbeat (F3) so only the first pane per window + // spawns `wl sync` at all. Visibility-gated like the refresh task: hidden + // panes skip the sync (and its follow-up refresh) entirely. Fires once + // immediately on start (as the previous SyncTimer did) so the first sync + // cycle is not delayed. if (opts.autoSync && opts.syncIntervalMs !== 0) { + // Heartbeat window tied to the configured interval (interval minus a 15s + // margin) so the sync cadence still lands ~once per interval while only + // the first pane per window spawns a process. + const heartbeatTtlMs = heartbeatTtlForInterval(opts.syncIntervalMs); scheduler.addTask({ id: 'sync', intervalMs: opts.syncIntervalMs, @@ -2792,7 +2807,7 @@ export async function runWorklistTui( } stopResumePoll(); panePaused = false; - doSync(true); // ifIdle: skip when another sync is in-flight / lock held + doSync(true, heartbeatTtlMs); // ifIdle + heartbeat: skip when another sync is in-flight / fresh doRefresh(false); }, }); diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 1b0252c98..e8a848392 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -19,6 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { promisify } from 'util'; import { contextExec } from '../process-lifecycle.js'; +import { invalidateCacheForWrite } from '../read-cache-cli.js'; const execAsync = contextExec; @@ -405,6 +406,17 @@ export default function register(ctx: PluginContext): void { }), options.ifIdle ? { skipIfLocked: true } : undefined ); + + // Post-sync read-cache invalidation (F3 — WL-0MSGAEJQA005QG3W): a + // successful sync pulled and merged remote data into the DB. Bump the + // read-cache state counter so entries cached BEFORE or DURING the + // sync window are never served — the next identical read must return + // post-pull data (AC1). Only reached on success: skipped (lock busy) + // and failed syncs exit before this line and do not refresh the + // sync heartbeat either (AC3). + if (process.env.WL_CACHE_DISABLED !== '1') { + invalidateCacheForWrite(); + } } catch (error) { // Lock-aware guard: another sync holds the lock — skip gracefully // (exit 0, no error) so auto-sync spawners do not pile up waiting. diff --git a/tests/cli/sync-cache-invalidation.test.ts b/tests/cli/sync-cache-invalidation.test.ts new file mode 100644 index 000000000..2850466e0 --- /dev/null +++ b/tests/cli/sync-cache-invalidation.test.ts @@ -0,0 +1,211 @@ +/** + * E2E tests for sync → read-cache invalidation (F3 — WL-0MSGAEJQA005QG3W, AC1). + * + * Uses REAL git + a real bare remote (mock-bin stripped from PATH, mirroring + * tests/cli/sync-concurrent.test.ts) so the pull/merge/push path actually + * runs against the compiled CLI (dist/cli.js). + * + * AC1: after a successful `wl sync`, identical reads return post-pull data + * (no stale cache served). The sync command bumps the read-cache state + * counter AFTER the merge lands (preAction already bumped it before the + * action), so entries cached before/during the sync window are never served. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync, execFile } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; +import { readStateCounter } from '../../src/read-cache.js'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'cli.js'); +const mockBinDir = path.join(__dirname, 'mock-bin'); + +/** PATH without the test mock-bin so we run the real git binary. */ +function realGitPath(): string { + return (process.env.PATH || '') + .split(path.delimiter) + .filter((p) => path.resolve(p) !== path.resolve(mockBinDir)) + .join(path.delimiter); +} + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, PATH: realGitPath() } }); +} + +interface CliResult { + code: number | null; + stdout: string; + stderr: string; +} + +function runCli(cwd: string, args: string[], env: Record): Promise { + return new Promise((resolve, reject) => { + const child = execFile( + process.execPath, + [cliPath, ...args], + { + cwd, + env: { ...process.env, ...env }, + maxBuffer: 20 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error) { + resolve({ code: (error as any).code ?? 1, stdout: String(stdout), stderr: String(stderr) }); + } else { + resolve({ code: 0, stdout: String(stdout), stderr: String(stderr) }); + } + } + ); + child.on('error', reject); + }); +} + +describe('sync → read-cache invalidation (F3 AC1)', () => { + let repoA: string; // primary repo + let repoB: string; // second clone (remote-side changes) + let remoteDir: string; + let cacheDir: string; + + function envFor(cwd: string): Record { + return { + PATH: realGitPath(), + WL_CACHE_DIR: cacheDir, + WL_TUI_MODE: '1', + }; + } + + function cli(cwd: string, ...args: string[]): Promise { + return runCli(cwd, args, envFor(cwd)); + } + + beforeEach(() => { + repoA = createTempDir(); + remoteDir = createTempDir(); + git(repoA, 'init', '-q'); + git(repoA, 'config', 'user.email', 'test@example.com'); + git(repoA, 'config', 'user.name', 'Test User'); + git(repoA, 'init', '-q', '--bare', remoteDir); + git(repoA, 'remote', 'add', 'origin', remoteDir); + fs.writeFileSync(path.join(repoA, 'README.md'), '# Sync Cache Test\n', 'utf-8'); + git(repoA, 'add', 'README.md'); + git(repoA, 'commit', '-m', 'initial commit'); + + // Worklog config (syncRemote/syncBranch like sync-concurrent.test.ts). + const worklogDir = path.join(repoA, '.worklog'); + fs.mkdirSync(worklogDir, { recursive: true }); + fs.writeFileSync( + path.join(worklogDir, 'config.json'), + JSON.stringify({ + projectName: 'Sync Cache Test', + prefix: 'SC', + syncRemote: 'origin', + syncBranch: 'refs/worklog/data', + }), + 'utf-8' + ); + fs.writeFileSync( + path.join(worklogDir, 'initialized'), + JSON.stringify({ version: '1.0.0', initializedAt: '2026-01-01T00:00:00.000Z' }), + 'utf-8' + ); + + // Second clone for remote-side changes. + repoB = path.join(createTempDir(), 'cloneB'); + git(repoA, 'clone', '-q', remoteDir, repoB); + git(repoB, 'config', 'user.email', 'test@example.com'); + git(repoB, 'config', 'user.name', 'Test User'); + // Empty clone has no HEAD; the sync worktree path needs an initial commit. + git(repoB, 'commit', '--allow-empty', '-m', 'chore: initial'); + const wlB = path.join(repoB, '.worklog'); + fs.mkdirSync(wlB, { recursive: true }); + fs.writeFileSync( + path.join(wlB, 'config.json'), + JSON.stringify({ + projectName: 'Sync Cache Test', + prefix: 'SC', + syncRemote: 'origin', + syncBranch: 'refs/worklog/data', + }), + 'utf-8' + ); + fs.writeFileSync( + path.join(wlB, 'initialized'), + JSON.stringify({ version: '1.0.0', initializedAt: '2026-01-01T00:00:00.000Z' }), + 'utf-8' + ); + + cacheDir = path.join(repoA, 'cache'); + }); + + afterEach(() => { + cleanupTempDir(repoA); + cleanupTempDir(path.dirname(repoB)); + cleanupTempDir(remoteDir); + }); + + it('AC1: after a successful sync, identical reads return post-pull data (no stale cache)', async () => { + const worklogDirA = path.join(repoA, '.worklog'); + + // Seed item 1 in A and cache a read (counter C0). + const create1 = await cli(repoA, '--json', 'create', '-t', 'Item One'); + expect(create1.code).toBe(0); + const read1 = await cli(repoA, 'list', '--json'); + expect(read1.code).toBe(0); + expect(read1.stdout).toContain('Item One'); + const counterBefore = readStateCounter(cacheDir, worklogDirA); + + // B creates item 2 and pushes it to the remote via wl sync. + const create2 = await cli(repoB, '--json', 'create', '-t', 'Item Two'); + expect(create2.code).toBe(0); + const syncB = await cli(repoB, '--json', 'sync'); + expect(syncB.code).toBe(0); + expect(syncB.stdout).toContain('"success": true'); + + // A syncs: pulls item 2 into its DB. + const syncA = await cli(repoA, '--json', 'sync'); + expect(syncA.code, `syncA stdout: ${syncA.stdout}\nstderr: ${syncA.stderr}`).toBe(0); + expect(syncA.stdout).toContain('"success": true'); + + // The post-sync invalidation fired: the state counter advanced AGAIN + // after the preAction bump (preAction +1, post-sync +1 ⇒ +2 total). + const counterAfter = readStateCounter(cacheDir, worklogDirA); + expect(counterAfter).toBe(counterBefore + 2); + + // The identical read must return POST-PULL data (both items), never the + // pre-sync cached payload. + const read2 = await cli(repoA, 'list', '--json'); + expect(read2.code).toBe(0); + expect(read2.stdout).toContain('Item One'); + expect(read2.stdout).toContain('Item Two'); + }, 180000); + + it('AC3: a dry-run sync does not refresh the heartbeat marker', async () => { + const markerPath = path.join(repoA, '.worklog', 'last-sync-time'); + const testTimestamp = '2026-06-25T12:00:00.000Z'; + fs.writeFileSync(markerPath, testTimestamp, 'utf-8'); + + const dry = await cli(repoA, '--json', 'sync', '--dry-run'); + expect(dry.code).toBe(0); + expect(dry.stdout).toContain('"success": true'); + + // Skipped sync: marker untouched (a refreshed marker would suppress real + // syncs across herdr panes). + expect(fs.readFileSync(markerPath, 'utf-8')).toBe(testTimestamp); + }, 120000); + + it('AC3: a failed sync does not create/refresh the heartbeat marker', async () => { + const markerPath = path.join(repoA, '.worklog', 'last-sync-time'); + expect(fs.existsSync(markerPath)).toBe(false); + + // A nonexistent remote fails at the push step (real git) → sync fails. + const failed = await cli(repoA, '--json', 'sync', '--git-remote', 'does-not-exist'); + expect(failed.code).not.toBe(0); + expect(failed.stdout).toContain('"success": false'); + + // Failed sync: no heartbeat written — the next pane tick must still sync. + expect(fs.existsSync(markerPath)).toBe(false); + }, 120000); +}); From 737cfe1f228693612682ffd3c60ed3d9c7399b48 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 01:21:00 +0100 Subject: [PATCH 13/59] WL-0MSGAEPOJ002824S: Implementation complete --- docs/ARCHITECTURE.md | 10 +++ packages/herdr/src/fetcher.ts | 89 +++++++++++++++++++- tests/herdr/fetcher-memo.test.ts | 140 +++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 tests/herdr/fetcher-memo.test.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8e44b5f08..1f8dc6356 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -292,6 +292,16 @@ sync succeeded within the freshness window (`heartbeatTtlMs`). indefinite skip). - Manual user syncs (pane `S`) bypass the heartbeat check and always run. +### In-process fetch memoization (F4, herdr fetcher) + +`packages/herdr/src/fetcher.ts` dedupes concurrent identical `runWl` READ +fetches within one pane process via an in-flight promise memo keyed by +(worklog-dir, args, json-mode): racing refresh ticks that issue the same +query spawn `wl` once and share the result. Writes are never memoized — a +write clears the memo so a read issued after it cannot share a pre-write +in-flight result. The memo is per-process, bounded (64 in-flight entries), +and cleared on `setWorklogDir`/`resetWorklogDir`. + ### Spawn instrumentation (spawn-reduction measurement) Env-gated counters (`src/spawn-counter.ts`): `WL_SPAWN_COUNT_FILE=` diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index 521887036..c7beb00b7 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -57,6 +57,9 @@ let _worklogDir: string | undefined; */ export function setWorklogDir(dir: string | undefined): void { _worklogDir = dir; + // Switching worklog dirs invalidates any in-flight fetch memo (F4): reads + // racing against the old dir must never be shared with the new one. + clearFetchMemo(); } /** @@ -72,6 +75,7 @@ export function getWorklogDir(): string | undefined { */ export function resetWorklogDir(): void { _worklogDir = undefined; + clearFetchMemo(); } /** @@ -263,7 +267,53 @@ function extractItems(payload: unknown): WorkItem[] { const CLI_BINARIES = ['wl', 'worklog']; -async function runWl(args: string[], includeJson = true, timeoutMs?: number): Promise { +// ── In-process fetch memoization (F4 — WL-0MSGAEPOJ002824S) ─────────── + +/** + * Read commands whose results may be deduplicated in-process. Matches the + * wl CLI read-cache command set (F2). Writes (update/create/sync/…) are + * NEVER memoized — a deduped write would drop a mutation. + */ +const MEMOIZABLE_COMMANDS = new Set(['list', 'next', 'show', 'search', 'status']); + +/** Cap on in-flight memo entries (bounded: entries are removed on settle, so + * this caps only concurrent racing fetches, which is the dedupe window). */ +const MEMO_MAX_ENTRIES = 64; + +/** + * In-flight promise memo: key → shared promise for an identical read that is + * currently executing in this process. Racing identical fetches (overlapping + * refresh ticks, duplicate pane queries) share the SAME promise, so `wl` is + * spawned once instead of N times. Entries are deleted when the promise + * settles — the memo never serves a settled result, so reads that begin + * after a DB write always spawn fresh (never stale across writes). + */ +const inflightFetchMemo = new Map>(); + +function fetchMemoKey(args: string[], includeJson: boolean): string { + // Include the worklog-dir override so a dir change never cross-contaminates. + return `${_worklogDir ?? ''}\u0000${includeJson ? '1' : '0'}\u0000${args.join('\u0000')}`; +} + +/** + * Drop all in-flight fetch memo entries (after a write or worklog-dir + * change). Reads that started before the write keep their own result; reads + * issued after the write will spawn fresh instead of sharing a pre-write + * in-flight read. + */ +export function clearFetchMemo(): void { + inflightFetchMemo.clear(); +} + +/** + * Test helper: current number of in-flight memo entries. Used to verify the + * memo stays bounded under many concurrent distinct fetches. + */ +export function _fetchMemoSize(): number { + return inflightFetchMemo.size; +} + +async function runWlInner(args: string[], includeJson: boolean, timeoutMs?: number): Promise { let lastError: unknown; for (const binary of CLI_BINARIES) { @@ -303,6 +353,43 @@ async function runWl(args: string[], includeJson = true, timeoutMs?: number): Pr throw new Error(`wl CLI not found: ${String(lastError)}`); } +async function runWl(args: string[], includeJson = true, timeoutMs?: number): Promise { + const command = args[0]; + + // Writes must never be deduplicated or share pre-write read results: drop + // any in-flight read memo so a read issued after this write spawns fresh. + if (!MEMOIZABLE_COMMANDS.has(command)) { + if (inflightFetchMemo.size > 0) inflightFetchMemo.clear(); + return runWlInner(args, includeJson, timeoutMs); + } + + // Read: dedupe concurrent identical fetches within this process (F4). + const key = fetchMemoKey(args, includeJson); + const inFlight = inflightFetchMemo.get(key); + if (inFlight) { + return inFlight; + } + + const promise = runWlInner(args, includeJson, timeoutMs).finally(() => { + // Remove on settle: the memo only dedupes CONCURRENT fetches, so a + // later identical read always spawns fresh (never stale across writes). + if (inflightFetchMemo.get(key) === promise) { + inflightFetchMemo.delete(key); + } + }); + + // Bound: evict the oldest entry when over the cap (Map preserves insertion + // order, so the first key is the oldest). + if (inflightFetchMemo.size >= MEMO_MAX_ENTRIES) { + const oldest = inflightFetchMemo.keys().next().value; + if (oldest !== undefined) { + inflightFetchMemo.delete(oldest); + } + } + inflightFetchMemo.set(key, promise); + return promise; +} + // ── Public API ──────────────────────────────────────────────────────── /** diff --git a/tests/herdr/fetcher-memo.test.ts b/tests/herdr/fetcher-memo.test.ts new file mode 100644 index 000000000..3048c1859 --- /dev/null +++ b/tests/herdr/fetcher-memo.test.ts @@ -0,0 +1,140 @@ +/** + * tests/herdr/fetcher-memo.test.ts — In-process fetch memoization (F4) + * + * Verifies that racing identical `runWl` read fetches within one process + * share a single in-flight promise (spawning `wl` once), while sequential + * reads, writes, different args, and worklog-dir changes always spawn fresh. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + fetchItemsByStage, + claimWorkItem, + setExecFileAsync, + resetExecFileAsync, + setWorklogDir, + resetWorklogDir, + clearFetchMemo, + _fetchMemoSize, +} from '../../packages/herdr/src/fetcher.js'; + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +const EMPTY_OUTPUT = JSON.stringify({ results: [], workItems: [] }); + +function installMock() { + return vi.fn().mockResolvedValue({ stdout: EMPTY_OUTPUT }); +} + +describe('in-process fetch memoization (F4)', () => { + beforeEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + clearFetchMemo(); + }); + + afterEach(() => { + clearFetchMemo(); + resetWorklogDir(); + resetExecFileAsync(); + }); + + it('AC1: two racing identical fetches spawn wl once and share the result', async () => { + const d = deferred<{ stdout: string }>(); + const mock = vi.fn().mockReturnValue(d.promise); + setExecFileAsync(mock as any); + + const p1 = fetchItemsByStage('open'); + const p2 = fetchItemsByStage('open'); // identical, concurrent + + expect(mock).toHaveBeenCalledTimes(1); // ONE spawn for both + + d.resolve({ stdout: EMPTY_OUTPUT }); + const [r1, r2] = await Promise.all([p1, p2]); + expect(r1).toEqual(r2); + }); + + it('sequential identical fetches both spawn (memo only dedupes concurrent)', async () => { + const mock = installMock(); + setExecFileAsync(mock as any); + + await fetchItemsByStage('open'); + await fetchItemsByStage('open'); + expect(mock).toHaveBeenCalledTimes(2); + }); + + it('a write between two reads prevents sharing (memo never serves across DB writes)', async () => { + const d = deferred<{ stdout: string }>(); + const mock = vi.fn().mockReturnValueOnce(d.promise).mockResolvedValue({ stdout: EMPTY_OUTPUT }); + setExecFileAsync(mock as any); + + const read1 = fetchItemsByStage('open'); // in-flight read + const claim = await claimWorkItem('WL-X', 'alice'); // write → clears memo + expect(claim.success).toBe(true); + const read2 = fetchItemsByStage('open'); // must spawn fresh, not share read1 + + expect(mock).toHaveBeenCalledTimes(3); // read + write + read + + d.resolve({ stdout: EMPTY_OUTPUT }); + await Promise.all([read1, read2]); + }); + + it('different args (different stage) are not deduplicated', async () => { + const d1 = deferred<{ stdout: string }>(); + const d2 = deferred<{ stdout: string }>(); + const mock = vi.fn().mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise); + setExecFileAsync(mock as any); + + const p1 = fetchItemsByStage('open'); + const p2 = fetchItemsByStage('plan_complete'); + expect(mock).toHaveBeenCalledTimes(2); + + d1.resolve({ stdout: EMPTY_OUTPUT }); + d2.resolve({ stdout: EMPTY_OUTPUT }); + await Promise.all([p1, p2]); + }); + + it('setWorklogDir changes isolate the memo (no cross-dir sharing)', async () => { + const d1 = deferred<{ stdout: string }>(); + const d2 = deferred<{ stdout: string }>(); + const mock = vi.fn().mockReturnValueOnce(d1.promise).mockReturnValueOnce(d2.promise); + setExecFileAsync(mock as any); + + setWorklogDir('/projA/.worklog'); + const p1 = fetchItemsByStage('open'); + setWorklogDir('/projB/.worklog'); // dir change clears the memo + const p2 = fetchItemsByStage('open'); // same args, different dir → fresh spawn + + expect(mock).toHaveBeenCalledTimes(2); + d1.resolve({ stdout: EMPTY_OUTPUT }); + d2.resolve({ stdout: EMPTY_OUTPUT }); + await Promise.all([p1, p2]); + }); + + it('memo stays bounded under many concurrent distinct fetches', async () => { + const deferreds: Array>> = []; + const mock = vi.fn(() => { + const d = deferred<{ stdout: string }>(); + deferreds.push(d); + return d.promise; + }); + setExecFileAsync(mock as any); + + const promises = []; + for (let i = 0; i < 70; i++) { + promises.push(fetchItemsByStage(`stage-${i}`)); + } + // 70 distinct in-flight fetches: none deduped, memo capped at 64. + expect(mock).toHaveBeenCalledTimes(70); + expect(_fetchMemoSize()).toBeLessThanOrEqual(64); + + for (const d of deferreds) d.resolve({ stdout: EMPTY_OUTPUT }); + await Promise.all(promises); + expect(_fetchMemoSize()).toBe(0); // settled entries removed + }); +}); From 613f1490b4c1424ccb9812afa9314880ca2d9a69 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 01:45:45 +0100 Subject: [PATCH 14/59] WL-0MSAPMGLS0034BTR: Implementation complete --- package.json | 1 + packages/herdr/README.md | 24 +++- scripts/install-herdr.sh | 65 +++++++++ tests/cli/read-cache-e2e.test.ts | 4 +- tests/cli/read-cache-spawn-reduction.test.ts | 4 +- tests/read-cache.test.ts | 4 +- tests/scripts/install-herdr.test.ts | 137 +++++++++++++++++++ tests/test-utils.ts | 23 ++++ 8 files changed, 254 insertions(+), 8 deletions(-) create mode 100755 scripts/install-herdr.sh create mode 100644 tests/scripts/install-herdr.test.ts diff --git a/package.json b/package.json index 04360c972..908326dd9 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "scripts": { "build": "npm run build:shared && tsc && node ./scripts/generate-version.cjs", + "postbuild": "bash ./scripts/install-herdr.sh", "build:shared": "cd packages/shared && npm run build", "dev": "tsx watch src/index.ts", "prestart": "npm run build", diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 6f45690e1..16f74cca6 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -36,11 +36,31 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b # Clone the repository cd /path/to/worklog-repo +# Build the repo — the postbuild hook automatically links the herdr plugin +# and registers the prefix+l keybinding (idempotent; safe to re-run) +npm run build + +# Build the plugin (also triggered by herdr's own [[build]] step) +cd packages/herdr && npm run build +``` + +`npm run build` runs `scripts/install-herdr.sh` via the root `postbuild` hook. The script: + +- Links the plugin with `herdr plugin link packages/herdr/herdr-plugin.toml` (a no-op when already linked). +- Inserts the `prefix+l` → `worklog-selection-list.open-worklist` keybinding into your herdr config (`~/.config/herdr/config.toml`, or `$HERDR_CONFIG_PATH` when set) **only if** it is not already present — re-running the build never creates duplicate keybindings. +- Warns (without failing the build) when `herdr` is not on PATH or the config cannot be written, so `npm run build` succeeds in CI/offline environments. + +Manual install (fallback, if you prefer not to run the build): + +```bash # Link the plugin in Herdr herdr plugin link packages/herdr/herdr-plugin.toml -# Build the plugin -cd packages/herdr && npm run build +# Add the keybinding to ~/.config/herdr/config.toml if not already present +# [[keys.command]] +# key = "prefix+l" +# command = "herdr plugin action invoke worklog-selection-list.open-worklist" +# description = "Open the Worklog work item selection pane in a new tab." ``` The plugin pane will then be available via the Herdr plugin system. diff --git a/scripts/install-herdr.sh b/scripts/install-herdr.sh new file mode 100755 index 000000000..9ab2af5ca --- /dev/null +++ b/scripts/install-herdr.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# install-herdr.sh — idempotent Herdr integration installer for Worklog +# +# Links the `worklog-selection-list` Herdr plugin and registers the +# `prefix+l` keybinding in the Herdr config, so the worklist works out of +# the box after `npm run build` (invoked via the root `postbuild` hook). +# +# Safe to run on every build: +# - Re-linking the plugin is a no-op (herdr's own idempotence). +# - The keybinding block is inserted only when a binding for +# `worklog-selection-list.open-worklist` is not already present. +# - Missing herdr binary or an unwritable config only warn (exit 0), so +# `npm run build` never fails in CI/offline environments. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# Config resolution matches herdr itself: HERDR_CONFIG_PATH overrides, +# otherwise ~/.config/herdr/config.toml. +CONFIG_PATH="${HERDR_CONFIG_PATH:-${HOME}/.config/herdr/config.toml}" + +# The keybinding block to insert (only when the binding is absent). +KEYBINDING_BLOCK='[[keys.command]] +key = "prefix+l" +command = "herdr plugin action invoke worklog-selection-list.open-worklist" +description = "Open the Worklog work item selection pane in a new tab."' + +# ── 1. Link the plugin (no-op when already linked) ────────────────────── +if command -v herdr >/dev/null 2>&1; then + if herdr plugin link "${REPO_ROOT}/packages/herdr/herdr-plugin.toml"; then + echo "Linked herdr plugin: worklog-selection-list" + else + echo "Warning: 'herdr plugin link' failed (plugin may already be linked, or herdr is busy)." >&2 + fi +else + echo "Warning: 'herdr' not found on PATH — skipping plugin link (npm run build continues)." >&2 +fi + +# ── 2. Insert the keybinding (idempotent) ─────────────────────────────── +if grep -qF 'worklog-selection-list.open-worklist' "${CONFIG_PATH}" 2>/dev/null; then + echo "herdr keybinding already present: ${CONFIG_PATH}" + exit 0 +fi + +if ! mkdir -p "$(dirname "${CONFIG_PATH}")"; then + echo "Warning: cannot create herdr config directory '$(dirname "${CONFIG_PATH}")' — skipping keybinding insert." >&2 + exit 0 +fi + +# Append a leading newline when the existing file does not end with one, +# so the block always starts on its own line. No other edits are made. +if [[ -s "${CONFIG_PATH}" ]] && [[ "$(tail -c 1 "${CONFIG_PATH}"; printf x)" != $'\n'x ]]; then + printf '\n' >> "${CONFIG_PATH}" || { + echo "Warning: cannot write herdr config '${CONFIG_PATH}' — skipping keybinding insert." >&2 + exit 0 + } +fi + +if ! printf '%s\n' "${KEYBINDING_BLOCK}" >> "${CONFIG_PATH}"; then + echo "Warning: cannot write herdr config '${CONFIG_PATH}' — skipping keybinding insert." >&2 + exit 0 +fi + +echo "Inserted herdr keybinding (prefix+l -> worklog-selection-list.open-worklist) into ${CONFIG_PATH}" diff --git a/tests/cli/read-cache-e2e.test.ts b/tests/cli/read-cache-e2e.test.ts index 7071bdfaf..aad449457 100644 --- a/tests/cli/read-cache-e2e.test.ts +++ b/tests/cli/read-cache-e2e.test.ts @@ -18,14 +18,14 @@ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; -import { createTempDir, cleanupTempDir } from '../test-utils.js'; +import { createTempDir, cleanupTempDir, resolveTsxBin } from '../test-utils.js'; import { countSpawnRecords } from '../../src/spawn-counter.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..', '..'); const cliPath = path.join(projectRoot, 'src', 'cli.ts'); -const tsxBin = path.join(projectRoot, 'node_modules', '.bin', 'tsx'); +const tsxBin = resolveTsxBin(__dirname); /** Run the real CLI as a subprocess; returns stdout, stderr, exit status. */ function runCli(args: string[], cwd: string, env: Record): { stdout: string; stderr: string; status: number | null } { diff --git a/tests/cli/read-cache-spawn-reduction.test.ts b/tests/cli/read-cache-spawn-reduction.test.ts index f8b4900be..7a664ddb0 100644 --- a/tests/cli/read-cache-spawn-reduction.test.ts +++ b/tests/cli/read-cache-spawn-reduction.test.ts @@ -14,14 +14,14 @@ import { spawnSync } from 'child_process'; import * as path from 'path'; import { fileURLToPath } from 'url'; import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; -import { createTempDir, cleanupTempDir } from '../test-utils.js'; +import { createTempDir, cleanupTempDir, resolveTsxBin } from '../test-utils.js'; import { countSpawnRecords } from '../../src/spawn-counter.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const projectRoot = path.resolve(__dirname, '..', '..'); const cliPath = path.join(projectRoot, 'src', 'cli.ts'); -const tsxBin = path.join(projectRoot, 'node_modules', '.bin', 'tsx'); +const tsxBin = resolveTsxBin(__dirname); const PANES = 6; diff --git a/tests/read-cache.test.ts b/tests/read-cache.test.ts index b5df98432..239cc4a7d 100644 --- a/tests/read-cache.test.ts +++ b/tests/read-cache.test.ts @@ -26,7 +26,7 @@ import { DEFAULT_TTL_MS, DEFAULT_MAX_ENTRIES, } from '../src/read-cache.js'; -import { createTempDir, cleanupTempDir } from './test-utils.js'; +import { createTempDir, cleanupTempDir, resolveTsxBin } from './test-utils.js'; // ── helpers ──────────────────────────────────────────────────────────── @@ -488,7 +488,7 @@ describe('read-cache', () => { it('survives concurrent cross-process readers/writers (spawned via tsx)', async () => { const childScript = path.resolve(__dirname, 'read-cache-concurrent-child.ts'); - const tsxBin = path.resolve(__dirname, '..', 'node_modules', '.bin', 'tsx'); + const tsxBin = resolveTsxBin(__dirname); const workers = 4; const rounds = 15; const run = (id: number) => diff --git a/tests/scripts/install-herdr.test.ts b/tests/scripts/install-herdr.test.ts new file mode 100644 index 000000000..483c031de --- /dev/null +++ b/tests/scripts/install-herdr.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +// The exact keybinding block scripts/install-herdr.sh must insert. +const KEYBINDING_BLOCK = `[[keys.command]] +key = "prefix+l" +command = "herdr plugin action invoke worklog-selection-list.open-worklist" +description = "Open the Worklog work item selection pane in a new tab." +`; + +// Marker the script uses for its existence check. +const COMMAND_MARKER = 'worklog-selection-list.open-worklist'; + +function runScript(env: Record): { + status: number; + stdout: string; + stderr: string; +} { + const repoRoot = path.resolve(__dirname, '..', '..'); + const scriptPath = path.join(repoRoot, 'scripts', 'install-herdr.sh'); + const result = spawnSync('bash', [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + // Keep PATH minimal so `herdr` is deterministically absent (the + // plugin-link path is not stubbed — see plan Q2 answer b). + PATH: '/usr/bin:/bin', + ...env, + }, + }); + return { status: result.status ?? -1, stdout: result.stdout, stderr: result.stderr }; +} + +function countOccurrences(content: string, needle: string): number { + return content.split(needle).length - 1; +} + +function makeTempDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +describe('install-herdr script', () => { + it('inserts the keybinding block exactly once when run twice (idempotent)', () => { + const tempDir = makeTempDir('worklog-herdr-idempotent-'); + const configPath = path.join(tempDir, 'config.toml'); + + const first = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(first.status).toBe(0); + const afterFirst = fs.readFileSync(configPath, 'utf8'); + expect(countOccurrences(afterFirst, COMMAND_MARKER)).toBe(1); + expect(afterFirst).toBe(KEYBINDING_BLOCK); + + const second = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(second.status).toBe(0); + const afterSecond = fs.readFileSync(configPath, 'utf8'); + expect(countOccurrences(afterSecond, COMMAND_MARKER)).toBe(1); + expect(countOccurrences(afterSecond, '[[keys.command]]')).toBe(1); + expect(afterSecond).toBe(KEYBINDING_BLOCK); + }); + + it('creates the parent directory and file when the config path does not exist', () => { + const tempDir = makeTempDir('worklog-herdr-missing-'); + const configPath = path.join(tempDir, 'herdr', 'config.toml'); + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + expect(fs.existsSync(configPath)).toBe(true); + expect(fs.readFileSync(configPath, 'utf8')).toBe(KEYBINDING_BLOCK); + }); + + it('skips insertion when the config already contains the binding', () => { + const tempDir = makeTempDir('worklog-herdr-existing-'); + const configPath = path.join(tempDir, 'config.toml'); + const original = '# hand-edited config\n' + KEYBINDING_BLOCK; + fs.writeFileSync(configPath, original); + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).toBe(original); // untouched — no duplicates, no other edits + expect(countOccurrences(content, COMMAND_MARKER)).toBe(1); + }); + + it('honours HERDR_CONFIG_PATH and leaves the default config untouched', () => { + const tempHome = makeTempDir('worklog-herdr-home-'); + const overridePath = path.join(tempHome, 'override.toml'); + + const result = runScript({ HOME: tempHome, HERDR_CONFIG_PATH: overridePath }); + expect(result.status).toBe(0); + expect(fs.existsSync(overridePath)).toBe(true); + expect(fs.readFileSync(overridePath, 'utf8')).toBe(KEYBINDING_BLOCK); + expect(fs.existsSync(path.join(tempHome, '.config', 'herdr', 'config.toml'))).toBe(false); + }); + + it('warns and exits 0 when herdr is not on PATH, still inserting the binding', () => { + const tempDir = makeTempDir('worklog-herdr-noherdr-'); + const configPath = path.join(tempDir, 'config.toml'); + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + expect(result.stderr).toContain('herdr'); + expect(result.stderr.toLowerCase()).toContain('warn'); + expect(fs.existsSync(configPath)).toBe(true); + expect(fs.readFileSync(configPath, 'utf8')).toBe(KEYBINDING_BLOCK); + }); + + it('warns and exits 0 when the config directory cannot be written', () => { + const tempDir = makeTempDir('worklog-herdr-readonly-'); + fs.chmodSync(tempDir, 0o555); + const configPath = path.join(tempDir, 'config.toml'); + + try { + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + expect(result.stderr.toLowerCase()).toContain('warn'); + expect(fs.existsSync(configPath)).toBe(false); + } finally { + fs.chmodSync(tempDir, 0o755); + } + }); + + it('appends the block on a fresh line when the existing config lacks a trailing newline', () => { + const tempDir = makeTempDir('worklog-herdr-newline-'); + const configPath = path.join(tempDir, 'config.toml'); + fs.writeFileSync(configPath, 'existing = true'); // no trailing newline + + const result = runScript({ HERDR_CONFIG_PATH: configPath }); + expect(result.status).toBe(0); + const content = fs.readFileSync(configPath, 'utf8'); + expect(content).toBe('existing = true\n' + KEYBINDING_BLOCK); + expect(countOccurrences(content, COMMAND_MARKER)).toBe(1); + }); +}); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index a90eb0cb1..17a83c03d 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -7,6 +7,29 @@ import * as path from 'path'; import * as os from 'os'; import { describe as vitestDescribe, it as vitestIt } from 'vitest'; +/** + * Resolve the `tsx` CLI binary path. + * + * Worktrees have an empty local `node_modules` (deps resolve upward to the + * main checkout), so a plain `/node_modules/.bin/tsx` path breaks + * for tests that spawn tsx as a subprocess from a worktree. Walk up parent + * directories until a real `node_modules/.bin/tsx` is found. + */ +export function resolveTsxBin(fromDir: string): string { + let dir = path.resolve(fromDir); + for (;;) { + const candidate = path.join(dir, 'node_modules', '.bin', 'tsx'); + if (fs.existsSync(candidate)) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`tsx binary not found walking up from ${fromDir}`); + } + dir = parent; + } +} + /** * Create a temporary directory for test files */ From 325111701046d4059cab54ae6dcc57509d874f8b Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 02:19:16 +0100 Subject: [PATCH 15/59] WL-0MSDT8X1V003206G: Implementation complete --- packages/tui/extensions/README.md | 4 +- packages/tui/extensions/Worklog/index.ts | 2 +- packages/tui/extensions/Worklog/lib/tools.ts | 31 +++++++---- .../Worklog/settings-persistence.test.ts | 43 +++++++-------- .../worklog-browse-extension.test.ts | 53 +++++++++++++++++-- 5 files changed, 98 insertions(+), 35 deletions(-) diff --git a/packages/tui/extensions/README.md b/packages/tui/extensions/README.md index 7e46f56bb..08dede1ba 100644 --- a/packages/tui/extensions/README.md +++ b/packages/tui/extensions/README.md @@ -523,7 +523,9 @@ handler returns without performing any search or injection. ## `/wl` Slash Command — Stage Filtering -The `/wl` slash command browses work items recommended by the `wl next` algorithm. The number of items shown is controlled by the `browseItemCount` setting (default: 5). It also supports an optional stage filter argument. +The `/wl` slash command browses work items recommended by the `wl next` algorithm. The number of items shown in the default (unfiltered) view is controlled by the `browseItemCount` setting (default: 5). It also supports an optional stage filter argument. + +When a stage filter is applied (`/wl `), the browse list shows **every open work item** in that stage — there is no `browseItemCount` cap and the `wl next` selection algorithm is not applied. "Open" means `status=open`; blocked, in-progress, and completed items are excluded. Results are ordered by the standard list order (sortIndex), matching the unfiltered browse list. Child items remain hidden in the filtered top-level list and are reachable via Tab drill-down, exactly as in the unfiltered view. ### Usage diff --git a/packages/tui/extensions/Worklog/index.ts b/packages/tui/extensions/Worklog/index.ts index 947814b69..684a709b9 100644 --- a/packages/tui/extensions/Worklog/index.ts +++ b/packages/tui/extensions/Worklog/index.ts @@ -127,7 +127,7 @@ export function createWorklogBrowseExtension(deps: WorklogBrowseDependencies = { }); pi.registerCommand('wl', { - description: `Browse next ${currentSettings.browseItemCount} work items, optionally filtered by stage and settings`, + description: `Browse work items, optionally filtered by stage (stage-filtered views show every open item in that stage) and settings`, handler: async (_args: string, ctx: ExtensionCommandContext) => { showActivity(ctx as any, '/wl', currentSettings.showActivityIndicator); const trimmed = _args?.trim() ?? ''; diff --git a/packages/tui/extensions/Worklog/lib/tools.ts b/packages/tui/extensions/Worklog/lib/tools.ts index 4248d96ae..3137944e8 100644 --- a/packages/tui/extensions/Worklog/lib/tools.ts +++ b/packages/tui/extensions/Worklog/lib/tools.ts @@ -290,15 +290,26 @@ export function createDefaultListWorkItems( }; } +/** + * Create a stage-filtered list function. + * + * Stage-filtered views show ALL open root items in the stage, ordered by the + * standard list order (sortIndex) — no `browseItemCount` cap and no `wl next` + * selection algorithm (WL-0MSDT8X1V003206G). "Open" means `status=open`; + * blocked/in-progress/completed items are excluded. Root-only + * (WL-0MS964SIA0057ABR): children stay hidden in the top-level list and + * remain reachable via drill-down. The `count` parameter is kept for API + * compatibility but is unused by the filtered view — it continues to govern + * only the unfiltered `/wl` list. + */ export function createListWorkItemsWithStage( run: RunWlFn = runWl, count?: number, ): (stage: string) => Promise { return async (stage: string): Promise => { - const itemCount = count ?? currentSettings.browseItemCount; - const output = await run(['next', '-n', String(itemCount), '--stage', stage, '--include-in-progress']); + const output = await run(['list', '--status', 'open', '--stage', stage, '--root-only']); const payload = extractJsonObject(output); - return normalizeListPayload(payload).slice(0, itemCount); + return normalizeListPayload(payload); }; } @@ -404,13 +415,16 @@ export function createListWorkItemsWithStageDb( count?: number, ): (stage: string) => Promise { return async (stage: string): Promise => { - const itemCount = count ?? currentSettings.browseItemCount; const db = await getDb(); if (!db) return defaultListWorkItemsWithStage(stage); try { - // Root-only (WL-0MS964SIA0057ABR): stage-filtered top-level lists hide - // child items; children remain reachable via drill-down (wl list --parent). - const items = db.list({ stage, rootOnly: true }); + // All open root items in the stage, ordered by sortIndex — mirrors the + // CLI-backed path (wl list --status open --stage --root-only, + // WL-0MSDT8X1V003206G). Root-only (WL-0MS964SIA0057ABR): stage-filtered + // top-level lists hide child items; children remain reachable via + // drill-down (wl list --parent). The `count` parameter is kept for API + // compatibility but is unused by the filtered view. + const items = db.list({ stage, rootOnly: true, status: ['open'] }); if (!Array.isArray(items)) return defaultListWorkItemsWithStage(stage); return items .sort((a: any, b: any) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)) @@ -430,8 +444,7 @@ export function createListWorkItemsWithStageDb( issueType: item.issueType || undefined, tags: item.tags?.length ? item.tags : undefined, githubIssueNumber: item.githubIssueNumber, - })) - .slice(0, itemCount); + })); } catch { return defaultListWorkItemsWithStage(stage); } diff --git a/packages/tui/extensions/Worklog/settings-persistence.test.ts b/packages/tui/extensions/Worklog/settings-persistence.test.ts index 727429ac4..3d0b5b264 100644 --- a/packages/tui/extensions/Worklog/settings-persistence.test.ts +++ b/packages/tui/extensions/Worklog/settings-persistence.test.ts @@ -4,7 +4,8 @@ * Verifies that: * 1. createDefaultListWorkItems dynamically reads currentSettings.browseItemCount * on each invocation, not at factory-creation time (fix for stale-capture bug). - * 2. createListWorkItemsWithStage has the same dynamic behavior. + * 2. createListWorkItemsWithStage fetches ALL open root items via wl list (no + * browseItemCount cap, count arg unused) — see WL-0MSDT8X1V003206G. * 3. updateSettings() correctly updates the module-level currentSettings, * and factory functions pick up the new value on subsequent calls. * 4. updateSettings() persists changes to .pi/settings.json under the @@ -146,22 +147,25 @@ describe('createListWorkItemsWithStage', () => { vi.clearAllMocks(); }); - it('uses currentSettings.browseItemCount when no explicit count is given', async () => { + it('runs wl list --status open --stage --root-only with no cap', async () => { const factory = createListWorkItemsWithStage(mockRun); await factory('in_progress'); - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '5']), - ); + // Stage-filtered views fetch ALL open root items via wl list, without a + // browseItemCount cap or the wl next selection algorithm + // (WL-0MSDT8X1V003206G). + expect(mockRun).toHaveBeenCalledWith(['list', '--status', 'open', '--stage', 'in_progress', '--root-only']); + expect(mockRun.mock.calls[0][0]).not.toContain('-n'); }); - it('uses explicit count when provided', async () => { + it('ignores an explicit count (count governs only the unfiltered view)', async () => { const factory = createListWorkItemsWithStage(mockRun, 3); await factory('in_progress'); - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '3']), - ); + // The `count` parameter is kept for API compatibility but is unused by + // the stage-filtered view (no -n is emitted). + expect(mockRun.mock.calls[0][0]).not.toContain('-n'); + expect(mockRun).toHaveBeenCalledWith(['list', '--status', 'open', '--stage', 'in_progress', '--root-only']); }); it('passes stage argument to the run function', async () => { @@ -173,31 +177,28 @@ describe('createListWorkItemsWithStage', () => { ); }); - it('dynamically reads updated currentSettings after factory creation', async () => { + it('does not read currentSettings.browseItemCount (filtered view is uncapped)', async () => { const factory = createListWorkItemsWithStage(mockRun); updateSettings({ browseItemCount: 20 }); await factory('in_progress'); - expect(mockRun).toHaveBeenCalledWith( - expect.arrayContaining(['-n', '20']), - ); + // No -n emitted: browseItemCount is irrelevant to the stage-filtered view. + expect(mockRun.mock.calls[0][0]).not.toContain('-n'); + expect(mockRun).toHaveBeenCalledWith(['list', '--status', 'open', '--stage', 'in_progress', '--root-only']); }); - it('dynamically reads updated currentSettings on second call without recreation', async () => { + it('returns all items on every call without a count cap', async () => { const factory = createListWorkItemsWithStage(mockRun); await factory('intake_complete'); - expect(mockRun).toHaveBeenNthCalledWith(1, - expect.arrayContaining(['-n', '5']), - ); - - updateSettings({ browseItemCount: 8 }); await factory('in_review'); + + expect(mockRun).toHaveBeenNthCalledWith(1, + ['list', '--status', 'open', '--stage', 'intake_complete', '--root-only']); expect(mockRun).toHaveBeenNthCalledWith(2, - expect.arrayContaining(['-n', '8']), - ); + ['list', '--status', 'open', '--stage', 'in_review', '--root-only']); }); }); diff --git a/tests/extensions/worklog-browse-extension.test.ts b/tests/extensions/worklog-browse-extension.test.ts index 391c9ed8f..c0d9b4718 100644 --- a/tests/extensions/worklog-browse-extension.test.ts +++ b/tests/extensions/worklog-browse-extension.test.ts @@ -1685,22 +1685,69 @@ describe('Worklog browse pi extension', () => { vi.clearAllMocks(); }); - it('createListWorkItemsWithStage runs wl next -n 5 --stage', async () => { + it('createListWorkItemsWithStage runs wl list --status open --stage --root-only', async () => { const runWl = vi.fn().mockResolvedValue(JSON.stringify({ success: true, count: 1, - results: [{ workItem: { id: 'WL-S1', title: 'Stage item', status: 'open', stage: 'in_review' } }], + workItems: [{ id: 'WL-S1', title: 'Stage item', status: 'open', stage: 'in_review' }], })); const listFn = createListWorkItemsWithStage(runWl as any); const items = await listFn('in_review'); - expect(runWl).toHaveBeenCalledWith(['next', '-n', '5', '--stage', 'in_review', '--include-in-progress']); + // All open root items in the stage are fetched via wl list (not wl next), + // with no browseItemCount cap (WL-0MSDT8X1V003206G). + expect(runWl).toHaveBeenCalledWith(['list', '--status', 'open', '--stage', 'in_review', '--root-only']); expect(items).toEqual([ { id: 'WL-S1', title: 'Stage item', status: 'open', stage: 'in_review' }, ]); }); + it('createListWorkItemsWithStage returns every open root item, ignoring the browseItemCount cap', async () => { + // 7 open root items — more than the default browseItemCount of 5. + const openRoots = Array.from({ length: 7 }, (_, i) => ({ + id: `WL-ROOT-${i}`, + title: `Root item ${i}`, + status: 'open', + stage: 'idea', + parentId: null, + })); + const runWl = vi.fn().mockResolvedValue(JSON.stringify({ + success: true, + count: 7, + workItems: openRoots, + })); + + const listFn = createListWorkItemsWithStage(runWl as any); + const items = await listFn('idea'); + + // No .slice(0, itemCount): every CLI result passes through, in the + // standard list order (sortIndex) returned by wl list. + expect(items).toHaveLength(7); + expect(items.map(i => i.id)).toEqual(openRoots.map(w => w.id)); + expect(runWl).toHaveBeenCalledTimes(1); + }); + + it('createListWorkItemsWithStage passes wl list results through untouched (no client-side re-filtering)', async () => { + // The CLI --status open flag enforces AC #2 (open only). The function + // must not re-apply selection logic: whatever wl list returns (already + // open, root-only, sortIndex-ordered) is shown in full. + const runWl = vi.fn().mockResolvedValue(JSON.stringify({ + success: true, + count: 3, + workItems: [ + { id: 'WL-LOW', title: 'Deprioritised but open', status: 'open', stage: 'plan_complete' }, + { id: 'WL-HIGH', title: 'High priority', status: 'open', stage: 'plan_complete' }, + { id: 'WL-MID', title: 'Mid priority', status: 'open', stage: 'plan_complete' }, + ], + })); + + const listFn = createListWorkItemsWithStage(runWl as any); + const items = await listFn('plan_complete'); + + expect(items.map(i => i.id)).toEqual(['WL-LOW', 'WL-HIGH', 'WL-MID']); + }); + it('handler with no args uses default listWorkItems (backward compatibility)', async () => { const pi = makeStageTestPi(); const listWorkItems = vi.fn().mockResolvedValue([ From 14e9ec5c05e601f257bedd9f33172d958df0c5f7 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 03:45:59 +0100 Subject: [PATCH 16/59] WL-0MSBQUJQX005RAT9: Implementation complete --- packages/herdr/README.md | 13 + .../shared/herdr-agent-state-protocol.md | 6 + packages/herdr/shared/send-to-pi.sh | 21 ++ packages/herdr/shared/tests/test_scripts.sh | 43 +++ packages/herdr/src/agent-tracker.test.ts | 325 +++++++++++++++++ packages/herdr/src/agent-tracker.ts | 338 ++++++++++++++++++ packages/herdr/src/fetcher.ts | 7 + packages/herdr/src/icons.test.ts | 114 ++++++ packages/herdr/src/icons.ts | 53 ++- packages/herdr/src/index.test.ts | 138 +++++++ packages/herdr/src/index.ts | 148 +++++++- .../herdr/src/worklist-agent-state.test.ts | 208 +++++++++++ packages/herdr/src/worklist.ts | 18 +- 13 files changed, 1425 insertions(+), 7 deletions(-) create mode 100644 packages/herdr/src/agent-tracker.test.ts create mode 100644 packages/herdr/src/agent-tracker.ts create mode 100644 packages/herdr/src/icons.test.ts create mode 100644 packages/herdr/src/worklist-agent-state.test.ts diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 16f74cca6..c3e72c5ed 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -13,6 +13,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b - **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 dialog is 80% of the pane width (40-column minimum), text wraps at the inner width, and the box grows downward as content is entered. See [Command input form](#command-input-form). - **Keyboard navigation** — Arrow keys or j/k to navigate (wraps at list boundaries), Page Up/Down, g/G for first/last, Enter to select, Escape to go back - **Pi agent pane dispatch** — Agent commands (`/skill:*`, `/intake`, `/plan`) are automatically dispatched to a new pi agent pane opened to the right, where pi receives the command as its initial prompt. Free-form prompts use the `/prompt:` prefix: the routing prefix is stripped so pi receives only the prompt text. +- **Agent status tracking** — When an agent command carrying a work-item ID is dispatched, the worklist records which pi agent pane is attached to that item (persisted to the gitignored `.worklog/agent-panes.json`, shared across worklist panes). The list shows a live agent-status icon at the start of each row's icon prefix: 🟢 working, ⛔ blocked, ⚪ idle. Done/closed items (and items without an agent) show no icon. The icon is a fixed-width slot so the item-ID column never shifts. See [Agent status icons](#agent-status-icons). - **Open Pi Agent action** — The plugin provides an action to open a fresh interactive pi session pane - **Tab-based opening** — The worklist opens in a new tab in the current workspace, providing full-screen access without reducing space for existing panes - **Quit** — Press `q` to exit @@ -124,6 +125,18 @@ The plugin pane will then be available via the Herdr plugin system. 10. Quit: - Press `q` to close the worklist pane +### 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: + +| Icon | Meaning | +|------|---------| +| 🟢 | Agent is working on the item | +| ⛔ | Agent is blocked | +| ⚪ | Agent is idle (spawned but not currently active) | +| *(none)* | No agent attached, or the agent finished (`done`/closed) | + +The icon occupies a fixed-width slot, so the remaining icons and the item-ID column stay perfectly aligned whether or not a row has an agent. State is refreshed from the herdr CLI on each worklist refresh (with a short TTL); if the CLI is unavailable the list renders without icons rather than failing. ### From the command line ```bash diff --git a/packages/herdr/shared/herdr-agent-state-protocol.md b/packages/herdr/shared/herdr-agent-state-protocol.md index e448b282c..2d9945008 100644 --- a/packages/herdr/shared/herdr-agent-state-protocol.md +++ b/packages/herdr/shared/herdr-agent-state-protocol.md @@ -123,6 +123,12 @@ Sent when the agent is shutting down or going out of scope (e.g., on `Drop`). | `working` | Background work in progress (e.g. TTS generation, review pipeline) | | `blocked` | Waiting for user input (e.g. note-editing mode, confirmation dialog) | +> **Consumed by the worklist plugin:** the herdr worklist plugin +> (WL-0MSBQUJQX005RAT9) mirrors these states to render a per-row agent-status +> icon (🟢 working / ⛔ blocked / ⚪ idle). The herdr CLI's `agent list` +> command is the authoritative status source; the plugin queries it with a +> TTL memoizer and fails open (no icons) when it is unavailable. + ## ID Generation Message IDs follow the pattern: diff --git a/packages/herdr/shared/send-to-pi.sh b/packages/herdr/shared/send-to-pi.sh index c1581b47a..46ae36a72 100755 --- a/packages/herdr/shared/send-to-pi.sh +++ b/packages/herdr/shared/send-to-pi.sh @@ -23,6 +23,9 @@ # (herdr pane split --cwd) modes. # --model Forward `--model ` to the pi CLI invocation # (pi model pattern or id, e.g. `code` or `provider/id`) +# --pane-id-file Write the new pane ID as JSON ({"pane_id": ""}) to +# immediately after the split succeeds. Backward +# compatible: absent flag = current behavior (no file). # -h, --help Show this help message # # Environment variables: @@ -51,6 +54,7 @@ focus=true check_cli=false cwd_arg="" model_arg="" +pane_id_file="" resize=true herdr_bin="${HERDR_BIN_PATH:-herdr}" grid_bin="${HERDR_GRID_BIN:-$(cd "$(dirname "$0")" && pwd)/grid.py}" @@ -102,6 +106,14 @@ while [[ $# -gt 0 ]]; do model_arg="${1#*=}" shift ;; + --pane-id-file) + pane_id_file="$2" + shift 2 + ;; + --pane-id-file=*) + pane_id_file="${1#*=}" + shift + ;; -h|--help) show_help ;; @@ -179,6 +191,15 @@ if [ -z "$np" ]; then exit 1 fi +# ── Publish the new pane ID (optional) ────────────────────────────── +# When --pane-id-file is given, write the pane ID immediately after the split +# succeeds so the calling plugin can record the pane association without +# parsing herdr output itself. Best effort: a failed write must not abort the +# (already successful) pane spawn. +if [ -n "$pane_id_file" ]; then + printf '{"pane_id":"%s"}\n' "$np" > "$pane_id_file" 2>/dev/null || true +fi + # ── Run pi with the command in the new pane ────────────────────────── quoted_cmd="$(printf '%q' "$COMMAND")" if [ -n "$model_arg" ]; then diff --git a/packages/herdr/shared/tests/test_scripts.sh b/packages/herdr/shared/tests/test_scripts.sh index 576f850ec..ad971530d 100755 --- a/packages/herdr/shared/tests/test_scripts.sh +++ b/packages/herdr/shared/tests/test_scripts.sh @@ -255,6 +255,49 @@ else echo " herdr log: $(cat "$HERDR_LOG" 2>/dev/null)" fi +echo "" +echo "=== Test: --pane-id-file writes the new pane id after the split (resize mode) ===" +PID_FILE="$SANDBOX/pane-id.json" +rm -f "$PID_FILE" +run_send --pane-id-file "$PID_FILE" "hello" >/dev/null 2>&1 || true +if [ -f "$PID_FILE" ] && grep -q '"pane_id":"grid-pane-5"' "$PID_FILE"; then + pass "send-to-pi --pane-id-file writes the grid pane id (resize mode)" +else + fail "send-to-pi --pane-id-file should write the grid pane id" + echo " file: $(cat "$PID_FILE" 2>/dev/null)" +fi + +rm -f "$PID_FILE" +run_send --no-resize --pane-id-file "$PID_FILE" "hello" >/dev/null 2>&1 || true +if [ -f "$PID_FILE" ] && grep -q '"pane_id":"plain-pane-1"' "$PID_FILE"; then + pass "send-to-pi --pane-id-file writes the plain-split pane id (--no-resize)" +else + fail "send-to-pi --pane-id-file should write the plain-split pane id" + echo " file: $(cat "$PID_FILE" 2>/dev/null)" +fi + +rm -f "$PID_FILE" +run_send "hello" >/dev/null 2>&1 || true +if [ ! -f "$PID_FILE" ]; then + pass "send-to-pi without --pane-id-file writes no file (backward compatible)" +else + fail "send-to-pi without --pane-id-file must not write a pane-id file" +fi + +rm -f "$PID_FILE" +run_send --pane-id-file "$SANDBOX/missing-dir/pane-id.json" "hello" >/dev/null 2>&1 || true +if [ ! -f "$SANDBOX/missing-dir/pane-id.json" ]; then + pass "send-to-pi tolerates an unwritable --pane-id-file path (spawn not aborted)" +else + fail "send-to-pi should tolerate an unwritable --pane-id-file path" +fi + +if grep -q "pane run grid-pane-5 exec pi" "$HERDR_LOG" 2>/dev/null; then + pass "send-to-pi --pane-id-file still spawns pi after writing the file" +else + fail "send-to-pi --pane-id-file should still spawn pi" +fi + echo "" echo "=== Test: open-pi-agent --pane-name and --cwd ===" run_open --pane-name "My Agent" --cwd /tmp/proj >/dev/null 2>&1 || true diff --git a/packages/herdr/src/agent-tracker.test.ts b/packages/herdr/src/agent-tracker.test.ts new file mode 100644 index 000000000..495d61b7e --- /dev/null +++ b/packages/herdr/src/agent-tracker.test.ts @@ -0,0 +1,325 @@ +/** + * packages/herdr/src/agent-tracker.test.ts — Unit tests for the + * work-item ↔ agent-pane association tracker (WL-0MSBQUJQX005RAT9). + * + * Covers: + * - recordAgentForWorkItem / getPaneId (in-memory + persisted) + * - persistence: atomic state file round-trip across tracker instances + * - refreshStates: herdr agent list parsing, state mapping, pruning of + * gone/done panes, PollGate-style TTL memoization + * - fail-open: CLI errors / unparseable output keep entries and yield no + * icons + * + * Run: npx vitest run packages/herdr/src/agent-tracker.test.ts + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + AgentTracker, + normalizeAgentState, + parseAgentListOutput, + type AgentState, +} from './agent-tracker.js'; +import { + setExecFileAsync, + resetExecFileAsync, +} from './fetcher.js'; + +/** + * Build a herdr-style `agent list` JSON envelope. The CLI may prefix the + * envelope with log lines, so the parser must scan for the first `{`. + */ +function agentListEnvelope(agents: Array>, prefix = ''): string { + return `${prefix}${JSON.stringify({ + id: 'cli:agent:list', + result: { agents }, + })}`; +} + +function makeStateFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'herdr-agent-tracker-')); + return join(dir, 'agent-panes.json'); +} + +describe('AgentTracker — record & in-memory lookup', () => { + it('records a workItemId → paneId association', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-1'); + expect(tracker.getPaneId('WL-ABC')).toBe('pane-1'); + }); + + it('returns undefined for unknown work items', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + expect(tracker.getPaneId('WL-NOPE')).toBeUndefined(); + }); + + it('overwrites an existing association for the same work item', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-1'); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-2'); + expect(tracker.getPaneId('WL-ABC')).toBe('pane-2'); + expect(tracker.size).toBe(1); + }); +}); + +describe('AgentTracker — persistence', () => { + it('persists the mapping to the state file (atomic write)', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile }); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-1'); + await tracker.recordAgentForWorkItem('WL-DEF', 'pane-2'); + + expect(existsSync(stateFile)).toBe(true); + const raw = JSON.parse(readFileSync(stateFile, 'utf8')); + expect(raw).toHaveLength(2); + expect(raw[0]).toMatchObject({ workItemId: 'WL-ABC', paneId: 'pane-1' }); + expect(raw[1]).toMatchObject({ workItemId: 'WL-DEF', paneId: 'pane-2' }); + }); + + it('restores the mapping across tracker instances (plugin restart)', async () => { + const stateFile = makeStateFile(); + const first = new AgentTracker({ stateFile }); + await first.recordAgentForWorkItem('WL-ABC', 'pane-1'); + + const second = new AgentTracker({ stateFile }); + expect(second.getPaneId('WL-ABC')).toBe('pane-1'); + }); + + it('tolerates a missing or corrupt state file (fail-open)', async () => { + const stateFile = makeStateFile(); + // Missing file — no entries, no throw. + const empty = new AgentTracker({ stateFile: join(stateFile, 'nope.json') }); + expect(empty.size).toBe(0); + + // Corrupt file — no entries, no throw, and recording still works. + writeFileSync(stateFile, '{not json', 'utf8'); + const corrupt = new AgentTracker({ stateFile }); + expect(corrupt.size).toBe(0); + await corrupt.recordAgentForWorkItem('WL-ABC', 'pane-1'); + expect(corrupt.getPaneId('WL-ABC')).toBe('pane-1'); + }); + + it('pruneEntry removes and persists the change', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile }); + await tracker.recordAgentForWorkItem('WL-ABC', 'pane-1'); + await tracker.recordAgentForWorkItem('WL-DEF', 'pane-2'); + await tracker.pruneEntry('WL-ABC'); + + expect(tracker.getPaneId('WL-ABC')).toBeUndefined(); + expect(tracker.size).toBe(1); + const raw = JSON.parse(readFileSync(stateFile, 'utf8')); + expect(raw).toHaveLength(1); + expect(raw[0].workItemId).toBe('WL-DEF'); + }); +}); + +describe('normalizeAgentState', () => { + it('maps known statuses to canonical AgentState values', () => { + expect(normalizeAgentState('idle')).toBe('idle'); + expect(normalizeAgentState('working')).toBe('working'); + expect(normalizeAgentState('blocked')).toBe('blocked'); + expect(normalizeAgentState('done')).toBe('done'); + }); + + it('maps unknown/empty statuses to unknown', () => { + expect(normalizeAgentState('weird')).toBe('unknown'); + expect(normalizeAgentState('')).toBe('unknown'); + expect(normalizeAgentState(undefined)).toBe('unknown'); + expect(normalizeAgentState('WORKING')).toBe('working'); // case-insensitive + }); +}); + +describe('parseAgentListOutput', () => { + it('parses the result.agents array shape', () => { + const records = parseAgentListOutput( + agentListEnvelope([ + { pane_id: 'p1', agent_status: 'working' }, + { pane_id: 'p2', agent_status: 'blocked' }, + ]), + ); + expect(records).toEqual([ + { paneId: 'p1', status: 'working' }, + { paneId: 'p2', status: 'blocked' }, + ]); + }); + + it('tolerates log lines prefixed before the JSON envelope', () => { + const records = parseAgentListOutput( + agentListEnvelope([{ pane_id: 'p1', agent_status: 'idle' }], 'some log line\n'), + ); + expect(records).toEqual([{ paneId: 'p1', status: 'idle' }]); + }); + + it('reads camelCase aliases (paneId / agentStatus)', () => { + const records = parseAgentListOutput( + JSON.stringify({ result: { agents: [{ paneId: 'p1', agentStatus: 'working' }] } }), + ); + expect(records).toEqual([{ paneId: 'p1', status: 'working' }]); + }); + + it('reads a bare status field when agent_status is absent', () => { + const records = parseAgentListOutput( + JSON.stringify({ result: { agents: [{ pane_id: 'p1', status: 'done' }] } }), + ); + expect(records).toEqual([{ paneId: 'p1', status: 'done' }]); + }); + + it('returns null for unparseable output (fail-open trigger)', () => { + expect(parseAgentListOutput('not json at all')).toBeNull(); + expect(parseAgentListOutput('')).toBeNull(); + }); + + it('returns null when no agent array is present', () => { + expect(parseAgentListOutput(JSON.stringify({ result: { ok: true } }))).toBeNull(); + expect(parseAgentListOutput(JSON.stringify({ result: { agents: 'nope' } }))).toBeNull(); + }); +}); + +describe('AgentTracker — refreshStates', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_BIN_PATH; + }); + + afterEach(() => { + resetExecFileAsync(); + process.env.HERDR_BIN_PATH = originalEnv.HERDR_BIN_PATH; + }); + + it('maps live agent panes to states and prunes gone panes', async () => { + const stateFile = makeStateFile(); + const tracker = new AgentTracker({ stateFile }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + await tracker.recordAgentForWorkItem('WL-GONE', 'pX'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([{ pane_id: 'p1', agent_status: 'working' }]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const states = await tracker.refreshStates(); + expect(states.get('WL-ABC')).toBe('working'); + expect(states.has('WL-GONE')).toBe(false); // pane pX not an agent → pruned + expect(tracker.getPaneId('WL-GONE')).toBeUndefined(); // pruned in memory + // Pruning is persisted. + const raw = JSON.parse(readFileSync(stateFile, 'utf8')); + expect(raw).toHaveLength(1); + expect(raw[0].workItemId).toBe('WL-ABC'); + // The CLI is invoked as `herdr agent list` (HERDR_BIN_PATH unset → herdr). + expect(mockFn.mock.calls[0][1]).toEqual(['agent', 'list']); + }); + + it('maps idle/working/blocked and prunes done panes', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-1', 'p1'); + await tracker.recordAgentForWorkItem('WL-2', 'p2'); + await tracker.recordAgentForWorkItem('WL-3', 'p3'); + await tracker.recordAgentForWorkItem('WL-DONE', 'p4'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([ + { pane_id: 'p1', agent_status: 'idle' }, + { pane_id: 'p2', agent_status: 'working' }, + { pane_id: 'p3', agent_status: 'blocked' }, + { pane_id: 'p4', agent_status: 'done' }, + ]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const states = await tracker.refreshStates(); + expect(states.get('WL-1')).toBe('idle'); + expect(states.get('WL-2')).toBe('working'); + expect(states.get('WL-3')).toBe('blocked'); + // done → no icon: pruned. + expect(states.has('WL-DONE')).toBe(false); + expect(tracker.getPaneId('WL-DONE')).toBeUndefined(); + }); + + it('fails open when the herdr CLI errors: no icons, entries kept', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const mockFn = vi.fn().mockRejectedValue({ code: 'ENOENT' }); + setExecFileAsync(mockFn as any); + + const states = await tracker.refreshStates(); + expect(states.size).toBe(0); + // Entries survive the failed refresh so icons reappear when the CLI returns. + expect(tracker.getPaneId('WL-ABC')).toBe('p1'); + }); + + it('fails open on unparseable CLI output: no icons, entries kept', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile() }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const mockFn = vi.fn().mockResolvedValue({ stdout: 'garbage', stderr: '' }); + setExecFileAsync(mockFn as any); + + const states = await tracker.refreshStates(); + expect(states.size).toBe(0); + expect(tracker.getPaneId('WL-ABC')).toBe('p1'); + }); + + it('memoizes the herdr query within the TTL (PollGate pattern)', async () => { + const tracker = new AgentTracker({ stateFile: makeStateFile(), ttlMs: 60_000 }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([{ pane_id: 'p1', agent_status: 'working' }]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const first = await tracker.refreshStates(); + const second = await tracker.refreshStates(); + expect(first.get('WL-ABC')).toBe('working'); + expect(second.get('WL-ABC')).toBe('working'); + expect(mockFn).toHaveBeenCalledTimes(1); // second call served from cache + }); + + it('uses HERDR_BIN_PATH when set', async () => { + process.env.HERDR_BIN_PATH = '/custom/herdr'; + const tracker = new AgentTracker({ stateFile: makeStateFile(), ttlMs: 60_000 }); + await tracker.recordAgentForWorkItem('WL-ABC', 'p1'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([{ pane_id: 'p1', agent_status: 'idle' }]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + await tracker.refreshStates(); + expect(mockFn.mock.calls[0][0]).toBe('/custom/herdr'); + }); + + it('reloads the state file on refresh so other panes/tabs entries are shared', async () => { + const stateFile = makeStateFile(); + const tabA = new AgentTracker({ stateFile }); + await tabA.recordAgentForWorkItem('WL-ABC', 'p1'); + + // Tab B starts after A persisted, then A adds another entry while B runs. + const tabB = new AgentTracker({ stateFile, ttlMs: 60_000 }); + await tabA.recordAgentForWorkItem('WL-DEF', 'p2'); + + const mockFn = vi.fn().mockResolvedValue({ + stdout: agentListEnvelope([ + { pane_id: 'p1', agent_status: 'working' }, + { pane_id: 'p2', agent_status: 'blocked' }, + ]), + stderr: '', + }); + setExecFileAsync(mockFn as any); + + const states = await tabB.refreshStates(); + expect(states.get('WL-ABC')).toBe('working'); + expect(states.get('WL-DEF')).toBe('blocked'); + }); +}); diff --git a/packages/herdr/src/agent-tracker.ts b/packages/herdr/src/agent-tracker.ts new file mode 100644 index 000000000..4b1cefbef --- /dev/null +++ b/packages/herdr/src/agent-tracker.ts @@ -0,0 +1,338 @@ +/** + * packages/herdr/src/agent-tracker.ts — Work-item ↔ agent-pane association + * tracker (WL-0MSBQUJQX005RAT9) + * + * Records which work-item each worklist-spawned pi agent pane is working on, + * persists the mapping to a gitignored JSON state file + * (`.worklog/agent-panes.json`), and resolves each pane's current agent + * state (`idle` / `working` / `blocked` / `done`) via the herdr CLI so the + * worklist can render an agent-status icon per row. + * + * Design notes: + * - Only worklist-spawned agent commands are tracked (the association is + * recorded at dispatch time when the new pane ID is captured). + * - The state file is shared across worklist panes/tabs: each refresh + * re-reads the file (last-writer-wins) before querying herdr. + * - Status lookup fails open: when the herdr CLI is unavailable or its + * output is unparseable, `refreshStates()` returns an empty map (no + * icons) and existing entries are kept so icons reappear when the CLI + * returns. + * - Entries whose pane is gone (no longer in `herdr agent list`) or whose + * agent reports `done` are pruned so the icon disappears. + * - The `herdr agent list` query is memoized within a TTL (PollGate + * pattern from visibility.ts) to avoid CLI spawn churn on every refresh + * tick. + * + * The implementation is self-contained and portable (pure functions where + * feasible) to ease the planned Rust migration (WL-0MS8XGSE400374VS). + */ + +import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs'; +import { getExecFileAsync } from './fetcher.js'; +import type { WorkItem } from './fetcher.js'; + +// ── Types ──────────────────────────────────────────────────────────── + +/** + * Agent states mirrored from the herdr sidebar protocol + * (packages/herdr/shared/herdr-agent-state-protocol.md): `idle`, `working`, + * `blocked` plus the CLI-terminal `done` and the catch-all `unknown`. + */ +export type AgentState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown'; + +/** One recorded work-item ↔ pane association. */ +export interface AgentPaneEntry { + workItemId: string; + paneId: string; + /** ISO timestamp of when the association was recorded. */ + recordedAt: string; +} + +/** A parsed `herdr agent list` record. */ +export interface AgentListRecord { + paneId: string; + status: string; +} + +// ── Constants ──────────────────────────────────────────────────────── + +/** Name of the state file inside the project's `.worklog/` directory. */ +export const AGENT_PANES_FILE = 'agent-panes.json'; + +/** Default TTL for the refreshStates memoizer (mirrors visibility.ts). */ +export const AGENT_STATE_TTL_MS = 2000; + +// ── Pure helpers ───────────────────────────────────────────────────── + +/** + * Normalize a raw agent-status string from the herdr CLI into an AgentState. + * Unknown/empty values map to `unknown`. + */ +export function normalizeAgentState(status: string | undefined): AgentState { + const key = (status || '').toLowerCase().trim(); + if (key === 'idle' || key === 'working' || key === 'blocked' || key === 'done') { + return key; + } + return 'unknown'; +} + +/** + * Parse `herdr agent list` output into pane → status records. + * + * Tolerates log lines prefixed before the JSON envelope (scan for the first + * `{`, like fetcher.ts), multiple envelope shapes, and snake_case/camelCase + * field aliases. Returns null when no agent array can be found (the caller + * treats this as fail-open). + */ +export function parseAgentListOutput(raw: string): AgentListRecord[] | null { + const start = raw.indexOf('{'); + if (start < 0) return null; + let payload: unknown; + try { + payload = JSON.parse(raw.slice(start)); + } catch { + return null; + } + + let agents: unknown = null; + if (payload && typeof payload === 'object') { + const obj = payload as Record; + const result = obj.result; + const resultObj = result && typeof result === 'object' ? (result as Record) : null; + if (Array.isArray(resultObj?.agents)) { + agents = resultObj.agents; + } else if (Array.isArray(obj.agents)) { + agents = obj.agents; + } else if (Array.isArray(result)) { + agents = result; + } else if (Array.isArray(obj)) { + agents = obj; + } + } + + if (!Array.isArray(agents)) return null; + + const records: AgentListRecord[] = []; + for (const entry of agents) { + if (!entry || typeof entry !== 'object') continue; + const rec = entry as Record; + const paneId = rec.pane_id ?? rec.paneId; + const status = rec.agent_status ?? rec.agentStatus ?? rec.status; + if (typeof paneId === 'string' && paneId !== '' && typeof status === 'string') { + records.push({ paneId, status }); + } + } + return records; +} + +// ── Tracker ────────────────────────────────────────────────────────── + +export interface AgentTrackerOptions { + /** Path to the persisted state file (`.worklog/agent-panes.json`). */ + stateFile?: string; + /** TTL for refreshStates memoization (PollGate pattern). */ + ttlMs?: number; +} + +/** + * In-memory + persisted mapping from work item ID → agent pane ID, with + * herdr-backed state refresh. Never throws on herdr/fs errors (fail-open). + */ +export class AgentTracker { + private readonly stateFile?: string; + private readonly ttlMs: number; + private readonly entries = new Map(); + private cachedStates = new Map(); + private cachedAt = 0; + + constructor(opts: AgentTrackerOptions = {}) { + this.stateFile = opts.stateFile; + this.ttlMs = opts.ttlMs ?? AGENT_STATE_TTL_MS; + this.loadFromDisk(); + } + + /** Number of tracked associations. */ + get size(): number { + return this.entries.size; + } + + /** Snapshot of the tracked entries (read-only for callers). */ + snapshot(): AgentPaneEntry[] { + return [...this.entries.values()]; + } + + /** + * Record (or overwrite) the association for a work item and persist it. + * Persistence failures are swallowed (fail-open). + */ + async recordAgentForWorkItem(workItemId: string, paneId: string): Promise { + this.entries.set(workItemId, { workItemId, paneId, recordedAt: new Date().toISOString() }); + this.persist(); + } + + /** Remove a single association and persist the change. */ + async pruneEntry(workItemId: string): Promise { + if (this.entries.delete(workItemId)) { + this.persist(); + } + } + + /** Return the pane ID recorded for a work item, or undefined. */ + getPaneId(workItemId: string): string | undefined { + return this.entries.get(workItemId)?.paneId; + } + + /** + * Refresh the agent states for all tracked panes, memoized within the TTL. + * + * On a cache miss: re-reads the shared state file (so associations + * recorded by other panes/tabs are picked up), queries `herdr agent list` + * once, prunes entries whose pane is gone or whose agent reports `done`, + * persists any pruning, and returns the resulting + * `Map`. + * + * Fail-open: a herdr CLI error or unparseable output yields an empty map + * (no icons) and keeps all entries. + */ + async refreshStates(): Promise> { + const now = Date.now(); + if (now - this.cachedAt < this.ttlMs) { + return this.cachedStates; + } + + // Re-read the shared file so other worklist panes/tabs are reflected. + this.loadFromDisk(); + + const bin = process.env.HERDR_BIN_PATH ?? 'herdr'; + try { + const execFileAsync = getExecFileAsync(); + const result = await execFileAsync(bin, ['agent', 'list'], { maxBuffer: 1024 * 1024 }); + const records = parseAgentListOutput(result.stdout); + if (!records) { + throw new Error('Unparseable herdr agent list output'); + } + + const statusByPane = new Map(); + for (const rec of records) { + statusByPane.set(rec.paneId, rec.status); + } + + const states = new Map(); + let pruned = false; + for (const [workItemId, entry] of [...this.entries]) { + const status = statusByPane.get(entry.paneId); + if (status === undefined) { + // Pane is gone (closed/exited) or no longer an agent — prune so + // the icon disappears on the next refresh cycle (AC5). + this.entries.delete(workItemId); + pruned = true; + } else { + const state = normalizeAgentState(status); + if (state === 'done') { + // Agent finished; the pane may still be open but no icon is + // shown. Prune now — a re-dispatch records a fresh association. + this.entries.delete(workItemId); + pruned = true; + } else { + states.set(workItemId, state); + } + } + } + if (pruned) { + this.persist(); + } + + this.cachedStates = states; + this.cachedAt = Date.now(); + return states; + } catch { + // Fail-open: keep entries, render no icons this cycle. + this.cachedStates = new Map(); + this.cachedAt = Date.now(); + return this.cachedStates; + } + } + + // ── Persistence ──────────────────────────────────────────────────── + + /** + * Load the persisted state file into memory. Missing or corrupt files are + * tolerated (in-memory entries are left untouched on a parse failure so a + * half-written file never wipes the live mapping). + */ + private loadFromDisk(): void { + if (!this.stateFile) return; + if (!existsSync(this.stateFile)) return; + try { + const raw = JSON.parse(readFileSync(this.stateFile, 'utf8')); + const list = Array.isArray(raw) ? raw : (raw as { entries?: unknown })?.entries; + if (!Array.isArray(list)) return; + const next = new Map(); + for (const rec of list) { + if (!rec || typeof rec !== 'object') continue; + const entry = rec as Record; + const workItemId = entry.workItemId ?? entry.workItem ?? entry.id; + const paneId = entry.paneId; + if (typeof workItemId === 'string' && workItemId !== '' && typeof paneId === 'string') { + next.set(workItemId, { + workItemId, + paneId, + recordedAt: typeof entry.recordedAt === 'string' ? entry.recordedAt : new Date().toISOString(), + }); + } + } + this.entries.clear(); + for (const [k, v] of next) this.entries.set(k, v); + } catch { + // Corrupt file — keep the current in-memory mapping. + } + } + + /** + * Write the mapping atomically (write-temp-then-rename). Multiple + * worklist tabs may write concurrently — last-writer-wins is acceptable. + * Failures are swallowed (fail-open). + */ + private persist(): void { + if (!this.stateFile) return; + try { + const payload = JSON.stringify([...this.entries.values()], null, 2); + const tmp = `${this.stateFile}.tmp.${process.pid}`; + writeFileSync(tmp, payload, 'utf8'); + renameSync(tmp, this.stateFile); + } catch { + // Persistence must never break the live tracker. + } + } +} + +/** + * Merge the tracker's agent states into a list of work items (recursively + * into children), setting `agentState` only for states with a rendered icon + * (`idle` / `working` / `blocked`). `done` / `unknown` / missing states are + * left unset so no icon renders. Fail-open: never throws. + */ +export async function mergeAgentStates(items: WorkItem[], tracker: AgentTracker): Promise { + let states: Map; + try { + states = await tracker.refreshStates(); + } catch { + return; // fail-open: no icons this cycle + } + if (states.size === 0) return; + const apply = (item: WorkItem): void => { + const state = states.get(item.id); + if (state === 'idle' || state === 'working' || state === 'blocked') { + item.agentState = state; + } + }; + const walk = (list: WorkItem[]): void => { + for (const item of list) { + apply(item); + if (item.children && item.children.length > 0) { + walk(item.children); + } + } + }; + walk(items); +} diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index c7beb00b7..71c1c7d78 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -11,6 +11,7 @@ import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { selectWorkItems } from './smart-selection.js'; import { regroupWorkItems } from './grouping.js'; +import type { AgentState } from './agent-tracker.js'; // ── Types ──────────────────────────────────────────────────────────── @@ -133,6 +134,12 @@ export interface WorkItem { depth?: number; /** Internal: whether the expand icon should show collapsed state. */ _expanded?: boolean; + /** + * Current agent status for the worklist-spawned agent pane associated + * with this item (merged by the agent tracker). `idle`/`working`/`blocked` + * render an icon; `done`/`unknown`/absent render none (WL-0MSBQUJQX005RAT9). + */ + agentState?: AgentState; } // ── Helpers ─────────────────────────────────────────────────────────── diff --git a/packages/herdr/src/icons.test.ts b/packages/herdr/src/icons.test.ts new file mode 100644 index 000000000..2c46d5d67 --- /dev/null +++ b/packages/herdr/src/icons.test.ts @@ -0,0 +1,114 @@ +/** + * packages/herdr/src/icons.test.ts — Icon tests for the agent-status icon + * and the fixed-width agent slot in the icon prefix (WL-0MSBQUJQX005RAT9). + * + * Alignment invariance (AC3): the agent icon occupies a fixed-width reserved + * slot at the start of the icon prefix, so rows with and without an agent + * keep the status/stage/review icons and the item-ID column at identical + * columns. + * + * Run: npx vitest run packages/herdr/src/icons.test.ts + */ + +import { describe, it, expect } from 'vitest'; +import { + agentStatusIcon, + getIconPrefix, + stringDisplayWidth, +} from './icons.js'; +import { formatItemLine } from './worklist.js'; +import type { WorkItem } from './fetcher.js'; + +describe('agentStatusIcon', () => { + it('maps working → 🟢 (green circle)', () => { + expect(agentStatusIcon('working')).toBe('\u{1F7E2}'); + }); + + it('maps blocked → ⛔ (no-entry sign)', () => { + expect(agentStatusIcon('blocked')).toBe('\u{26D4}'); + }); + + it('maps idle → ⚪ (white circle)', () => { + expect(agentStatusIcon('idle')).toBe('\u{26AA}'); + }); + + it('renders no icon for done, unknown, or missing states', () => { + expect(agentStatusIcon('done')).toBe(''); + expect(agentStatusIcon('unknown')).toBe(''); + expect(agentStatusIcon(undefined)).toBe(''); + expect(agentStatusIcon('')).toBe(''); + }); + + it('is case-insensitive', () => { + expect(agentStatusIcon('WORKING')).toBe('\u{1F7E2}'); + expect(agentStatusIcon('Blocked')).toBe('\u{26D4}'); + }); + + it('uses [TEXT] fallbacks when noIcons is set', () => { + expect(agentStatusIcon('working', { noIcons: true })).toBe('[WORK]'); + expect(agentStatusIcon('blocked', { noIcons: true })).toBe('[BLKD]'); + expect(agentStatusIcon('idle', { noIcons: true })).toBe('[IDLE]'); + expect(agentStatusIcon('done', { noIcons: true })).toBe(''); + }); +}); + +describe('getIconPrefix — fixed-width agent slot (AC3 alignment invariance)', () => { + const base = { status: 'open', stage: 'idea' } as const; + const withAgent = { status: 'open', stage: 'idea', agentState: 'working' } as const; + const withBlockedAgent = { status: 'open', stage: 'idea', agentState: 'blocked' } as const; + + it('keeps the total prefix at the fixed width with and without an agent', () => { + expect(stringDisplayWidth(getIconPrefix(base))).toBe(12); + expect(stringDisplayWidth(getIconPrefix(withAgent))).toBe(12); + }); + + it('keeps the remaining icons at identical columns (no column shift)', () => { + const noAgent = getIconPrefix(base); + const yesAgent = getIconPrefix(withAgent); + // Both prefixes are the same display width; the status icon (display + // column 3-4) must start at the same display column in both. + expect(stringDisplayWidth(noAgent)).toBe(stringDisplayWidth(yesAgent)); + // Display column where the status icon (🔓) begins: + const statusCol = (s: string): number => stringDisplayWidth(s.slice(0, s.indexOf('\u{1F513}'))); + expect(statusCol(noAgent)).toBe(statusCol(yesAgent)); + }); + + it('places the agent icon in the reserved leading slot', () => { + const prefix = getIconPrefix(withAgent); + // The green circle is the first 2-cell glyph. + expect(prefix.startsWith('\u{1F7E2}')).toBe(true); + // Rows without an agent reserve the slot with two spaces. + expect(getIconPrefix(base).startsWith(' ')).toBe(true); + }); + + it('renders different agent states without shifting the status icon', () => { + const working = getIconPrefix(withAgent); + const blocked = getIconPrefix(withBlockedAgent); + // Slot is fixed-width: both have the same display width, and the status + // icon (🔓) appears at the same DISPLAY column (cells 3-4) in both. + expect(stringDisplayWidth(working)).toBe(stringDisplayWidth(blocked)); + const statusCol = (s: string): number => stringDisplayWidth(s.slice(0, s.indexOf('\u{1F513}'))); // 🔓 + expect(statusCol(working)).toBe(statusCol(blocked)); + }); + + it('keeps the item-ID column identical in full row rendering', () => { + const makeItem = (agentState?: string): WorkItem => ({ + id: 'WL-0MSBQUJQX005RAT9', + title: 'Some task', + status: 'open', + stage: 'idea', + agentState: agentState as WorkItem['agentState'], + }); + const withRow = formatItemLine(makeItem('working'), 120); + const withoutRow = formatItemLine(makeItem(undefined), 120); + // Compare DISPLAY columns (emoji are surrogate pairs, so JS indexOf + // code-unit positions differ even though terminal columns do not). + const idCol = (line: string): number => + stringDisplayWidth(line.slice(0, line.indexOf('WL-0MSBQUJQX005RAT9'))); + expect(idCol(withRow)).toBe(idCol(withoutRow)); + // And both place the ID at the same display column a row with a blocked + // agent does. + const blockedRow = formatItemLine(makeItem('blocked'), 120); + expect(idCol(withRow)).toBe(idCol(blockedRow)); + }); +}); diff --git a/packages/herdr/src/icons.ts b/packages/herdr/src/icons.ts index 51ca843f3..bbcbcd8c5 100644 --- a/packages/herdr/src/icons.ts +++ b/packages/herdr/src/icons.ts @@ -92,6 +92,28 @@ const AUDIT_STALE_FAILED = '\u{26A0}'; // ⚠️ const NEEDS_REVIEW_ICON = '\u{274C}'; // ❌ const REVIEW_DONE_ICON = '\u{2705}'; // ✅ +// Agent-status icons (WL-0MSBQUJQX005RAT9). Glyph mapping confirmed by the +// user: working → 🟢 (green circle), blocked → ⛔ (no-entry sign), idle → ⚪ +// (white circle). `done`/`unknown`/absent → no icon. +const AGENT_STATE_ICONS: Record = { + idle: '\u{26AA}', // ⚪ + working: '\u{1F7E2}', // 🟢 + blocked: '\u{26D4}', // ⛔ +}; + +const AGENT_STATE_FALLBACK: Record = { + idle: '[IDLE]', + working: '[WORK]', + blocked: '[BLKD]', +}; + +/** + * Fixed display width (in terminal cells) of the reserved agent-status + * slot at the start of the icon prefix. Rows with and without an agent keep + * the remaining icons and the item-ID column at identical columns (AC3). + */ +export const AGENT_SLOT_WIDTH = 2; + // ── Public API ───────────────────────────────────────────────────────── /** @@ -170,6 +192,21 @@ export function epicIcon(opts?: IconOptions): string { return EPIC_ICON; } +/** + * Get the icon for a tracked agent's current state. + * + * `working → 🟢`, `blocked → ⛔`, `idle → ⚪`. `done`, `unknown`, or an + * absent state render no icon (the agent finished or the pane is gone). + * Text fallbacks follow the `[TEXT]` convention for noIcons mode. + */ +export function agentStatusIcon(state: string | undefined, opts?: IconOptions): string { + const key = (state || '').toLowerCase(); + if (opts?.noIcons) { + return AGENT_STATE_FALLBACK[key] || ''; + } + return AGENT_STATE_ICONS[key] || ''; +} + /** * Get the risk icon. */ @@ -304,16 +341,24 @@ const ICON_PREFIX_WIDTH = 12; * regardless of how many icon fields are present. * * Column layout (left to right): + * 0. Agent status (fixed-width reserved slot, WL-0MSBQUJQX005RAT9) * 1. Status icon * 2. Stage icon (for in_review items, shows audit-aware icon instead) * 3. Producer review flag * 4. Optional epic icon + child count */ export function getIconPrefix( - item: { status: string; stage?: string; priority?: string; auditResult?: boolean | null; auditedAt?: string | null; needsProducerReview?: boolean; updatedAt?: string; issueType?: string; childCount?: number }, + item: { status: string; stage?: string; priority?: string; auditResult?: boolean | null; auditedAt?: string | null; needsProducerReview?: boolean; updatedAt?: string; issueType?: string; childCount?: number; agentState?: string }, opts?: IconOptions, ): string { const noIcons = opts?.noIcons ?? false; + + // Column 0: agent status — fixed-width reserved slot so rows with and + // without an agent keep the remaining icons and the item-ID column at + // identical columns (AC3, WL-0MSBQUJQX005RAT9). + const agentIcon = agentStatusIcon(item.agentState, { noIcons }); + const agentSlot = agentIcon !== '' ? agentIcon : ' '.repeat(AGENT_SLOT_WIDTH); + const sIcon = statusIcon(item.status, { noIcons }); // Column 2: stage or audit-aware icon for in_review @@ -344,8 +389,10 @@ export function getIconPrefix( // Column 4: epic icon (child count is no longer shown in prefix) const epicSuffix = item.issueType === 'epic' ? epicIcon({ noIcons }) : ''; - // Build full prefix and pad to fixed width for alignment - let prefix = [coreIcons, epicSuffix].filter(Boolean).join(''); + // Build full prefix and pad to fixed width for alignment. The agent slot + // is included in the total, so rows with and without an agent still land + // at exactly ICON_PREFIX_WIDTH cells. + let prefix = [agentSlot, coreIcons, epicSuffix].filter(Boolean).join(''); const width = stringDisplayWidth(prefix); if (width < ICON_PREFIX_WIDTH) { const padCount = ICON_PREFIX_WIDTH - width; diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 9c030782f..f8b8331e5 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -12,6 +12,9 @@ import { stripAgentPromptPrefix, buildSendToPiArgs, createDowntimeDeps, + capturePaneIdFromFile, + parsePaneIdFile, + CAPTURE_TIMEOUT_MS, } from './index.js'; import { DOWNTIME_LOG_FILE } from './downtime-log.js'; import { @@ -672,3 +675,138 @@ describe('createDowntimeDeps recordDispatch', () => { ).resolves.toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Agent-pane association capture (WL-0MSBQUJQX005RAT9) +// +// AC1: when an agent command carrying a work-item ID is dispatched, the +// plugin captures the new pane ID (via --pane-id-file) and records the +// work-item ↔ pane association. AC6: only worklist-spawned agent commands +// are tracked; commands without an ID are not. +// --------------------------------------------------------------------------- + +describe('buildSendToPiArgs — pane-id capture flag', () => { + it('forwards --pane-id-file when provided', () => { + expect( + buildSendToPiArgs('/skill:implement ', '/project', 'code', '/tmp/pane.json'), + ).toEqual([ + '--cwd', + '/project', + '--model', + 'code', + '--pane-id-file', + '/tmp/pane.json', + '/skill:implement ', + ]); + }); + + it('omits --pane-id-file when not provided (backward compatible)', () => { + expect(buildSendToPiArgs('/skill:implement ', '/project')).toEqual([ + '--cwd', + '/project', + '/skill:implement ', + ]); + }); +}); + +describe('parsePaneIdFile', () => { + it('parses the pane_id written by send-to-pi.sh', () => { + expect(parsePaneIdFile('{"pane_id":"grid-pane-5"}')).toBe('grid-pane-5'); + expect(parsePaneIdFile('{"pane_id": "plain-pane-1"}')).toBe('plain-pane-1'); + }); + + it('tolerates log lines prefixed before the JSON', () => { + expect(parsePaneIdFile('some noise\n{"pane_id":"p1"}')).toBe('p1'); + }); + + it('returns undefined for unparseable content', () => { + expect(parsePaneIdFile('')).toBeUndefined(); + expect(parsePaneIdFile('not json')).toBeUndefined(); + expect(parsePaneIdFile('{"no_pane":1}')).toBeUndefined(); + }); +}); + +describe('capturePaneIdFromFile — dispatch-time association capture', () => { + it('polls for the pane-id file, records the association, and cleans up', async () => { + const record = vi.fn().mockResolvedValue(undefined); + const unlink = vi.fn(); + let fileExists = false; + let fileContent = ''; + + const promise = capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => fileExists, + readFile: () => fileContent, + unlink, + sleep: async () => { + // Simulate the pane-id file appearing after the first poll. + fileExists = true; + fileContent = '{"pane_id":"grid-pane-5"}'; + }, + now: () => 0, + }); + + const paneId = await promise; + expect(paneId).toBe('grid-pane-5'); + expect(record).toHaveBeenCalledWith('WL-ABC', 'grid-pane-5'); + expect(unlink).toHaveBeenCalledWith('/tmp/pane.json'); + }); + + it('records nothing when the file never appears (split failed)', async () => { + const record = vi.fn(); + let t = 0; + const paneId = await capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => false, + readFile: () => '', + // Advance the clock past the deadline so the loop terminates (a + // constant `now` would make the timeout unreachable and spin forever). + sleep: async () => { t += CAPTURE_TIMEOUT_MS; }, + now: () => t, + }); + expect(paneId).toBeUndefined(); + expect(record).not.toHaveBeenCalled(); + }); + + it('is fail-open: a recording error never throws', async () => { + const record = vi.fn().mockRejectedValue(new Error('tracker boom')); + const unlink = vi.fn(); + const paneId = await capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => true, + readFile: () => '{"pane_id":"p1"}', + unlink, + sleep: async () => {}, + now: () => 0, + }); + expect(paneId).toBe('p1'); + expect(record).toHaveBeenCalledWith('WL-ABC', 'p1'); + expect(unlink).toHaveBeenCalledWith('/tmp/pane.json'); + }); + + it('keeps polling while the file is unreadable or mid-write', async () => { + const record = vi.fn(); + let readable = false; + const paneId = await capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => true, + readFile: () => { + if (!readable) throw new Error('mid-write'); + return '{"pane_id":"p1"}'; + }, + sleep: async () => { readable = true; }, + now: () => 0, + }); + expect(paneId).toBe('p1'); + expect(record).toHaveBeenCalledWith('WL-ABC', 'p1'); + }); + + it('gives up once the timeout is reached', async () => { + const record = vi.fn(); + let t = 0; + const paneId = await capturePaneIdFromFile('WL-ABC', '/tmp/pane.json', record, { + existsSync: () => true, + readFile: () => 'not json', + sleep: async () => { t += 200; }, + now: () => t, + }); + expect(paneId).toBeUndefined(); + expect(record).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 5736e515c..ae4517d1d 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -21,6 +21,8 @@ import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname, resolve, join } from 'path'; +import { tmpdir } from 'node:os'; +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; import { resolveWorklogRoot } from '@worklog/shared/worklog-paths'; import { checkWlAvailable, @@ -30,6 +32,7 @@ import { claimWorkItem, getExecFileAsync, } from './fetcher.js'; +import { AgentTracker, AGENT_PANES_FILE, mergeAgentStates } from './agent-tracker.js'; import { runWorklistTui, getTermSize } from './worklist.js'; import { loadShortcutConfig } from './shortcut-config.js'; import { loadSettings, getDefaultSettingsPath, clampBrowseItemCount, defaultSettings } from './settings.js'; @@ -130,17 +133,123 @@ export function stripAgentPromptPrefix(command: string): string { * '/skill:implement '`). Free-form `/prompt:` commands have their routing * prefix stripped here so pi receives only the prompt text. Commands without * a model get no `--model` flag. + * + * When `paneIdFile` is provided (agent commands carrying a work-item ID, + * WL-0MSBQUJQX005RAT9), `--pane-id-file ` is forwarded so the script + * writes the new pane ID immediately after the split succeeds — the plugin + * reads it back to record the work-item ↔ pane association. */ -export function buildSendToPiArgs(command: string, targetCwd: string, model?: string): string[] { +export function buildSendToPiArgs( + command: string, + targetCwd: string, + model?: string, + paneIdFile?: string, +): string[] { const agentPrompt = stripAgentPromptPrefix(command); const args = ['--cwd', targetCwd]; if (model) { args.push('--model', model); } + if (paneIdFile) { + args.push('--pane-id-file', paneIdFile); + } args.push(agentPrompt); return args; } +/** + * Parse the pane-ID file written by send-to-pi.sh (`--pane-id-file`). + * + * The script writes `{"pane_id": ""}` immediately after the pane split + * succeeds. Tolerates log lines prefixed before the JSON envelope. + * Returns undefined when the file is absent, unparseable, or has no pane id. + */ +export function parsePaneIdFile(raw: string): string | undefined { + const start = raw.indexOf('{'); + if (start < 0) return undefined; + try { + const obj = JSON.parse(raw.slice(start)) as Record; + const paneId = obj?.pane_id ?? obj?.paneId; + return typeof paneId === 'string' && paneId !== '' ? paneId : undefined; + } catch { + return undefined; + } +} + +// ── Pane-ID capture (WL-0MSBQUJQX005RAT9) ───────────────────────────── + +/** Poll interval for the pane-ID file capture loop. */ +export const CAPTURE_POLL_INTERVAL_MS = 200; +/** Total time budget for the pane-ID file capture loop. */ +export const CAPTURE_TIMEOUT_MS = 5000; + +/** + * Injectable filesystem/timer dependencies for {@link capturePaneIdFromFile} + * (tests replace these so no real polling or temp files are needed). + */ +export interface PaneIdFileDeps { + existsSync?: (p: string) => boolean; + readFile?: (p: string) => string; + unlink?: (p: string) => void; + sleep?: (ms: number) => Promise; + now?: () => number; +} + +/** + * Poll for the pane-ID file written by send-to-pi.sh, record the + * work-item ↔ pane association, and clean up the temp file. + * + * Fire-and-forget (never awaited by the TUI): the loop polls briefly for + * the file so pane-ID capture never blocks the TUI event loop or changes + * spawn behavior. A missing file (split failed) is a no-op — no entry is + * recorded. All failures are swallowed (fail-open). + * + * @param workItemId - Work item the agent command was dispatched for. + * @param paneIdFile - Path passed to send-to-pi.sh via `--pane-id-file`. + * @param record - Records the association (typically + * `tracker.recordAgentForWorkItem`). + * @param deps - Injectable fs/timer dependencies (tests). + * @returns The captured pane ID, or undefined on timeout. + */ +export async function capturePaneIdFromFile( + workItemId: string, + paneIdFile: string, + record: (workItemId: string, paneId: string) => void | Promise, + deps: PaneIdFileDeps = {}, +): Promise { + const existsSyncFn = deps.existsSync ?? ((p: string) => existsSync(p)); + const readFileFn = deps.readFile ?? ((p: string) => readFileSync(p, 'utf8')); + const unlinkFn = deps.unlink ?? ((p: string) => { try { unlinkSync(p); } catch { /* ignore */ } }); + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = deps.now ?? (() => Date.now()); + const deadline = now() + CAPTURE_TIMEOUT_MS; + + while (now() < deadline) { + if (existsSyncFn(paneIdFile)) { + let raw: string | null = null; + try { + raw = readFileFn(paneIdFile); + } catch { + raw = null; // file mid-write — keep polling + } + const paneId = raw !== null ? parsePaneIdFile(raw) : undefined; + if (paneId) { + try { + await record(workItemId, paneId); + } catch { + // Recording must never break dispatch; the temp file is still + // cleaned up below. + } + unlinkFn(paneIdFile); + return paneId; + } + } + await sleep(CAPTURE_POLL_INTERVAL_MS); + } + // Timed out — the split may have failed or the file never appeared. + return undefined; +} + /** * Check if a command is an agent command that should be sent to a pi pane. * Agent commands are those starting with /skill:, /intake, /plan, or /prompt:. @@ -346,6 +455,15 @@ async function main(): Promise { process.stderr.write(uninitializedReport(resolvedCwd ?? process.cwd())); } + // Agent tracker (WL-0MSBQUJQX005RAT9): records which worklist-spawned + // agent pane is attached to each work item. The state file lives in the + // project's gitignored .worklog/ directory and is shared across worklist + // panes/tabs. Fail-open: without a valid worklog root the tracker runs + // in-memory only (no persistence). + const agentTracker = new AgentTracker({ + stateFile: wlRoot ? join(wlRoot, '.worklog', AGENT_PANES_FILE) : undefined, + }); + // Create a fetcher that loads items using the current browseItemCount setting // Each call reads from settings so changes take effect on next auto-refresh // Smart selection (see fetchNextItems) guarantees all critical and @@ -360,7 +478,11 @@ async function main(): Promise { try { const currentSettings = loadSettings(); const count = clampBrowseItemCount(currentSettings.browseItemCount ?? defaultSettings.browseItemCount); - return await fetchNextItems(count); + const items = await fetchNextItems(count); + // Merge agent-status state into the fetched items (fail-open: no herdr + // CLI → no icons, list still works). Also covers the initial load. + await mergeAgentStates(items, agentTracker); + return items; } catch { return []; } @@ -411,6 +533,10 @@ async function main(): Promise { // Re-read on every render so a showHelpText change applies on the next // refresh (no plugin restart needed), matching browseItemCount behavior. getShowHelpText: () => loadSettings().showHelpText ?? true, + // Merge agent-status state into freshly fetched items (top-level + + // expanded children) on every refresh cycle (WL-0MSBQUJQX005RAT9). + // Fail-open: herdr errors yield no icons; the list keeps working. + mergeAgentStates: (items) => mergeAgentStates(items, agentTracker), onCommand: async (command: string, model?: string) => { // Agent commands (/skill:*, /intake, /plan) are routed to a new pi agent // pane opened to the right. Commands prefixed with `!!`/`!` (shell-executed @@ -440,13 +566,22 @@ async function main(): Promise { } catch { // Belt-and-suspenders: a claim failure must never block the pane. } + // Agent-pane association capture (WL-0MSBQUJQX005RAT9): when the + // command carries a work-item ID, ask send-to-pi.sh to write the + // new pane ID to a temp file (--pane-id-file) right after the split + // succeeds, then record the work-item ↔ pane association + // fire-and-forget. Commands without an ID are not tracked (AC6). + const itemId = extractWorkItemId(command); + const paneIdFile = itemId + ? join(tmpdir(), `herdr-pane-${process.pid}-${Date.now()}.json`) + : undefined; // Spawn send-to-pi.sh asynchronously — detached and with stdio ignored // so the TUI loop is not blocked or affected by the script's output. // The `model` from the shortcut entry (if any) is forwarded as // `--model ` so the pi CLI opens with the right model. const child = spawn( SEND_TO_PI_SCRIPT, - buildSendToPiArgs(command, targetCwd, model), + buildSendToPiArgs(command, targetCwd, model, paneIdFile), { detached: true, stdio: 'ignore', @@ -455,6 +590,13 @@ async function main(): Promise { }, ); child.unref(); // Allow the parent to exit independently + if (itemId && paneIdFile) { + // Fire-and-forget: polling must never block the TUI loop. A + // missing file (split failed) is a no-op — no entry recorded. + void capturePaneIdFromFile(itemId, paneIdFile, (wid, pid) => + agentTracker.recordAgentForWorkItem(wid, pid), + ); + } } else if (route === 'pane') { // Strip `!!` / `!` bash history-expansion prefixes, then run the // command visibly in a new herdr pane via run-in-pane.sh. diff --git a/packages/herdr/src/worklist-agent-state.test.ts b/packages/herdr/src/worklist-agent-state.test.ts new file mode 100644 index 000000000..75d92c7b7 --- /dev/null +++ b/packages/herdr/src/worklist-agent-state.test.ts @@ -0,0 +1,208 @@ +/** + * packages/herdr/src/worklist-agent-state.test.ts — Integration tests for + * merging agent-status state into fetched work items (WL-0MSBQUJQX005RAT9). + * + * runWorklistTui accepts a `mergeAgentStates` callback that is applied to + * freshly fetched items (top-level + expanded children) on every refresh + * cycle. This verifies the plumbing: when the callback stamps an + * `agentState` on an item, the row renders the agent-status icon; without + * it, the row renders no agent icon. + * + * Run: npx vitest run packages/herdr/src/worklist-agent-state.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +vi.mock('./auto-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runSync: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui } from './worklist.js'; +import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; +import { stringDisplayWidth } from './icons.js'; +import type { WorkItem } from './fetcher.js'; + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { + value: vi.fn(), + configurable: true, + writable: true, + }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); +}); + +afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_PANE_ID; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function makeExecMock(): Mock { + return vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { + return { + stdout: JSON.stringify({ id: 'cli:pane:get', result: { pane: { focused: true } } }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }); +} + +async function quit(p: Promise): Promise { + dataHandler?.(Buffer.from('q')); + await p; +} + +const GREEN_CIRCLE = '\u{1F7E2}'; // 🟢 — the agent working icon + +describe('worklist — agent-state merge into fetched items', () => { + it('renders the agent-status icon when mergeAgentStates stamps an agentState', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, [], undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + mergeAgentStates: async (items: WorkItem[]) => { + for (const it of items) { + if (it.id === 'WL-1') it.agentState = 'working'; + } + }, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + // Trigger the first auto-refresh cycle, which applies mergeAgentStates. + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + expect(output).toContain(GREEN_CIRCLE); + expect(output).toContain('WL-1'); + + await quit(p); + }); + + it('renders no agent icon when no agentState is stamped', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, [], undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + expect(output).toContain('WL-1'); + expect(output).not.toContain(GREEN_CIRCLE); + + await quit(p); + }); + + it('keeps rows aligned: item IDs share a column with and without the agent icon', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-AGENT', title: 'Has agent', status: 'open', stage: 'idea' }, + { id: 'WL-PLAIN', title: 'No agent', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, [], undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + mergeAgentStates: async (items: WorkItem[]) => { + for (const it of items) { + if (it.id === 'WL-AGENT') it.agentState = 'working'; + } + }, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + const lines = output.split('\n'); + const findLine = (id: string): string => lines.find((l) => l.includes(id)) ?? ''; + const agentLine = findLine('WL-AGENT'); + const plainLine = findLine('WL-PLAIN'); + expect(agentLine).not.toBe(''); + expect(plainLine).not.toBe(''); + // Compare DISPLAY columns. Strip ANSI colour codes (the selected row + // carries a reverse-video marker) and normalize the selection marker: + // stringDisplayWidth models `▸` (U+25B8) as 2 cells, but terminals + // render it as 1, so the selected row measures 1 cell wider than the + // non-selected row even though real columns align. Ignoring surrogate-pair + // code-unit indexes too (emoji), which differ though columns don't. + const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); + const norm = (s: string): string => stripAnsi(s).replace('▸', ' '); + const idCol = (line: string, id: string): number => { + const vis = norm(line); + return stringDisplayWidth(vis.slice(0, vis.indexOf(id))); + }; + expect(idCol(agentLine, 'WL-AGENT')).toBe(idCol(plainLine, 'WL-PLAIN')); + + await quit(p); + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 3240c2c1c..1a49a61fe 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -2082,7 +2082,7 @@ export async function runWorklistTui( fetcher: () => Promise, initialItems?: WorkItem[], shortcutRegistry?: { lookupChord: Function; getChordByLeader: Function; getChordByPrefix: Function; getChordEntries: Function } | ShortcutRegistry | undefined, - options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; onCommand?: (command: string, model?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number }, + options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; onCommand?: (command: string, model?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number; mergeAgentStates?: (items: WorkItem[]) => Promise }, ): Promise { const opts = { autoRefresh: options?.autoRefresh ?? true, @@ -2095,6 +2095,7 @@ export async function runWorklistTui( onCommand: options?.onCommand, downtimeWorker: options?.downtimeWorker, downtimePollIntervalMs: options?.downtimePollIntervalMs ?? DEFAULT_DOWNTIME_POLL_INTERVAL_MS, + mergeAgentStates: options?.mergeAgentStates, }; let termSize = getTermSize(); @@ -2231,6 +2232,14 @@ export async function runWorklistTui( } })); } + // Merge agent-status state into the refreshed items (top-level + + // expanded children) so the agent icons reflect the latest tracker + // state (WL-0MSBQUJQX005RAT9). Fail-open: no herdr CLI → no icons. + try { + await opts.mergeAgentStates?.(newItems); + } catch { + // Fail-open: a merge failure must never break the refresh cycle. + } if (showNotification && newItems.length !== oldLen) { const diff = newItems.length - oldLen; const msg = diff > 0 ? `+${diff} new` : `${diff} removed`; @@ -2571,6 +2580,13 @@ export async function runWorklistTui( render(); // immediate render while fetch is pending const children = await fetchChildrenForItem(selected.id); selected.children = children; + // Merge agent-status state into the freshly loaded children so + // their rows show agent icons too (WL-0MSBQUJQX005RAT9). + try { + await opts.mergeAgentStates?.([selected]); + } catch { + // Fail-open: a merge failure must never break expansion. + } state.toggleExpand(selected.id); render(); return; From 4018e3ea861ea08dfd018ebd929240207bde784e Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 13:21:06 +0100 Subject: [PATCH 17/59] WL-0MSGTSPXK007POB1: Implementation complete --- packages/herdr/README.md | 2 +- packages/herdr/src/worklist.test.ts | 75 +++++++++++++++++++++++++++-- packages/herdr/src/worklist.ts | 37 +++++--------- tests/herdr/errors.test.ts | 1 - tests/herdr/worklist.test.ts | 20 ++------ 5 files changed, 86 insertions(+), 49 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index c3e72c5ed..75e451f43 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -411,7 +411,7 @@ packages/herdr/ - **No direct database access** — The plugin uses the `wl` CLI as the backend data source, ensuring compatibility without duplicating data-access logic. - **Terminal UI via raw mode** — The TUI uses raw stdin mode and ANSI escape codes for rendering, making it compatible with any Herdr pane without additional dependencies. -- **Fixed-height pane rendering** — The list renderer budgets its output to `rows - 1` lines (header + blank + filter bar + items + group separators + fill + footer), reserving the last row for the transient notification line (e.g. `[Synced]`, `[Refresh failed]`). Group separator lines count against the budget, so the pane never scrolls the header or top items off the top of the view regardless of item/group count (see WL-0MSAAON63003N6LO). +- **Fixed-height pane rendering** — The list renderer budgets its output to `rows - 1` lines (header + items + group separators + fill + footer), reserving the last row for the transient notification line (e.g. `[Synced]`, `[Refresh failed]`). The active stage filter is shown in the header only (` (filtered: )`) — there is no standalone filter bar or blank chrome row. Group separator lines count against the budget, so the pane never scrolls the header or top items off the top of the view regardless of item/group count (see WL-0MSAAON63003N6LO, WL-0MSGTSPXK007POB1). - **Testable core** — All state management, formatting, and keyboard handling is pure logic in `worklist.ts`, fully testable without a terminal. - **Toast notifications instead of bottom-line status** — Transient status feedback (refresh outcomes, sync outcomes, sent/skipped command feedback, errors) is surfaced via Herdr toast notifications (`herdr notification show`) instead of being appended to the bottom of the pane output. This keeps the rendered pane within the terminal height budget, so the list header and top lines are never pushed off the top of the pane. Toast delivery requires `ui.toast.delivery = "herdr"` in `~/.config/herdr/config.toml`; toasts appear in the bottom-right corner by default. The helper lives in `notify.ts` and is fire-and-forget (failures are tolerated silently). - **Command routing via callback** — When a chord resolves to a non-`/wl` command, it is passed to an `onCommand` callback (set by the entry point) which routes it by prefix: diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 6b7f9f25a..cd7fdebef 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -134,6 +134,70 @@ describe('createListRenderer — line-count invariant', () => { }); }); +// ── Filter chrome removal (WL-0MSGTSPXK007POB1) ──────────────────────── +// The list mode no longer emits a blank line after the header/banner nor a +// standalone filter status bar; the active stage filter is indicated in the +// header only (via filterLabel). The two freed rows are given back to the +// item list (page size 11 → 13 on 80x24). + +describe('createListRenderer — no blank line, no filter bar', () => { + const renderer = createListRenderer(); + + it('does not render a blank line between the header and the first item', () => { + const output = renderer([makeItem('A'), makeItem('B'), makeItem('C')], 0, 0, TERM_80x24, null, 'list', null); + const lines = output.split('\n'); + expect(lines[0]).toContain('Work Items'); + expect(lines[1].trim()).not.toBe(''); + expect(lines[1]).toContain('A'); + }); + + it('does not render a blank line between the code-freeze banner and the first item', () => { + const output = renderer( + [makeItem('A'), makeItem('B')], + 0, + 0, + TERM_80x24, + null, + 'list', + null, + undefined, + null, + 0, + false, + undefined, + undefined, + 0, + false, + true, // codeFreezeActive + ); + const lines = output.split('\n'); + expect(lines[0]).toContain('Work Items'); + expect(lines[1]).toContain('CODE FREEZE'); + expect(lines[2].trim()).not.toBe(''); + expect(lines[2]).toContain('A'); + }); + + it('renders no standalone filter bar when unfiltered', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, null, 'list', null); + expect(output).not.toContain('No filter'); + expect(output).not.toContain('press [f]'); + }); + + it('renders no standalone filter bar when filtered (header carries the indication)', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, 'in_review', 'list', null); + expect(output).not.toMatch(/Filter: /); + }); + + it('still indicates an active stage filter in the header', () => { + const output = renderer([makeItem('A')], 0, 0, TERM_80x24, 'in_review', 'list', null); + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('Work Items'); + expect(firstLine).toContain('(filtered: in_review)'); + const unfiltered = renderer([makeItem('A')], 0, 0, TERM_80x24, null, 'list', null); + expect(unfiltered.split('\n')[0]).not.toContain('filtered:'); + }); +}); + describe('WorkItemListState.refreshItems — preserve selection by ID', () => { let items: WorkItem[]; @@ -897,10 +961,11 @@ describe('handleKeypress — metadata scroll keys', () => { state.pageUp(); expect(state.selectedIndex).toBe(0); - // pageDown advances by the list page size (11 rows on 80x24) + // pageDown advances by the list page size (13 rows on 80x24 — the + // freed blank + filter-bar chrome rows are given back to the list) state.selectedIndex = 0; state.pageDown(); - expect(state.selectedIndex).toBe(11); + expect(state.selectedIndex).toBe(13); // pageDown clamps at the last item state.selectedIndex = 29; @@ -910,7 +975,7 @@ describe('handleKeypress — metadata scroll keys', () => { // pageUp moves back exactly one page state.selectedIndex = 23; state.pageUp(); - expect(state.selectedIndex).toBe(12); + expect(state.selectedIndex).toBe(10); }); it('goToFirst/goToLast jump to the ends via state', () => { @@ -938,12 +1003,12 @@ describe('handleKeypress — metadata scroll keys', () => { // PgUp (\x1b[5~) → pageup expect(handleKeypress(state, '\x1b[5~', TERM_80x24)).toBe('pageup'); - expect(state.selectedIndex).toBe(18); // 29 - 11 + expect(state.selectedIndex).toBe(16); // 29 - 13 // PgDn (\x1b[6~) → pagedown state.selectedIndex = 0; expect(handleKeypress(state, '\x1b[6~', TERM_80x24)).toBe('pagedown'); - expect(state.selectedIndex).toBe(11); + expect(state.selectedIndex).toBe(13); }); }); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 1a49a61fe..d1fdeaa28 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -611,9 +611,9 @@ export class WorkItemListState { /** Number of visible list rows (accounts for the metadata panel). */ _listHeight(): number { - // Reserve 3 rows for header, 1 for filter bar, 1 for footer, 1 for status + // Reserve 3 rows for header, 1 for footer, 1 for status const panelHeight = computeMetadataPanelHeight(this.termSize.rows); - return Math.max(3, this.termSize.rows - 6 - panelHeight); + return Math.max(3, this.termSize.rows - 4 - panelHeight); } _adjustScroll(): void { @@ -1087,18 +1087,6 @@ export function formatDetailView( return visible.join('\n'); } -/** - * Format the filter status bar. - */ -export function formatFilterBar(filter: string | null, maxCols: number): string { - if (filter) { - const color = STAGE_COLORS[filter] || 241; - const bar = ` ${ANSI.bg(color)}${ANSI.fg(16)} Filter: ${filter} ${ANSI.reset}`; - return bar.padEnd(maxCols, '─'); - } - return ` ${ANSI.dim}No filter — press [f] then [i/n/p/r] to filter by stage${ANSI.reset}`.padEnd(maxCols, ' '); -} - /** * Format the filter selection prompt. */ @@ -1584,7 +1572,7 @@ export function createListRenderer(): ( // the list area is the remaining height minus the notification row. const panelHeight = computeMetadataPanelHeight(rows); const listArea = Math.max(1, rows - 1 - panelHeight); - const listHeight = Math.max(3, rows - 6 - panelHeight); + const listHeight = Math.max(3, rows - 4 - panelHeight); if (mode === 'detail' && detailItem) { const viewportHeight = Math.max(10, rows - 1); @@ -1633,23 +1621,20 @@ export function createListRenderer(): ( const bannerLine = `${ANSI.bg(196)}${ANSI.fg(231)} ${bannerText} ${ANSI.reset}`; output.push(truncateLine(bannerLine, cols)); } - output.push(''); - - // Filter bar - output.push(formatFilterBar(activeFilter, cols)); // Items are already flattened by the caller (render callback in runWorklistTui // calls state.getFlattenedItems() before passing items here). Do NOT re-flatten. const flatItems = items; // Items with group separators. Each `── ──` separator consumes a - // row, so the visible window must be sized so header + blank + filter bar - // + items + separators + fill + footer fit in `rows - 1` lines (the last - // row is reserved for the notification line appended by render()). Without - // this accounting the output overflows the pane and the terminal scrolls - // the header/top items off the top (WL-0MSAAON63003N6LO). + // row, so the visible window must be sized so header + items + separators + // + fill + footer fit in `rows - 1` lines (the last row is reserved for + // the notification line appended by render()). Without this accounting the + // output overflows the pane and the terminal scrolls the header/top items + // off the top (WL-0MSAAON63003N6LO). The active stage filter is indicated + // in the header only (filterLabel) — no standalone filter bar is rendered. const bannerActive = codeFreezeActive === true; - const chromeLines = bannerActive ? 5 : 4; // header + banner + blank + filter bar + footer + const chromeLines = bannerActive ? 3 : 2; // header + banner + footer const budgetForItemsAndSeps = Math.max(0, listArea - chromeLines); // Count the group separators a window would render (same logic as the // render loop below) so the window can be trimmed when separators would @@ -1704,7 +1689,7 @@ export function createListRenderer(): ( } } - // Fill remaining rows (header + blank + filter bar + items + separators) + // Fill remaining rows (header + items + separators) const used = chromeLines + visible.length + numSeparators; for (let i = used; i < listArea; i++) { output.push(''); diff --git a/tests/herdr/errors.test.ts b/tests/herdr/errors.test.ts index 23db908df..1acc12028 100644 --- a/tests/herdr/errors.test.ts +++ b/tests/herdr/errors.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, vi } from 'vitest'; import { WorkItemListState, formatItemLine, - formatFilterBar, createListRenderer, handleKeypress, type WorkItem, diff --git a/tests/herdr/worklist.test.ts b/tests/herdr/worklist.test.ts index 67f378e0c..585129f62 100644 --- a/tests/herdr/worklist.test.ts +++ b/tests/herdr/worklist.test.ts @@ -10,7 +10,6 @@ import { StageFilter, formatItemLine, formatDetailView, - formatFilterBar, handleKeypress, createListRenderer, STAGES, @@ -393,19 +392,6 @@ describe('formatDetailView', () => { }); }); -describe('formatFilterBar', () => { - it('shows current filter when active', () => { - const bar = formatFilterBar('in_progress', 80); - expect(bar).toContain('in_progress'); - expect(bar).toContain('Filter'); - }); - - it('shows no filter message when null', () => { - const bar = formatFilterBar(null, 80); - expect(bar).toContain('No filter'); - }); -}); - describe('handleKeypress', () => { it('handles j/k navigation in list mode', () => { const state = new WorkItemListState(sampleItems, DEFAULT_TERM_SIZE); @@ -542,10 +528,12 @@ describe('createListRenderer', () => { expect(output).not.toContain('Work Items'); }); - it('renders filter bar when filter is active', () => { + it('indicates an active stage filter in the header only (no filter bar)', () => { const renderer = createListRenderer(); const output = renderer(sampleItems, 0, 0, DEFAULT_TERM_SIZE, 'in_progress', 'list', null); - expect(output).toContain('in_progress'); + const firstLine = output.split('\n')[0]; + expect(firstLine).toContain('(filtered: in_progress)'); + expect(output).not.toMatch(/Filter: /); }); it('shows total actionable count in header when totalCount > items.length', () => { From 0e47d912df8aff100995762447fc0112e22d1996 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 13:26:55 +0100 Subject: [PATCH 18/59] fix(herdr): keep expanded items visible across list refreshes (WL-0MSBVBNGH002RDP5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fetcher returns fresh top-level item objects that never carry a `children` array, so a refresh swapped them in and only re-fetched children afterwards — any render in that window showed every expanded parent collapsed (the momentary-collapse flicker), and a selected child was lost from the flattened list. - WorkItemListState.refreshItems now carries previously fetched children over to the new objects by ID before the swap, making the refresh atomic with respect to expanded state (fresh children attached by doRefresh are never clobbered). - doRefresh now fetches the top-level list and the children of expanded parents in parallel and applies both in one synchronous refreshItems step, eliminating the collapsed window entirely (single render after the atomic swap; single-flight guard unchanged). - Children are still re-fetched from the worklog on every refresh, so data freshness is preserved (AC-3). Tests: - 4 new refreshItems unit tests (children-less production-shaped objects, child-selection restoration, fresh-not-clobbered, orphan parents) in worklist.test.ts. - 2 new TUI-level tests in worklist-expand-refresh.test.ts: expanded children survive an auto-refresh tick, and a render fired mid-refresh (children re-fetch pending) never shows a collapsed frame. Docs: README refresh/hierarchy notes updated. --- packages/herdr/README.md | 4 +- .../herdr/src/worklist-expand-refresh.test.ts | 267 ++++++++++++++++++ packages/herdr/src/worklist.test.ts | 99 +++++++ packages/herdr/src/worklist.ts | 79 ++++-- 4 files changed, 431 insertions(+), 18 deletions(-) create mode 100644 packages/herdr/src/worklist-expand-refresh.test.ts diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 75e451f43..37c208ce5 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -4,7 +4,7 @@ A Herdr plugin that provides a keyboard-navigable work item selection list for b ## Features -- **Browse work items** — Lists work items from `wl next` in a scrollable, keyboard-navigable list. The top-level list is root-only: child work items are hidden and appear only under their parent via expand. +- **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. Expanded parents **stay expanded across refreshes**: each auto/manual refresh re-fetches their children in parallel with the top-level list and swaps both in atomically, so the hierarchy never momentarily collapses or flickers (WL-0MSBVBNGH002RDP5). - **Filter by stage** — Press `f` followed by a chord key (`i`=idea, `n`=intake, `p`=plan, `r`=review) to filter items by stage - **View details** — Press Enter on any item to see its full details (description, acceptance criteria, metadata, tags, priority, GitHub issue number, and audit status information such as audit result, review status, and last audit timestamp) - **Audit indicators** — The list view shows audit icons next to `in_review` items (✅ audited, ❌ failed, ❓ unaudited). The detail view metadata section additionally shows the review status (❌ needs review / ✅ reviewed) and the last audit timestamp. @@ -155,7 +155,7 @@ The plugin respects the following environment variables: Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings include: - `autoRefresh` — Enable periodic auto-refresh of the work item list (default: `true`) -- `refreshIntervalMs` — Interval in ms between auto-refreshes (default: `30000`). Refresh cycles are single-flight: a tick that fires while the previous refresh is still awaiting its `wl` calls is skipped (no overlapping refresh cycles / wl spawn bursts from a pane), and the cadence resumes on the next tick (WL-0MSBVYBMD004007C). +- `refreshIntervalMs` — Interval in ms between auto-refreshes (default: `30000`). Refresh cycles are single-flight: a tick that fires while the previous refresh is still awaiting its `wl` calls is skipped (no overlapping refresh cycles / wl spawn bursts from a pane), and the cadence resumes on the next tick (WL-0MSBVYBMD004007C). Each refresh is **atomic with respect to expanded state**: children of expanded parents are re-fetched in parallel with the top-level list and applied in one synchronous swap, so an expanded hierarchy never momentarily collapses mid-refresh (WL-0MSBVBNGH002RDP5). - `autoSync` — Enable periodic background `wl sync` before auto-refreshes (default: `true`). Background syncs use a single-flight in-process guard and pass `wl sync --if-idle`, so overlapping syncs (from this pane or other panes/TUI instances) are skipped instead of piling up — preventing wl sync lock storms (WL-0MSAB7ZUC004SK7E). - `syncIntervalMs` — Interval in ms between background `wl sync` calls (default: `60000`, minimum: `60000`; set to `0` to disable auto-sync) - `browseItemCount` — Max number of non-mandatory items to show in the list (default: `10`, range `1`–`50`; critical and completed/in_review items are always shown regardless) diff --git a/packages/herdr/src/worklist-expand-refresh.test.ts b/packages/herdr/src/worklist-expand-refresh.test.ts new file mode 100644 index 000000000..6def58a25 --- /dev/null +++ b/packages/herdr/src/worklist-expand-refresh.test.ts @@ -0,0 +1,267 @@ +/** + * packages/herdr/src/worklist-expand-refresh.test.ts — TUI-level tests for + * expanded items surviving a list refresh without collapsing + * (WL-0MSBVBNGH002RDP5). + * + * The production fetcher returns fresh top-level item objects that never + * carry a `children` array (normalizeItem drops it). Before the fix, a + * refresh swapped those objects in and only re-fetched children afterwards, + * so any render that fired in that window showed every expanded parent + * collapsed (the "momentary collapse" flicker). These tests drive the real + * `runWorklistTui` refresh path (auto-refresh timer tick) with + * production-shaped fetcher responses and assert the expanded children stay + * visible at every point of the refresh cycle. + * + * Run: npx vitest run packages/herdr/src/worklist-expand-refresh.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +// --------------------------------------------------------------------------- +// Module mocks (must be hoisted before worklist.js is imported) +// --------------------------------------------------------------------------- +// Keep the REAL fetcher + visibility modules (so the exec seam and PollGate +// run for real) but avoid real `wl`/`herdr` process spawns by injecting a +// mock execFileAsync via setExecFileAsync() in each test. + +vi.mock('./auto-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // Keep the real scheduler/sync plumbing but stub runSync so no real + // `wl sync` is spawned. + runSync: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui } from './worklist.js'; +import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; +import type { WorkItem } from './fetcher.js'; + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness (same pattern as worklist-inflight.test.ts) +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + // Define missing stdin properties (vitest's process.stdin may not expose them) + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { + value: vi.fn(), + configurable: true, + writable: true, + }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); +}); + +afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_PANE_ID; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +/** Quit the TUI and await its promise (cleans up timers/listeners). */ +async function quit(p: Promise): Promise { + dataHandler?.(Buffer.from('q')); + await p; +} + +/** + * Build a minimal child work item (fetcher-style, no nested children). + */ +function makeChild(id: string): WorkItem { + return { id, title: `Child ${id}`, status: 'open' }; +} + +/** + * Production-shaped top-level item: childCount present, NO `children` array + * (normalizeItem never populates it). + */ +function makeTopLevel(id: string, title: string, childCount: number): WorkItem { + return { id, title, status: 'open', childCount }; +} + +/** + * Exec-mock helper: visible pane (focused=true) so auto-refresh ticks fire, + * `wl list --parent` returns the expanded parent's children, and other wl + * calls are benign. + */ +function makeChildrenExecMock(): Mock { + return vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { + return { + stdout: JSON.stringify({ + id: 'cli:pane:get', + result: { pane: { focused: true } }, + }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--parent')) { + return { + stdout: JSON.stringify({ + workItems: [makeChild('C1'), makeChild('C2')], + }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('worklist expand-across-refresh (WL-0MSBVBNGH002RDP5)', () => { + it('auto-refresh keeps expanded children visible — production fetcher shape (children-less objects)', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeChildrenExecMock() as any); + + // Startup fetch returns the parent; the refresh tick returns a NEW parent + // object (fresh title) that again lacks `children`. + const fetcher = vi.fn() + .mockResolvedValueOnce([makeTopLevel('P', 'Parent', 2)]) + .mockResolvedValueOnce([makeTopLevel('P', 'Parent (refreshed)', 2)]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + // Expand the parent with Tab: children fetched on demand and shown. + dataHandler?.(Buffer.from('\t')); + await vi.advanceTimersByTimeAsync(0); + expect(writes.join('')).toContain('C1'); + expect(writes.join('')).toContain('C2'); + + // Auto-refresh tick: the fetcher returns a NEW children-less parent + // object. The expanded children must remain in the flattened view. + writes.length = 0; + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + expect(output).toContain('Parent (refreshed)'); + expect(output).toContain('C1'); + expect(output).toContain('C2'); + + await quit(p); + }); + + it('a render fired mid-refresh (children re-fetch pending) keeps expanded children visible', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + + // The refresh's children re-fetch is deferred so the test can fire a + // render while the refresh cycle is still awaiting its children call. + let childFetchCount = 0; + let resolveDeferredChildren!: () => void; + const deferredChildren = new Promise((r) => { resolveDeferredChildren = r; }); + + setExecFileAsync(vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { + return { + stdout: JSON.stringify({ + id: 'cli:pane:get', + result: { pane: { focused: true } }, + }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--parent')) { + childFetchCount += 1; + if (childFetchCount === 1) { + // Expand-time fetch: immediate. + return { stdout: JSON.stringify({ workItems: [makeChild('C1'), makeChild('C2')] }), stderr: '' }; + } + // Refresh-time re-fetch: deferred until the test resolves it. + await deferredChildren; + return { stdout: JSON.stringify({ workItems: [makeChild('C1'), makeChild('C2')] }), stderr: '' }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }) as any); + + const fetcher = vi.fn() + .mockResolvedValueOnce([makeTopLevel('P', 'Parent', 2)]) + .mockResolvedValueOnce([makeTopLevel('P', 'Parent (refreshed)', 2)]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + + // Expand the parent with Tab: children fetched and shown. + dataHandler?.(Buffer.from('\t')); + await vi.advanceTimersByTimeAsync(0); + expect(writes.join('')).toContain('C1'); + + // Auto-refresh tick starts a refresh cycle; the children re-fetch is + // still pending here (the swap has NOT happened yet). + writes.length = 0; + await vi.advanceTimersByTimeAsync(30_000); + + // A render fired mid-refresh (keypress navigation) must still show the + // expanded children — never a collapsed intermediate frame. + writes.length = 0; + dataHandler?.(Buffer.from('j')); + await vi.advanceTimersByTimeAsync(0); + const midRefreshOutput = writes.join(''); + expect(midRefreshOutput).toContain('C1'); + expect(midRefreshOutput).toContain('C2'); + + // Complete the refresh: children re-fetch resolves, the atomic swap + // applies, and the expanded view remains with fresh data. + resolveDeferredChildren(); + await vi.advanceTimersByTimeAsync(0); + const afterRefreshOutput = writes.join(''); + expect(afterRefreshOutput).toContain('Parent (refreshed)'); + expect(afterRefreshOutput).toContain('C1'); + expect(afterRefreshOutput).toContain('C2'); + + await quit(p); + }); +}); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index cd7fdebef..a129bd303 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -352,6 +352,105 @@ describe('WorkItemListState.refreshItems — preserve selection by ID', () => { expect(state.getFlattenedItems()[state.selectedIndex].id).toBe('CHILD-A1'); }); + it('keeps expanded children visible when refresh supplies new objects without children (production fetcher shape)', () => { + // The production fetcher (normalizeItem) never populates `children` on + // top-level items — each refresh returns NEW object references that only + // carry childCount. This test models that shape (WL-0MSBVBNGH002RDP5). + const parentA = makeItem('PARENT-A', 'idea'); + parentA.children = [makeItem('CHILD-A1'), makeItem('CHILD-A2')]; + parentA.childCount = 2; + + const parentB = makeItem('PARENT-B', 'in_progress'); + parentB.children = [makeItem('CHILD-B1')]; + parentB.childCount = 1; + + const state = new WorkItemListState([parentA, parentB], TERM_80x24); + state.toggleExpand('PARENT-A'); + + // Flattened: [PARENT-A, CHILD-A1, CHILD-A2, PARENT-B] + expect(state.getFlattenedItems().length).toBe(4); + expect(state.getFlattenedItems()[1].id).toBe('CHILD-A1'); + + // Refresh with brand-new objects that LACK `children` — exactly what the + // fetcher returns (children are fetched separately for expanded parents). + const freshParentA = { ...makeItem('PARENT-A', 'idea'), childCount: 2 }; + const freshParentB = { ...makeItem('PARENT-B', 'in_progress'), childCount: 1 }; + state.refreshItems([freshParentB, freshParentA]); + + // Expanded children must remain in the flattened view — no momentary + // collapse window after the swap. + expect(state.getFlattenedItems().map((i) => i.id)).toEqual([ + 'PARENT-B', + 'PARENT-A', + 'CHILD-A1', + 'CHILD-A2', + ]); + }); + + it('restores selection on a child of an expanded parent after a children-less refresh', () => { + // A selected CHILD must survive a refresh that swaps in children-less + // objects (previously the new flattened list lost the child, so selection + // jumped to the top of the list) — WL-0MSBVBNGH002RDP5 AC-4. + const parentA = makeItem('PARENT-A', 'idea'); + parentA.children = [makeItem('CHILD-A1'), makeItem('CHILD-A2')]; + parentA.childCount = 2; + + const parentB = makeItem('PARENT-B', 'in_progress'); + parentB.childCount = 1; + + const state = new WorkItemListState([parentA, parentB], TERM_80x24); + state.toggleExpand('PARENT-A'); + + // Select CHILD-A1 (flattened index 1). + state.selectedIndex = 1; + expect(state.getFlattenedItems()[1].id).toBe('CHILD-A1'); + + // Refresh with children-less NEW objects, reordered (B first). + const freshParentA = { ...makeItem('PARENT-A', 'idea'), childCount: 2 }; + const freshParentB = { ...makeItem('PARENT-B', 'in_progress'), childCount: 1 }; + state.refreshItems([freshParentB, freshParentA]); + + // CHILD-A1 is still visible and selected (now flattened index 2 after + // the reorder) — the selection followed the child, not the top of the list. + expect(state.getFlattenedItems()[2].id).toBe('CHILD-A1'); + expect(state.getFlattenedItems()[state.selectedIndex].id).toBe('CHILD-A1'); + }); + + it('does not overwrite fresh children already attached to the new objects (carry-over only fills gaps)', () => { + // doRefresh attaches freshly fetched children to the new parent objects + // BEFORE refreshItems; the carry-over must never clobber fresh data with + // stale children (WL-0MSBVBNGH002RDP5 AC-3 freshness). + const parentA = makeItem('PARENT-A', 'idea'); + parentA.children = [makeItem('CHILD-OLD')]; + parentA.childCount = 1; + + const state = new WorkItemListState([parentA], TERM_80x24); + state.toggleExpand('PARENT-A'); + + const freshParentA = { ...makeItem('PARENT-A', 'idea'), childCount: 1 }; + freshParentA.children = [makeItem('CHILD-FRESH')]; + state.refreshItems([freshParentA]); + + expect(state.getFlattenedItems().map((i) => i.id)).toEqual(['PARENT-A', 'CHILD-FRESH']); + }); + + it('does not carry children over for a parent that no longer exists after refresh', () => { + const parentA = makeItem('PARENT-A', 'idea'); + parentA.children = [makeItem('CHILD-A1')]; + parentA.childCount = 1; + + const state = new WorkItemListState([parentA], TERM_80x24); + state.toggleExpand('PARENT-A'); + expect(state.getFlattenedItems().length).toBe(2); + + // Refresh: PARENT-A is gone; only PARENT-B remains (no children). + const freshParentB = { ...makeItem('PARENT-B', 'in_progress'), childCount: 0 }; + state.refreshItems([freshParentB]); + + // No orphan children may leak into the flattened view. + expect(state.getFlattenedItems().map((i) => i.id)).toEqual(['PARENT-B']); + }); + it('prefers the selected item ID over the collapsed child position', () => { // Test that when parent is collapsed and then refreshed with expanded // children, the same child ID is found in the new flattened list diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index d1fdeaa28..1dafa362d 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -544,9 +544,35 @@ export class WorkItemListState { // Capture the currently selected item's ID before replacing items const prevSelectedId = this._captureSelectedId(); + // The fetcher returns fresh top-level objects that never carry a + // `children` array (normalizeItem drops it), so a plain swap would + // momentarily collapse every expanded parent until doRefresh re-fetches + // children asynchronously — the reported flicker (WL-0MSBVBNGH002RDP5). + // Carry the previously fetched children over to the new objects by ID so + // the flattened view — and any selection pointing at a child — survives + // the swap atomically; doRefresh replaces the carried-over data with + // fresh children immediately afterwards. + const prevChildrenById = new Map(); + for (const item of this._allItems) { + if (item.children && item.children.length > 0) { + prevChildrenById.set(item.id, item.children); + } + } + this._allItems = [...newItems]; this._applyFilters(); + // Attach carried-over children only where the new object has no children + // yet (fresh children attached by doRefresh are never clobbered). + for (const item of this._allItems) { + if (item.childCount && item.childCount > 0 && !item.children) { + const prev = prevChildrenById.get(item.id); + if (prev) { + item.children = prev; + } + } + } + // Try to restore selection by ID; fall back to clamping if not found if (!this._restoreSelectionById(prevSelectedId)) { this._clampSelection(); @@ -2195,28 +2221,49 @@ export async function runWorklistTui( refreshInFlight = true; try { try { - const newItems = await fetcher(); - const oldLen = state.items.length; - state.refreshItems(newItems); - // Re-read the Code Freeze marker so a freeze that started (or ended) - // since the last refresh is reflected in the banner promptly. - refreshFreezeState(); - // Re-fetch children for expanded parents so the hierarchy view stays - // fresh after an auto/manual refresh while inside a child context. + // Fetch the new top-level items and the children of currently + // expanded parents in PARALLEL, then apply both in one synchronous + // refreshItems step. The fetcher returns fresh top-level objects + // without `children`, so a swap followed by an async children + // re-fetch would render a collapsed intermediate state — the + // "momentary collapse" flicker (WL-0MSBVBNGH002RDP5). Fetching + // children up front removes that window entirely; refreshItems also + // carries over previously fetched children as a safety net when a + // children re-fetch fails. const expanded = [...state.expandedItems]; - if (expanded.length > 0) { + const freshChildren = new Map(); + const fetchExpandedChildren = async (parentId: string): Promise => { + try { + const children = await fetchChildrenForItem(parentId); + freshChildren.set(parentId, children); + return children; + } catch { + // Ignore: refreshItems carries over the previously fetched + // children when the re-fetch fails. + return []; + } + }; + const [newItems] = await Promise.all([ + fetcher(), + ...expanded.map(fetchExpandedChildren), + ]); + const oldLen = state.items.length; + + // Attach freshly fetched children to the new parent objects BEFORE + // the swap so the flattened view is complete at render time. + if (freshChildren.size > 0) { const byId = new Map(newItems.map((it) => [it.id, it])); - await Promise.all(expanded.map(async (parentId) => { + for (const [parentId, children] of freshChildren) { const parent = byId.get(parentId); - if (!parent) return; // parent no longer exists - try { - const children = await fetchChildrenForItem(parentId); + if (parent) { parent.children = children; - } catch { - // ignore: keep previously fetched children on refresh failure } - })); + } } + state.refreshItems(newItems); + // Re-read the Code Freeze marker so a freeze that started (or ended) + // since the last refresh is reflected in the banner promptly. + refreshFreezeState(); // Merge agent-status state into the refreshed items (top-level + // expanded children) so the agent icons reflect the latest tracker // state (WL-0MSBQUJQX005RAT9). Fail-open: no herdr CLI → no icons. From 2e64aacf1cce1aebbf2b482f5c53feba5857af05 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 14:28:17 +0100 Subject: [PATCH 19/59] WL-0MSHJ2NWZ000P3X9: Implementation complete --- tests/cli/read-cache-spawn-reduction.test.ts | 50 ++++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/tests/cli/read-cache-spawn-reduction.test.ts b/tests/cli/read-cache-spawn-reduction.test.ts index 7a664ddb0..b2105d15f 100644 --- a/tests/cli/read-cache-spawn-reduction.test.ts +++ b/tests/cli/read-cache-spawn-reduction.test.ts @@ -50,26 +50,36 @@ describe('read-cache spawn reduction (6-pane simulation)', () => { let spawnFile: string; let envBase: Record; - beforeEach(() => { - tempDir = createTempDir(); - writeConfig(tempDir, 'Spawn Reduction', 'SPR'); - writeInitSemaphore(tempDir); - spawnFile = path.join(tempDir, 'spawns.log'); - envBase = { WL_TUI_MODE: '1', WL_SPAWN_COUNT_FILE: spawnFile }; + beforeEach( + () => { + tempDir = createTempDir(); + writeConfig(tempDir, 'Spawn Reduction', 'SPR'); + writeInitSemaphore(tempDir); + spawnFile = path.join(tempDir, 'spawns.log'); + // WL_CACHE_TTL_MS: the 30s default TTL (a bounding safety net) can + // expire cache entries mid-run on loaded machines where each tsx + // subprocess spawn takes seconds; raise it so the simulation measures + // cache behaviour, not wall-clock timing. + envBase = { WL_TUI_MODE: '1', WL_SPAWN_COUNT_FILE: spawnFile, WL_CACHE_TTL_MS: '300000' }; - // Seed a realistic worklog (open/critical/completed-in-review/blocked). - const seed: Array<[string, string[]]> = [ - ['Critical Open', ['--priority', 'critical']], - ['High Open', ['--priority', 'high']], - ['Completed Review', ['--priority', 'medium', '--status', 'completed', '--stage', 'in_review']], - ['Blocked Item', ['--priority', 'low', '--status', 'blocked']], - ['In Progress', ['--priority', 'high', '--status', 'in-progress']], - ]; - for (const [title, extra] of seed) { - const res = runCli(['--json', 'create', '-t', title, ...extra], tempDir, envBase); - expect(res.status).toBe(0); - } - }); + // Seed a realistic worklog (open/critical/completed-in-review/blocked). + const seed: Array<[string, string[]]> = [ + ['Critical Open', ['--priority', 'critical']], + ['High Open', ['--priority', 'high']], + ['Completed Review', ['--priority', 'medium', '--status', 'completed', '--stage', 'in_review']], + ['Blocked Item', ['--priority', 'low', '--status', 'blocked']], + ['In Progress', ['--priority', 'high', '--status', 'in-progress']], + ]; + for (const [title, extra] of seed) { + const res = runCli(['--json', 'create', '-t', title, ...extra], tempDir, envBase); + expect(res.status).toBe(0); + } + }, + // Seeding runs 5 `tsx src/cli.ts create` subprocesses (cold tsx starts + // can take seconds each on a loaded machine); the default 10s hook + // timeout makes this beforeEach flake out. Matches the it() budget. + 180_000, + ); afterEach(() => { cleanupTempDir(tempDir); @@ -115,6 +125,6 @@ describe('read-cache spawn reduction (6-pane simulation)', () => { expect(cachedWork).toBeLessThanOrEqual(0.4 * baselineWork); expect(cachedHits).toBeGreaterThan(0); // panes 1-5 were served from cache }, - 180_000 // slow: 60+ tsx subprocess spawns + 300_000 // slow: 60+ tsx subprocess spawns (180s was too tight on loaded dev machines) ); }); From 178dfa7118cc2d736a114fd578493642b298b123 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 14:35:33 +0100 Subject: [PATCH 20/59] WL-0MSBV4RYO008JL70: Wire showIcons setting + settings reload mechanism for herdr plugin - index.ts: pass showIcons and getShowIcons (re-reads loadSettings() per render, mirroring getShowHelpText) into runWorklistTui options - worklist.ts: createListRenderer now accepts a getShowIcons getter (default icons-enabled); render consults it per render and passes noIcons down to formatItemLine; removed unused iconsEnabled import - tests: iconsEnabled unit tests (src/icons.test.ts), createListRenderer showIcons gating + per-render re-read tests (src/worklist.test.ts), TUI-level getShowIcons integration tests (src/worklist-icons.test.ts), settings load tests for showIcons:false (tests/herdr/settings.test.ts) - README: document that showIcons changes apply without a restart --- packages/herdr/README.md | 2 +- packages/herdr/src/icons.test.ts | 15 ++ packages/herdr/src/index.ts | 4 + packages/herdr/src/worklist-icons.test.ts | 197 ++++++++++++++++++++++ packages/herdr/src/worklist.test.ts | 40 +++++ packages/herdr/src/worklist.ts | 22 +-- tests/herdr/settings.test.ts | 12 ++ 7 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 packages/herdr/src/worklist-icons.test.ts diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 37c208ce5..cadb7e609 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -160,7 +160,7 @@ Settings are persisted in `~/.config/herdr/worklog-plugin.json`. Key settings in - `syncIntervalMs` — Interval in ms between background `wl sync` calls (default: `60000`, minimum: `60000`; set to `0` to disable auto-sync) - `browseItemCount` — Max number of non-mandatory items to show in the list (default: `10`, range `1`–`50`; critical and completed/in_review items are always shown regardless) - `showHelpText` — Show the shortcut hint line at the bottom of the list (default: `true`); changes apply on the next render without a plugin restart -- `showIcons` — Toggle icons in the list (default: `true`) +- `showIcons` — Toggle icons in the list (default: `true`); changes apply on the next render without a plugin restart ### Downtime worker (local-LLM idle dispatch) diff --git a/packages/herdr/src/icons.test.ts b/packages/herdr/src/icons.test.ts index 2c46d5d67..4b7d9dc05 100644 --- a/packages/herdr/src/icons.test.ts +++ b/packages/herdr/src/icons.test.ts @@ -14,11 +14,26 @@ import { describe, it, expect } from 'vitest'; import { agentStatusIcon, getIconPrefix, + iconsEnabled, stringDisplayWidth, } from './icons.js'; import { formatItemLine } from './worklist.js'; import type { WorkItem } from './fetcher.js'; +describe('iconsEnabled — noIcons flag (WL-0MSBV4RYO008JL70)', () => { + it('returns true by default', () => { + expect(iconsEnabled()).toBe(true); + }); + + it('returns false when noIcons is true', () => { + expect(iconsEnabled({ noIcons: true })).toBe(false); + }); + + it('returns true when noIcons is false', () => { + expect(iconsEnabled({ noIcons: false })).toBe(true); + }); +}); + describe('agentStatusIcon', () => { it('maps working → 🟢 (green circle)', () => { expect(agentStatusIcon('working')).toBe('\u{1F7E2}'); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index ae4517d1d..8ab8bfc48 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -528,11 +528,15 @@ async function main(): Promise { autoSync: runSettings.autoSync, syncIntervalMs: runSettings.syncIntervalMs, showHelpText: runSettings.showHelpText, + showIcons: runSettings.showIcons, downtimeWorker, downtimePollIntervalMs: runSettings.downtimePollIntervalMs, // Re-read on every render so a showHelpText change applies on the next // refresh (no plugin restart needed), matching browseItemCount behavior. getShowHelpText: () => loadSettings().showHelpText ?? true, + // Re-read on every render so a showIcons change applies on the next + // refresh (no plugin restart needed), matching showHelpText behavior. + getShowIcons: () => loadSettings().showIcons ?? true, // Merge agent-status state into freshly fetched items (top-level + // expanded children) on every refresh cycle (WL-0MSBQUJQX005RAT9). // Fail-open: herdr errors yield no icons; the list keeps working. diff --git a/packages/herdr/src/worklist-icons.test.ts b/packages/herdr/src/worklist-icons.test.ts new file mode 100644 index 000000000..c8a6929ac --- /dev/null +++ b/packages/herdr/src/worklist-icons.test.ts @@ -0,0 +1,197 @@ +/** + * packages/herdr/src/worklist-icons.test.ts — TUI-level integration tests + * for the showIcons setting gating (WL-0MSBV4RYO008JL70). + * + * runWorklistTui accepts a `getShowIcons` getter (wired from the settings + * file in index.ts) that is consulted on every render. When it returns + * false the item lines render text fallbacks ([OPEN], [IDEA], ...) instead + * of emoji icons; when true (the default) icons are rendered. Because the + * getter is re-read per render, editing showIcons in the config applies on + * the next render without a plugin restart. + * + * Run: npx vitest run packages/herdr/src/worklist-icons.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Mock } from 'vitest'; + +// --------------------------------------------------------------------------- +// Module mocks (must be hoisted before worklist.js is imported) +// --------------------------------------------------------------------------- +// Keep the REAL fetcher + visibility modules (so the exec seam and PollGate +// run for real) but avoid real `wl`/`herdr` process spawns by injecting a +// mock execFileAsync via setExecFileAsync() in each test. + +vi.mock('./auto-sync.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runSync: vi.fn().mockResolvedValue({ success: true }), + }; +}); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui } from './worklist.js'; +import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; +import type { WorkItem } from './fetcher.js'; + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness (same pattern as worklist-agent-state.test.ts) +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + for (const prop of ['on', 'removeListener', 'pause', 'resume', 'setRawMode'] as const) { + if (!(prop in process.stdin)) { + Object.defineProperty(process.stdin, prop, { + value: vi.fn(), + configurable: true, + writable: true, + }); + } + } + (process.stdin as any).on = vi.fn((event: string, cb: (chunk: Buffer) => void) => { + if (event === 'data') dataHandler = cb; + return process.stdin; + }); + (process.stdin as any).removeListener = vi.fn(() => process.stdin); + (process.stdin as any).pause = vi.fn(() => process.stdin); + (process.stdin as any).resume = vi.fn(() => process.stdin); + (process.stdin as any).setRawMode = vi.fn(() => process.stdin); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + vi.spyOn(process.stdout, 'write').mockImplementation(((s: any) => { + writes.push(String(s)); + return true; + }) as any); + vi.spyOn(process.stdout, 'on').mockImplementation((() => process.stdout as any) as any); + vi.spyOn(process.stdout, 'removeListener').mockImplementation((() => process.stdout as any) as any); +}); + +afterEach(() => { + resetExecFileAsync(); + delete process.env.HERDR_PANE_ID; + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function makeExecMock(): Mock { + return vi.fn(async (bin: string, args: string[]) => { + if (bin === 'herdr' && args[0] === 'pane' && args[1] === 'get') { + return { + stdout: JSON.stringify({ id: 'cli:pane:get', result: { pane: { focused: true } } }), + stderr: '', + }; + } + if (args.includes('list') && args.includes('--status')) { + return { stdout: JSON.stringify({ count: 5 }), stderr: '' }; + } + return { stdout: JSON.stringify({ workItems: [] }), stderr: '' }; + }); +} + +async function quit(p: Promise): Promise { + dataHandler?.(Buffer.from('q')); + await p; +} + +const OPEN_ICON = '\u{1F513}'; // 🔓 — the open-status icon + +describe('worklist — showIcons gating through runWorklistTui', () => { + it('renders item icons by default (backwards compatible)', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: false, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + + const output = writes.join(''); + expect(output).toContain(OPEN_ICON); + expect(output).toContain('WL-1'); + expect(output).not.toContain('[OPEN]'); + + await quit(p); + }); + + it('omits item icons when getShowIcons returns false', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea', priority: 'high' }, + ]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: false, + autoSync: false, + showHelpText: false, + getShowIcons: () => false, + }); + await vi.advanceTimersByTimeAsync(0); + + const output = writes.join(''); + expect(output).not.toContain(OPEN_ICON); + expect(output).toContain('[OPEN]'); // status text fallback + expect(output).toContain('WL-1'); + + await quit(p); + }); + + it('applies a showIcons change on the next render (per-render re-read)', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + // Mutable flag standing in for the settings file on disk: the getter + // re-reads it on every render, so flipping it between renders applies + // without a plugin restart (mirrors getShowHelpText behavior). + let showIcons = true; + const fetcher = vi.fn(async (): Promise => [ + { id: 'WL-1', title: 'Task one', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: true, + refreshIntervalMs: 30_000, + autoSync: false, + showHelpText: false, + getShowIcons: () => showIcons, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + // First render (initial load) — icons shown. + expect(writes.join('')).toContain(OPEN_ICON); + + // User edits the config to disable icons, then the next refresh renders. + showIcons = false; + writes = []; // ignore the initial render — assert only the refresh render + await vi.advanceTimersByTimeAsync(30_000); + + const output = writes.join(''); + expect(output).not.toContain(OPEN_ICON); + expect(output).toContain('[OPEN]'); + expect(output).toContain('WL-1'); + + await quit(p); + }); +}); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index a129bd303..921bee268 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -1353,3 +1353,43 @@ describe('renderDowntimeStatus', () => { expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); }); }); + +// ── showIcons gating (WL-0MSBV4RYO008JL70) ───────────────────────────── +// The renderer consults the getShowIcons getter on EVERY render, so a +// showIcons setting change applies without a plugin restart (same pattern +// as getShowHelpText). When the getter returns false, item lines render +// text fallbacks ([OPEN], [IDEA], ...) instead of emoji icons. + +const OPEN_ICON = '\u{1F513}'; // 🔓 — the open-status icon + +describe('createListRenderer — showIcons gating', () => { + it('renders emoji icons by default (backwards compatible)', () => { + const renderer = createListRenderer(); + const output = renderer([makeItem('A', 'idea')], 0, 0, TERM_80x24, null, 'list', null); + expect(output).toContain(OPEN_ICON); + expect(output).not.toContain('[OPEN]'); + }); + + it('renders text fallbacks instead of icons when getShowIcons returns false', () => { + const renderer = createListRenderer(() => false); + const output = renderer([makeItem('A', 'idea')], 0, 0, TERM_80x24, null, 'list', null); + expect(output).not.toContain(OPEN_ICON); + expect(output).toContain('[OPEN]'); // status text fallback + expect(output).toContain('[IDEA]'); // stage text fallback + }); + + it('re-reads the getter on every render (settings re-read path)', () => { + let showIcons = true; + const renderer = createListRenderer(() => showIcons); + + const withIcons = renderer([makeItem('A', 'idea')], 0, 0, TERM_80x24, null, 'list', null); + expect(withIcons).toContain(OPEN_ICON); + + // Simulate the user editing the config: flip the flag, render again — + // the getter is consulted per-render, so the change applies immediately. + showIcons = false; + const withoutIcons = renderer([makeItem('A', 'idea')], 0, 0, TERM_80x24, null, 'list', null); + expect(withoutIcons).not.toContain(OPEN_ICON); + expect(withoutIcons).toContain('[OPEN]'); + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 1dafa362d..b4063d460 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -25,7 +25,6 @@ import { needsProducerReviewIcon, getIconPrefix, applyStageColour, - iconsEnabled, stageColor, type IconOptions, } from './icons.js'; @@ -1548,7 +1547,7 @@ export function renderDowntimeStatus(worker: DowntimeWorker | undefined): string return ` ${ANSI.dim}[downtime busy]${ANSI.reset}`; } -export function createListRenderer(): ( +export function createListRenderer(getShowIcons?: () => boolean): ( items: WorkItem[], selectedIndex: number, scrollOffset: number, @@ -1570,6 +1569,9 @@ export function createListRenderer(): ( readFile?: (filePath: string) => string | null, downtimeStatus?: string, ) => string { + // Default to icons enabled when no getter is supplied (backwards + // compatible — callers/tests that render without options keep icons). + const showIconsGetter = getShowIcons ?? (() => true); return ( items: WorkItem[], selectedIndex: number, @@ -1706,7 +1708,7 @@ export function createListRenderer(): ( const expandedItem = { ...item, _expanded: hasChildCount && isExpanded }; const isSelected = actualIndex === selectedIndex; - const noIcons = !iconsEnabled(); + const noIcons = !showIconsGetter(); const line = formatItemLine(expandedItem, cols, isSelected, noIcons); if (isSelected) { output.push(`${ANSI.reverse}${line}${ANSI.reset}`); @@ -1772,11 +1774,6 @@ export function createListRenderer(): ( // ── Main TUI loop ───────────────────────────────────────────────────── -/** - * Default renderer instance. - */ -const defaultRenderer = createListRenderer(); - /** * Work-item ID format: a prefix (e.g. `WL`, `SA`) followed by a hash, * e.g. `WL-0MS9NPHQU005Y3VE`. @@ -2093,7 +2090,7 @@ export async function runWorklistTui( fetcher: () => Promise, initialItems?: WorkItem[], shortcutRegistry?: { lookupChord: Function; getChordByLeader: Function; getChordByPrefix: Function; getChordEntries: Function } | ShortcutRegistry | undefined, - options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; onCommand?: (command: string, model?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number; mergeAgentStates?: (items: WorkItem[]) => Promise }, + options?: { autoRefresh?: boolean; refreshIntervalMs?: number; autoSync?: boolean; syncIntervalMs?: number; browseItemCount?: number; showHelpText?: boolean; getShowHelpText?: () => boolean; showIcons?: boolean; getShowIcons?: () => boolean; onCommand?: (command: string, model?: string) => void; downtimeWorker?: DowntimeWorker; downtimePollIntervalMs?: number; mergeAgentStates?: (items: WorkItem[]) => Promise }, ): Promise { const opts = { autoRefresh: options?.autoRefresh ?? true, @@ -2103,6 +2100,8 @@ export async function runWorklistTui( browseItemCount: options?.browseItemCount ?? 10, showHelpText: options?.showHelpText ?? true, getShowHelpText: options?.getShowHelpText ?? (() => options?.showHelpText ?? true), + showIcons: options?.showIcons ?? true, + getShowIcons: options?.getShowIcons ?? (() => options?.showIcons ?? true), onCommand: options?.onCommand, downtimeWorker: options?.downtimeWorker, downtimePollIntervalMs: options?.downtimePollIntervalMs ?? DEFAULT_DOWNTIME_POLL_INTERVAL_MS, @@ -2128,7 +2127,10 @@ export async function runWorklistTui( }); const state = new WorkItemListState(items, termSize); - const renderer = defaultRenderer; + // Icons are gated by the getShowIcons getter (re-read on every render so a + // showIcons setting change applies without a plugin restart — same pattern + // as getShowHelpText). The renderer is created per-TUI-run with the getter. + const renderer = createListRenderer(opts.getShowIcons); const chordState: ChordState = createChordState(); let formState: FormState | null = null; /** Saved mode before entering form overlay (to restore on cancel) */ diff --git a/tests/herdr/settings.test.ts b/tests/herdr/settings.test.ts index cf4f527b2..0fba74da9 100644 --- a/tests/herdr/settings.test.ts +++ b/tests/herdr/settings.test.ts @@ -138,6 +138,18 @@ describe('loadSettings', () => { expect(settings.downtimeRequiredFreeSlots).toBe(0); // from defaults }); + it('loads showIcons: false from the config file', () => { + writeFileSync(settingsPath, JSON.stringify({ showIcons: false }), 'utf-8'); + const settings = loadSettings(settingsPath); + expect(settings.showIcons).toBe(false); + }); + + it('defaults showIcons to true when missing from the config', () => { + writeFileSync(settingsPath, JSON.stringify({ autoRefresh: false }), 'utf-8'); + const settings = loadSettings(settingsPath); + expect(settings.showIcons).toBe(true); + }); + it('loads settings from existing file', () => { writeFileSync(settingsPath, JSON.stringify({ autoRefresh: false, From 2e26cf96ae9fbeebeda3f0496f9c88feda770f13 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 14:37:53 +0100 Subject: [PATCH 21/59] WL-0MSGEA9AY0080V4Q: Implementation complete --- packages/herdr/README.md | 8 +++ packages/herdr/src/worklist.test.ts | 80 ++++++++++++++++++++++++++++- packages/herdr/src/worklist.ts | 58 ++++++++++++++++++--- 3 files changed, 138 insertions(+), 8 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 37c208ce5..ee19948ad 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -335,6 +335,14 @@ description. The viewer: - is preview-only (no notes editor); - falls back to the raw description when the file is missing/unreadable. +Key Files paths are resolved against the **worklog root** (the directory +containing `.worklog/`, from `HERDR_RESOLVED_CWD` / `configureWorklogTarget`) +— never the plugin pane's process CWD, which is the plugin source dir — with +fallback candidates tried in order: the worklog root, then the legacy +podcast-relative base `/.llm-wiki/wiki/podcast/` (older episode items +wrote Key Files paths relative to the podcast dir rather than the wiki root), +then `process.cwd()` as a last resort. + The rendered lines appear under an `Episode file (md viewer)` heading in the detail view, scrollable with the usual `↑↓/j:k` keys. diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index a129bd303..f87746bb3 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { WorkItemListState, getTermSize, @@ -26,12 +27,13 @@ import { formatMetadataPanel, formatTimestamp, buildMetaRows, + resolveKeyFilePath, } from './worklist.js'; import type { DowntimeWorker } from './downtime-worker.js'; import { setLogPath, resetLogPath, recordCommand, getLastCommand } from './command-log.js'; import { loadShortcutConfig, ShortcutRegistry } from './shortcut-config.js'; import { regroupWorkItems } from './grouping.js'; -import type { WorkItem } from './fetcher.js'; +import { setWorklogDir, resetWorklogDir, type WorkItem } from './fetcher.js'; // ── ANSI helpers ─────────────────────────────────────────────────────── // Regression test: the sync-failed status indicator uses ANSI.yellow @@ -1353,3 +1355,79 @@ describe('renderDowntimeStatus', () => { expect(output.split('\n').length).toBeLessThanOrEqual(TERM_80x24.rows - 1); }); }); + +// ── Key Files md path resolution (WL-0MSGEA9AY0080V4Q) ───────────────── +// Regression: readKeyFile resolved `Key Files:` paths against the plugin +// pane's process.cwd() (the herdr plugin source dir), NOT the worklog root, +// so episode .podcast.md files never rendered in the detail view. +// resolveKeyFilePath must prefer the configured worklog root, then the +// legacy podcast-relative base (.llm-wiki/wiki/podcast/), and only fall +// back to process.cwd() as a last resort. Fail-open: no candidate on disk +// yields null so the detail view falls back to the raw description. + +describe('resolveKeyFilePath (Key Files md resolution)', () => { + let root: string; + let originalCwd: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'herdr-keyfiles-')); + // Simulate configureWorklogTarget: the resolved worklog root is the + // parent of the configured .worklog dir + // (setWorklogDir(join(wlRoot, '.worklog'))). + setWorklogDir(join(root, '.worklog')); + originalCwd = process.cwd(); + }); + + afterEach(() => { + resetWorklogDir(); + process.chdir(originalCwd); + rmSync(root, { recursive: true, force: true }); + }); + + it('resolves Key Files paths against the worklog root, not process.cwd()', () => { + writeFileSync(join(root, 'episode.podcast.md'), '# Episode'); + const resolved = resolveKeyFilePath('episode.podcast.md'); + expect(resolved).toBe(join(root, 'episode.podcast.md')); + }); + + it('falls back to the legacy podcast-relative base under the worklog root', () => { + const podcastDir = join(root, '.llm-wiki', 'wiki', 'podcast', 'irish-folklore-fine-tuning'); + mkdirSync(podcastDir, { recursive: true }); + writeFileSync(join(podcastDir, 'irish-folklore-fine-tuning.podcast.md'), '# Episode'); + const resolved = resolveKeyFilePath('irish-folklore-fine-tuning/irish-folklore-fine-tuning.podcast.md'); + expect(resolved).toBe(join(podcastDir, 'irish-folklore-fine-tuning.podcast.md')); + }); + + it('prefers the worklog root over the podcast base when both exist', () => { + const podcastDir = join(root, '.llm-wiki', 'wiki', 'podcast'); + mkdirSync(podcastDir, { recursive: true }); + writeFileSync(join(root, 'shared.podcast.md'), 'root copy'); + writeFileSync(join(podcastDir, 'shared.podcast.md'), 'podcast copy'); + const resolved = resolveKeyFilePath('shared.podcast.md'); + expect(resolved).toBe(join(root, 'shared.podcast.md')); + }); + + it('falls back to process.cwd() as a last resort when no worklog dir is configured', () => { + resetWorklogDir(); + const cwdDir = mkdtempSync(join(tmpdir(), 'herdr-keyfiles-cwd-')); + process.chdir(cwdDir); + try { + writeFileSync(join(cwdDir, 'notes.md'), '# Notes'); + const resolved = resolveKeyFilePath('notes.md'); + expect(resolved).toBe(join(cwdDir, 'notes.md')); + } finally { + process.chdir(originalCwd); + rmSync(cwdDir, { recursive: true, force: true }); + } + }); + + it('returns null when no candidate exists on disk', () => { + expect(resolveKeyFilePath('missing/file.podcast.md')).toBeNull(); + }); + + it('accepts absolute Key Files paths directly', () => { + writeFileSync(join(root, 'abs.podcast.md'), '# Abs'); + const abs = join(root, 'abs.podcast.md'); + expect(resolveKeyFilePath(abs)).toBe(abs); + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index 1dafa362d..29b446c17 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -10,8 +10,8 @@ * Herdr's pane-based model. */ -import { readFileSync } from 'node:fs'; -import { resolve as resolvePath } from 'node:path'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve as resolvePath } from 'node:path'; import { fetchChildrenForItem, fetchActionableCount, getWorklogDir, type WorkItem } from './fetcher.js'; import { isPaneVisible, PollGate, DEFAULT_POLL_GATE_TTL_MS } from './visibility.js'; @@ -1070,6 +1070,47 @@ export function firstMarkdownKeyFile(description: string | undefined): string { return md ?? ''; } +/** + * Resolve a `Key Files:` markdown path to an absolute filesystem path. + * + * Key Files paths are documented as relative to the worklog root, but the + * plugin pane's process CWD is the plugin source directory — NOT the worklog + * root — so resolving against `process.cwd()` alone is wrong + * (WL-0MSGEA9AY0080V4Q). Candidates are tried in order: + * + * 1. the resolved worklog root (the directory containing `.worklog/`, + * derived from the configured worklog dir — see configureWorklogTarget / + * HERDR_RESOLVED_CWD), + * 2. the legacy podcast-relative base `/.llm-wiki/wiki/podcast/` + * (older episode items wrote Key Files paths relative to the podcast dir + * rather than the wiki root), + * 3. `process.cwd()` as a last resort (plain CWD-relative key paths). + * + * Fail-open: returns null when no candidate exists on disk, so the detail + * view falls back to the raw description. + */ +export function resolveKeyFilePath(filePath: string): string | null { + const bases: string[] = []; + const wlDir = getWorklogDir(); + if (wlDir) { + const wlRoot = dirname(wlDir); + bases.push(wlRoot); + bases.push(join(wlRoot, '.llm-wiki', 'wiki', 'podcast')); + } + bases.push(process.cwd()); + for (const base of bases) { + try { + const candidate = resolvePath(base, filePath); + if (existsSync(candidate)) { + return candidate; + } + } catch { + // Ignore unreadable base and try the next candidate. + } + } + return null; +} + /** * Format the detail view for a single work item, with scrolling support. * @@ -2637,14 +2678,17 @@ export async function runWorklistTui( /** * Read a Key Files: markdown document (e.g. an episode .podcast.md) for - * the generic md viewer in the detail pane. Paths are relative to the - * worklog root (the process CWD when the plugin runs in a pane). - * Fail-open: unreadable or missing files yield null and the detail view - * falls back to the raw description. + * the generic md viewer in the detail pane. Paths are resolved against + * the worklog root (via resolveKeyFilePath) — the plugin pane's process + * CWD is the plugin source dir, not the worklog root + * (WL-0MSGEA9AY0080V4Q). Fail-open: unreadable or missing files yield + * null and the detail view falls back to the raw description. */ const readKeyFile = (filePath: string): string | null => { + const resolved = resolveKeyFilePath(filePath); + if (!resolved) return null; try { - return readFileSync(resolvePath(filePath, process.cwd()), 'utf-8'); + return readFileSync(resolved, 'utf-8'); } catch { return null; } From 1710cd2ebd6e05d450290d7206822dc877c7c3cf Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 14:50:47 +0100 Subject: [PATCH 22/59] WL-0MSBV4RYO008JL70: Gate metadata panel and detail-view icons behind showIcons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC1 requires ALL icons omitted when showIcons=false, including the audit and producer-review icons in the metadata panel and detail view (they were rendered unconditionally via buildMetaRows). Thread the noIcons flag through buildMetaRows/formatMetadataPanel/formatDetailContent/ formatDetailView (all default to false for backward compatibility) and hoist the per-frame noIcons computation in the renderer so list lines, detail view, and metadata panel share one gate. Tests assert the audit icon (❓) is replaced by the [ ? ] text fallback when icons are off. --- packages/herdr/src/worklist-icons.test.ts | 4 ++++ packages/herdr/src/worklist.test.ts | 3 +++ packages/herdr/src/worklist.ts | 23 +++++++++++++++-------- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/herdr/src/worklist-icons.test.ts b/packages/herdr/src/worklist-icons.test.ts index c8a6929ac..81ab6f63c 100644 --- a/packages/herdr/src/worklist-icons.test.ts +++ b/packages/herdr/src/worklist-icons.test.ts @@ -105,6 +105,7 @@ async function quit(p: Promise): Promise { } const OPEN_ICON = '\u{1F513}'; // 🔓 — the open-status icon +const AUDIT_UNKNOWN_ICON = '\u{2753}'; // ❓ — audit-unknown icon (metadata panel) describe('worklist — showIcons gating through runWorklistTui', () => { it('renders item icons by default (backwards compatible)', async () => { @@ -127,6 +128,7 @@ describe('worklist — showIcons gating through runWorklistTui', () => { expect(output).toContain(OPEN_ICON); expect(output).toContain('WL-1'); expect(output).not.toContain('[OPEN]'); + expect(output).toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon still shown await quit(p); }); @@ -152,6 +154,8 @@ describe('worklist — showIcons gating through runWorklistTui', () => { expect(output).not.toContain(OPEN_ICON); expect(output).toContain('[OPEN]'); // status text fallback expect(output).toContain('WL-1'); + expect(output).not.toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon + expect(output).toContain('[?]'); // audit text fallback await quit(p); }); diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index 921bee268..bd0501670 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -1361,6 +1361,7 @@ describe('renderDowntimeStatus', () => { // text fallbacks ([OPEN], [IDEA], ...) instead of emoji icons. const OPEN_ICON = '\u{1F513}'; // 🔓 — the open-status icon +const AUDIT_UNKNOWN_ICON = '\u{2753}'; // ❓ — audit-unknown icon (metadata panel) describe('createListRenderer — showIcons gating', () => { it('renders emoji icons by default (backwards compatible)', () => { @@ -1376,6 +1377,8 @@ describe('createListRenderer — showIcons gating', () => { expect(output).not.toContain(OPEN_ICON); expect(output).toContain('[OPEN]'); // status text fallback expect(output).toContain('[IDEA]'); // stage text fallback + expect(output).not.toContain(AUDIT_UNKNOWN_ICON); // metadata panel audit icon + expect(output).toContain('[?]'); // audit text fallback }); it('re-reads the getter on every render (settings re-read path)', () => { diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index b4063d460..3ecdb86c5 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -824,7 +824,7 @@ export function formatTimestamp(iso: string): string { * Shared by the detail view and the list-mode metadata panel so both stay * consistent (WL-0MSAYNVBY006LM9X-FT4). */ -export function buildMetaRows(item: WorkItem): Array<[string, string]> { +export function buildMetaRows(item: WorkItem, noIcons = false): Array<[string, string]> { const metaRows: Array<[string, string]> = []; const addMeta = (label: string, value: string | undefined | null): void => { if (value != null && value !== '') { @@ -847,8 +847,8 @@ export function buildMetaRows(item: WorkItem): Array<[string, string]> { addMeta('GitHub Issue', item.githubIssueNumber ? `#${item.githubIssueNumber}` : undefined); addMeta('Created', item.createdAt ? formatTimestamp(item.createdAt) : undefined); addMeta('Updated', item.updatedAt ? formatTimestamp(item.updatedAt) : undefined); - addMeta('Audit', auditIcon(item.auditResult)); - addMeta('Reviewed', needsProducerReviewIcon(item.needsProducerReview)); + addMeta('Audit', auditIcon(item.auditResult, { noIcons })); + addMeta('Reviewed', needsProducerReviewIcon(item.needsProducerReview, { noIcons })); addMeta('Audited At', item.auditedAt ? formatTimestamp(item.auditedAt) : undefined); return metaRows; } @@ -876,6 +876,7 @@ export function formatMetadataPanel( panelRows: number, metaScrollOffset = 0, lastCommand?: string | null, + noIcons = false, ): string[] { const lines: string[] = []; if (!item) { @@ -890,7 +891,7 @@ export function formatMetadataPanel( lines.push(` ${ANSI.dim}── ${item.id} ──${ANSI.reset}`); // Metadata rows - const metaRows = buildMetaRows(item); + const metaRows = buildMetaRows(item, noIcons); if (metaRows.length > 0) { const fieldWidth = Math.max(...metaRows.map(([l]) => l.length), 6); for (const [label, value] of metaRows) { @@ -949,6 +950,7 @@ export function formatDetailContent( item: WorkItem | null, maxCols: number, readFile?: (filePath: string) => string | null, + noIcons = false, ): string[] { if (!item) return []; @@ -965,7 +967,7 @@ export function formatDetailContent( // Metadata — rendered as a markdown table (shared row builder; ID and // Title are already shown in the header above, so they are filtered out // here to avoid duplicating them). - const metaRows = buildMetaRows(item).filter(([label]) => label !== 'ID' && label !== 'Title'); + const metaRows = buildMetaRows(item, noIcons).filter(([label]) => label !== 'ID' && label !== 'Title'); // Render the metadata as a markdown table if (metaRows.length > 0) { @@ -1084,8 +1086,9 @@ export function formatDetailView( scrollOffset = 0, viewportHeight = 20, readFile?: (filePath: string) => string | null, + noIcons = false, ): string { - const allLines = formatDetailContent(item, maxCols, readFile); + const allLines = formatDetailContent(item, maxCols, readFile, noIcons); if (allLines.length === 0) return ''; const totalLines = allLines.length; @@ -1595,6 +1598,10 @@ export function createListRenderer(getShowIcons?: () => boolean): ( downtimeStatus?: string, ): string => { const { rows, cols } = termSize; + // Icons are gated by the getter for the whole frame (list lines, detail + // view, and metadata panel alike) so showIcons=false omits every item + // icon, including audit/review icons (AC1, WL-0MSBV4RYO008JL70). + const noIcons = !showIconsGetter(); const output: string[] = []; // The metadata panel reserves 20–40% of the pane height below the list; // the list area is the remaining height minus the notification row. @@ -1605,7 +1612,7 @@ export function createListRenderer(getShowIcons?: () => boolean): ( if (mode === 'detail' && detailItem) { const viewportHeight = Math.max(10, rows - 1); const offset = detailScrollOffset ?? 0; - return formatDetailView(detailItem, cols, offset, viewportHeight, readFile); + return formatDetailView(detailItem, cols, offset, viewportHeight, readFile, noIcons); } if (mode === 'filter') { @@ -1708,7 +1715,6 @@ export function createListRenderer(getShowIcons?: () => boolean): ( const expandedItem = { ...item, _expanded: hasChildCount && isExpanded }; const isSelected = actualIndex === selectedIndex; - const noIcons = !showIconsGetter(); const line = formatItemLine(expandedItem, cols, isSelected, noIcons); if (isSelected) { output.push(`${ANSI.reverse}${line}${ANSI.reset}`); @@ -1755,6 +1761,7 @@ export function createListRenderer(getShowIcons?: () => boolean): ( panelHeight, metaScrollOffset ?? 0, metaLastCommand, + noIcons, ); for (const line of panelLines) { output.push(line); From 9eeb7c1f5185f72d48377ce8bd9e67ba85841fe5 Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 15:14:33 +0100 Subject: [PATCH 23/59] WL-0MSHLCOCR0031GAF: Implementation complete --- tests/cli/mock-timeout.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/cli/mock-timeout.test.ts b/tests/cli/mock-timeout.test.ts index 273421929..4f900cb8b 100644 --- a/tests/cli/mock-timeout.test.ts +++ b/tests/cli/mock-timeout.test.ts @@ -37,10 +37,15 @@ describe('mock-bin/git timeout guard', () => { // Simulate a long-running command by asking git to do init (which creates a .git dir) // Then run rev-parse with a very short timeout const result = await execFile(gitMockPath, ['rev-parse', '--show-toplevel'], { - env: { ...process.env, WORKLOG_MOCK_TIMEOUT: '1' }, + // 1s was too tight for normal execution on loaded machines (bash + // startup alone can exceed it), which made the guard fire spuriously + // (exit 124) and flake the test under full-suite parallel load. Use a + // generous budget: the point is that a NORMAL rev-parse must never + // trip the guard, not that it finishes within a second. + env: { ...process.env, WORKLOG_MOCK_TIMEOUT: '30' }, timeout: 10000, }) - // A timeout of 1s should be enough for normal execution, but the timeout + // A timeout should be enough for normal execution, but the timeout // mechanism must not cause premature exit. This test validates that basic // operations work within the timeout window. // Just verify it completes normally - no error From 386ba9a26d261a777125e8b182609acc5ce5684b Mon Sep 17 00:00:00 2001 From: pi Date: Thu, 6 Aug 2026 15:20:20 +0100 Subject: [PATCH 24/59] WL-0MSGI7PV9004UD0N: Implementation complete --- .../{tui-tests.yml => cli-tests.yml} | 2 +- .github/workflows/install-and-smoke-test.yml | 47 +- CLI.md | 40 - IMPLEMENTATION_SUMMARY.md | 10 +- LOCAL_LLM.md | 19 +- README.md | 31 +- TUI.md | 80 - docs/TUI_PROFILING.md | 126 -- docs/icons-design.md | 115 +- docs/migrations/dialog-helpers-mapping.md | 8 +- docs/opencode-to-pi-migration.md | 36 +- docs/tutorials/03-building-a-plugin.md | 2 +- docs/tutorials/04-using-the-tui.md | 235 -- docs/tutorials/05-planning-an-epic.md | 1 - docs/tutorials/README.md | 3 +- docs/ux/design-checklist.md | 89 - docs/wl-integration.md | 17 +- package.json | 1 - packages/tui/extensions/README.md | 730 +----- .../tui/extensions/Worklog/actionPalette.ts | 477 ---- packages/tui/extensions/Worklog/chatPane.ts | 530 ----- packages/tui/extensions/Worklog/config.ts | 315 --- packages/tui/extensions/Worklog/index.ts | 191 +- .../Worklog/lib/auto-inject.test.ts | 679 ------ .../tui/extensions/Worklog/lib/auto-inject.ts | 373 ---- .../Worklog/lib/browse-idle.test.ts | 285 --- .../tui/extensions/Worklog/lib/browse.test.ts | 501 ----- packages/tui/extensions/Worklog/lib/browse.ts | 1458 ------------ .../extensions/Worklog/lib/grouping.test.ts | 176 -- .../tui/extensions/Worklog/lib/grouping.ts | 379 ---- .../extensions/Worklog/lib/scheduler.test.ts | 566 ----- .../tui/extensions/Worklog/lib/scheduler.ts | 701 ------ .../extensions/Worklog/lib/settings.test.ts | 65 - .../tui/extensions/Worklog/lib/settings.ts | 290 --- .../extensions/Worklog/lib/shortcuts.test.ts | 208 -- .../tui/extensions/Worklog/lib/shortcuts.ts | 120 - .../Worklog/lib/smart-selection.test.ts | 258 --- .../extensions/Worklog/lib/smart-selection.ts | 55 - .../tui/extensions/Worklog/lib/tools.test.ts | 137 -- packages/tui/extensions/Worklog/lib/tools.ts | 531 ----- .../Worklog/settings-config.test.ts | 1120 ---------- .../tui/extensions/Worklog/settings-config.ts | 301 --- .../Worklog/settings-persistence.test.ts | 304 --- .../Worklog/shortcut-config-edge.test.ts | 710 ------ .../Worklog/shortcut-config.test.ts | 1546 ------------- .../tui/extensions/Worklog/shortcut-config.ts | 506 ----- .../tui/extensions/Worklog/shortcuts.json | 154 -- .../tui/extensions/Worklog/worklog-helpers.ts | 201 -- packages/tui/extensions/package.json | 2 +- packages/tui/extensions/settings.json | 5 +- packages/tui/pi.json | 35 - packages/tui/tests/activity-indicator.test.ts | 910 -------- .../tui/tests/browse-auto-refresh.test.ts | 1007 --------- .../tests/browse-detail-escape-loop.test.ts | 468 ---- .../browse-hierarchical-navigation.test.ts | 928 -------- .../tui/tests/browse-shortcut-help.test.ts | 257 --- packages/tui/tests/browse-total-count.test.ts | 413 ---- .../tui/tests/build-selection-widget.test.ts | 299 --- packages/tui/tests/extension-label.test.ts | 94 - packages/tui/tests/icons-import-path.test.ts | 50 - .../tui/tests/runWl-init-detection.test.ts | 603 ----- packages/tui/tests/worklog-widgets.test.ts | 302 --- src/cli.ts | 6 - src/commands/grouping.ts | 2 +- src/commands/piman.ts | 79 - src/commands/tui.ts | 79 - tests/README.md | 36 +- tests/cli/cli-inproc.ts | 2 - tests/cli/completion.test.ts | 2 +- tests/e2e/agent-flow.test.ts | 169 -- tests/e2e/headless-tui.test.ts | 294 --- .../worklog-browse-extension.test.ts | 1985 ----------------- tests/unit/resolve-pi-dir.test.ts | 118 +- tests/verify-blessed-removal.test.ts | 95 +- 74 files changed, 266 insertions(+), 22703 deletions(-) rename .github/workflows/{tui-tests.yml => cli-tests.yml} (96%) delete mode 100644 TUI.md delete mode 100644 docs/TUI_PROFILING.md delete mode 100644 docs/tutorials/04-using-the-tui.md delete mode 100644 docs/ux/design-checklist.md delete mode 100644 packages/tui/extensions/Worklog/actionPalette.ts delete mode 100644 packages/tui/extensions/Worklog/chatPane.ts delete mode 100644 packages/tui/extensions/Worklog/config.ts delete mode 100644 packages/tui/extensions/Worklog/lib/auto-inject.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/auto-inject.ts delete mode 100644 packages/tui/extensions/Worklog/lib/browse-idle.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/browse.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/browse.ts delete mode 100644 packages/tui/extensions/Worklog/lib/grouping.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/grouping.ts delete mode 100644 packages/tui/extensions/Worklog/lib/scheduler.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/scheduler.ts delete mode 100644 packages/tui/extensions/Worklog/lib/settings.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/settings.ts delete mode 100644 packages/tui/extensions/Worklog/lib/shortcuts.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/shortcuts.ts delete mode 100644 packages/tui/extensions/Worklog/lib/smart-selection.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/smart-selection.ts delete mode 100644 packages/tui/extensions/Worklog/lib/tools.test.ts delete mode 100644 packages/tui/extensions/Worklog/lib/tools.ts delete mode 100644 packages/tui/extensions/Worklog/settings-config.test.ts delete mode 100644 packages/tui/extensions/Worklog/settings-config.ts delete mode 100644 packages/tui/extensions/Worklog/settings-persistence.test.ts delete mode 100644 packages/tui/extensions/Worklog/shortcut-config-edge.test.ts delete mode 100644 packages/tui/extensions/Worklog/shortcut-config.test.ts delete mode 100644 packages/tui/extensions/Worklog/shortcut-config.ts delete mode 100644 packages/tui/extensions/Worklog/shortcuts.json delete mode 100644 packages/tui/extensions/Worklog/worklog-helpers.ts delete mode 100644 packages/tui/pi.json delete mode 100644 packages/tui/tests/activity-indicator.test.ts delete mode 100644 packages/tui/tests/browse-auto-refresh.test.ts delete mode 100644 packages/tui/tests/browse-detail-escape-loop.test.ts delete mode 100644 packages/tui/tests/browse-hierarchical-navigation.test.ts delete mode 100644 packages/tui/tests/browse-shortcut-help.test.ts delete mode 100644 packages/tui/tests/browse-total-count.test.ts delete mode 100644 packages/tui/tests/build-selection-widget.test.ts delete mode 100644 packages/tui/tests/extension-label.test.ts delete mode 100644 packages/tui/tests/icons-import-path.test.ts delete mode 100644 packages/tui/tests/runWl-init-detection.test.ts delete mode 100644 packages/tui/tests/worklog-widgets.test.ts delete mode 100644 src/commands/piman.ts delete mode 100644 src/commands/tui.ts delete mode 100644 tests/e2e/agent-flow.test.ts delete mode 100644 tests/e2e/headless-tui.test.ts delete mode 100644 tests/extensions/worklog-browse-extension.test.ts diff --git a/.github/workflows/tui-tests.yml b/.github/workflows/cli-tests.yml similarity index 96% rename from .github/workflows/tui-tests.yml rename to .github/workflows/cli-tests.yml index 7267f9fec..ea5a72811 100644 --- a/.github/workflows/tui-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -4,7 +4,7 @@ on: pull_request: concurrency: - group: tui-tests-${{ github.event.pull_request.number || github.ref }} + group: cli-tests-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/install-and-smoke-test.yml b/.github/workflows/install-and-smoke-test.yml index 4f5c0e326..3adc6849a 100644 --- a/.github/workflows/install-and-smoke-test.yml +++ b/.github/workflows/install-and-smoke-test.yml @@ -42,37 +42,32 @@ jobs: node ./dist/cli.js list -n 1 --json > /dev/null echo "✓ wl CLI works" - # Verify the TUI module loads without errors (using tsx for TypeScript imports) + # Verify the Pi extension entry point and retained agent-side modules load + # without errors (using tsx for TypeScript imports) npx tsx -e " (async () => { - const { ChatPane } = await import('./packages/tui/extensions/Worklog/chatPane.ts'); - const { ActionPalette } = await import('./packages/tui/extensions/Worklog/actionPalette.ts'); + const { createWorklogPiExtension } = await import('./packages/tui/extensions/Worklog/index.ts'); + if (typeof createWorklogPiExtension !== 'function') throw new Error('createWorklogPiExtension is not a function'); const { runWl } = await import('./packages/tui/extensions/wl-integration.ts'); - console.log('✓ TUI modules load successfully'); + const { INSTALL_GUARDRAILS } = await import('./packages/tui/extensions/Worklog/lib/guardrails.ts'); + const { registerSkillPathTool } = await import('./packages/tui/extensions/Worklog/lib/skill-path.ts'); + const { registerLeaseRelease } = await import('./packages/tui/extensions/Worklog/lease-release.ts'); + console.log('✓ Pi extension modules load successfully'); })(); " - # Verify chat pane can send messages + # Verify the extension registers its feature modules (dry run, no pi runtime) npx tsx -e " (async () => { - const { ChatPane } = await import('./packages/tui/extensions/Worklog/chatPane.ts'); - const pane = new ChatPane(); - pane.clear(); - console.log('✓ ChatPane instantiated'); - })(); - " - - # Verify action palette has default actions - npx tsx -e " - (async () => { - const { ChatPane } = await import('./packages/tui/extensions/Worklog/chatPane.ts'); - const { ActionPalette } = await import('./packages/tui/extensions/Worklog/actionPalette.ts'); - const chat = new ChatPane(); - const palette = new ActionPalette(chat); - palette.open(); - const actions = palette.getFilteredActions(); - if (actions.length < 5) throw new Error('Expected at least 5 default actions, got ' + actions.length); - console.log('✓ ActionPalette has ' + actions.length + ' default actions'); + const { createWorklogPiExtension } = await import('./packages/tui/extensions/Worklog/index.ts'); + const registered: string[] = []; + const piStub = { + on: () => {}, registerCommand: () => {}, registerTool: () => {}, + ui: { setStatus: () => {}, setFooter: () => {}, notify: () => {} }, + }; + const register = createWorklogPiExtension(); + register(piStub); + console.log('✓ Extension registers without errors'); })(); " @@ -86,9 +81,3 @@ jobs: - name: Run full test suite run: npm test - - - name: Run E2E agent flow tests - run: npx vitest run tests/e2e/agent-flow.test.ts - - - name: Run E2E headless TUI tests - run: npx vitest run tests/e2e/headless-tui.test.ts diff --git a/CLI.md b/CLI.md index dfd1cb0be..c2d184242 100644 --- a/CLI.md +++ b/CLI.md @@ -1056,46 +1056,6 @@ wl init wl init --project-name "My Project" --prefix PROJ --auto-export yes --auto-sync no ``` -### `tui` [options] - -Launch the terminal UI for browsing and filtering work items. - -Options: - -- `--in-progress` — Show only in-progress items. -- `--all` — Include completed/deleted items in the list. -- `--prefix ` — Override the default prefix. - -Example: - -```sh -wl tui --in-progress -``` - -Example (JSON): - -```sh -wl --json init -``` - -### `piman` | `pi` [options] - -Launch the Pi-based TUI for browsing and managing work items with agent chat and action palette. This is the agent-centric TUI that replaces the legacy Opencode-based interface. All Worklog reads/writes use the wl CLI (no direct database access). - -Options: - -- `--in-progress` — Show only in-progress items. -- `--all` — Include completed/deleted items in the list. -- `--prefix ` — Override the default prefix. -- `--perf` — Enable performance instrumentation. -- `--headless` — Run in headless mode for CI scripting and automated tests. - -Example: - -```sh -wl piman --in-progress -``` - ### `status` [options] Show Worklog system and database status (counts, configuration values). diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md index 5a0b0d053..49a3c872a 100644 --- a/IMPLEMENTATION_SUMMARY.md +++ b/IMPLEMENTATION_SUMMARY.md @@ -55,11 +55,14 @@ All requirements from the problem statement have been successfully implemented: - `GET /health` - Health check ### CLI Tool (`src/cli.ts`, `src/commands/*`) -- Command modules for create, list, show, update, delete, close, search, next, in-progress, recent, comment, dep, reviewed, import/export, sync, github, doctor, re-sort, migrate, unlock, init, status, tui, and plugins +- Command modules for create, list, show, update, delete, close, search, next, in-progress, recent, comment, dep, reviewed, import/export, sync, github, doctor, re-sort, migrate, unlock, init, status, and plugins - Shared helpers for ordering, filtering, tree rendering, and output formatting -### TUI (`src/tui/*`) -- Interactive terminal UI with tree view, details pane, and OpenCode integration +### Pi extension (`packages/tui/extensions/Worklog/`) +- Agent-side Pi plugin: activity indicator, session health/model display, + guardrails, skill-path tool, error recovery (`/retry`), lease release +- The Pi-based TUI browse UI (chat pane, action palette, `/wl` command) was + removed; work item browsing is provided by the Herdr plugin ## File Structure @@ -67,7 +70,6 @@ All requirements from the problem statement have been successfully implemented: Worklog/ ├── src/ │ ├── commands/ # CLI command implementations -│ ├── tui/ # Terminal UI components │ ├── types.ts # Type definitions │ ├── database.ts # Worklog database facade │ ├── persistent-store.ts # SQLite persistence diff --git a/LOCAL_LLM.md b/LOCAL_LLM.md index d50ae9d35..24bcb34ae 100644 --- a/LOCAL_LLM.md +++ b/LOCAL_LLM.md @@ -10,7 +10,9 @@ This document uses two concrete examples so the steps are easy to follow. There' This includes: -- The **TUI OpenCode dialog** today (press `O` in `wl tui`) +- The **TUI OpenCode dialog** (legacy, removed — the Pi-based TUI that + replaced it was also removed; work item browsing is now provided by the + Herdr plugin) - Future **LLM-powered CLI commands** (e.g., issue/work-item management helpers) The goal is to make it easy for agents to leverage **local compute** for tasks that don’t require a massive cloud-hosted model running on a huge GPU, while still allowing optional cloud providers when they’re genuinely needed. @@ -24,7 +26,7 @@ Worklog does **not** call any model provider directly. 1. Worklog starts (or connects to) an LLM provider. By default it does this through an **OpenCode server** (`opencode serve`) 2. OpenCode server talks to a **model provider** (Ollama locally, Foundry Local, cloud providers, or any other provider you configure) -See [docs/opencode-tui.md](docs/opencode-tui.md) for the current TUI integration details. +See the [Herdr plugin](packages/herdr/) for the current work item UI. --- @@ -198,17 +200,14 @@ Document your chosen OpenCode settings here once confirmed: - Base URL: `http://localhost:11434/...` (TBD) - Model: `llama3.1` (example) -### Run Worklog TUI with OpenCode (Ollama) +### Run Worklog with a local LLM (Ollama) + +The `wl` CLI itself does not call a model provider directly. Local models can +be used by agents via standard provider configuration (e.g. Ollama +`http://localhost:11434/`): ```powershell $env:OPENCODE_SERVER_PORT = 51625 -wl tui -``` - -Press `O`, wait for `[OK]`, then try: - -``` -Summarize the selected work item in 3 bullets. ``` --- diff --git a/README.md b/README.md index f1c7a227f..10369238d 100644 --- a/README.md +++ b/README.md @@ -59,26 +59,17 @@ wl github push # add --verbose for a per-item synced list + timing brea wl github import ``` -### Using the TUI +### Pi agent plugin -```bash -wl tui # Launch Pi-based TUI (interactive browse + agent chat) -wl tui --in-progress # Launch Pi-based TUI, show only in-progress items -wl tui --perf # Enable performance instrumentation and write diagnostics artifacts under .worklog/ -wl tui --perf # Launch Pi-based TUI with performance instrumentation -``` - -Press `O` in the TUI to access the Pi agent chat pane. The Pi-based TUI provides natural language chat, an action palette with agent-driven flows, and all work item operations through the `wl` CLI. See [TUI.md](TUI.md) for controls, including quick stage filters (`Alt+T` for `intake_complete`, `Alt+P` for `plan_complete`) that exclude closed items. - -For the Pi-based TUI design checklist, see [docs/ux/design-checklist.md](docs/ux/design-checklist.md). - -For freeze triage and profiling details (including `TUI_CHORD_DEBUG`, `strace`, and artifact locations), see [docs/TUI_PROFILING.md](docs/TUI_PROFILING.md). - -### Install the Pi Worklog browse extension - -The repository includes a Pi extension that adds a Worklog browse flow (`/wl` and `Ctrl+Shift+B`) which lists the next 5 recommended work items (`wl next -n 5`) and previews the selected item in a widget above the editor as selection changes (`title `, `Priority/Stage/Status`, `Risk/Effort`, and the first 7 description lines). The top-level list is root-only: child work items are hidden and only appear under their parent via Tab drill-down (children are reachable through `wl list --parent `). Pressing Enter on a selected item opens a focused scrollable detail view backed by `wl show --format markdown`, with keyboard navigation support for Up/Down, PageUp/PageDown, Space, `g` (top), `G` (bottom), and `Esc` (close). +The repository includes a Pi extension that auto-loads into every pi session +(via a global symlink under `~/.pi/agent/extensions`). It provides the +agent-side capabilities that complement the Herdr plugin: an activity +indicator and session health/model display in the pi footer, guardrails that +protect worklog database files, a `skill_path` tool, automatic error recovery +(`/retry`), and proactive Local Proxy model lease release. Work item browsing +and management is provided by the [Herdr plugin](packages/herdr/). -Install it globally by creating a symlink under `~/.pi/agent/extensions`: +Install the extension globally: ```bash npm run install:pi-extension @@ -141,8 +132,6 @@ You can get a lot of value from using Worklog as a memory for your agents. But y | Document | Description | |----------|-------------| | [docs/opencode-to-pi-migration.md](docs/opencode-to-pi-migration.md) | Migration guide from OpenCode to Pi framework | -| [docs/ux/design-checklist.md](docs/ux/design-checklist.md) | Pi-based TUI design checklist | -| [docs/tui-ci.md](docs/tui-ci.md) | Headless TUI testing for CI | | [docs/migrations.md](docs/migrations.md) | Database migration system | | [docs/prd/sort_order_PRD.md](docs/prd/sort_order_PRD.md) | Sort order product requirements | | [docs/validation/status-stage-inventory.md](docs/validation/status-stage-inventory.md) | Status/stage validation rules | @@ -157,7 +146,6 @@ Step-by-step guides for learning Worklog: | [Your First Work Item](docs/tutorials/01-your-first-work-item.md) | New users | Install, init, create, update, and close work items | | [Team Collaboration](docs/tutorials/02-team-collaboration.md) | Team leads | Git sync, GitHub mirroring, multi-user workflow | | [Building a Plugin](docs/tutorials/03-building-a-plugin.md) | Developers | Plugin API, database access, testing | -| [Using the TUI](docs/tutorials/04-using-the-tui.md) | Any user | Interactive tree view, keyboard shortcuts, Pi agent chat, action palette | | [Planning an Epic](docs/tutorials/05-planning-an-epic.md) | Project leads | Epics, child items, dependencies, wl next | See [docs/tutorials/README.md](docs/tutorials/README.md) for the full tutorial index. @@ -198,7 +186,6 @@ npm run dev # Development mode with auto-reload npm test # Run all tests npm run test:watch # Tests in watch mode npm run test:coverage # Tests with coverage report -npm run test:tui # TUI tests only (CI/headless) ``` ### Vitest Configuration diff --git a/TUI.md b/TUI.md deleted file mode 100644 index ea5abc1e1..000000000 --- a/TUI.md +++ /dev/null @@ -1,80 +0,0 @@ -# Worklog TUI - -The Worklog TUI is a Pi-based interactive terminal UI that provides a unified -agent chat + work item management experience. It is available via the `wl tui` -and `wl piman` commands (they are aliases for each other). - -## Overview - -The TUI launches the [Pi coding agent](https://github.com/earendil-works/pi-coding-agent) -with worklog extensions pre-loaded, providing: - -- **Browse view**: list and select recommended work items with keyboard-driven navigation -- **Detail view**: full work item details, comments, and audit results -- **Shortcut keys**: configurable keyboard shortcuts for common workflows (implement, plan, audit, intake) -- **Agent chat**: natural language interaction for work item management - -## Usage - -```bash -# Launch the TUI -wl tui -wl piman # same as wl tui - -# Show only in-progress items -wl tui --in-progress -wl piman --in-progress - -# Include completed/deleted items -wl tui --all - -# Override the default prefix -wl tui --prefix PREFIX - -# Enable performance instrumentation -wl tui --perf -``` - -## Architecture - -The TUI is implemented as a Pi extension located in `packages/tui/`: - -- `packages/tui/pi.json` — Extension configuration and entry points -- `packages/tui/extensions/index.ts` — Main extension that registers the `/wl` browser command -- `packages/tui/extensions/Worklog/chatPane.ts` — Chat pane for natural language work item management -- `packages/tui/extensions/Worklog/actionPalette.ts` — Keyboard-first action palette -- `packages/tui/extensions/wl-integration.ts` — Integration layer for executing wl CLI commands -- `packages/tui/extensions/Worklog/shortcut-config.ts` — Config-driven keyboard shortcut system -- `packages/tui/extensions/Worklog/shortcuts.json` — Default shortcut definitions - -## Features - -### Browse & Select - -On launch, the TUI shows a list of recommended next work items (root items only — child work items are hidden and appear only under their parent via drill-down). Navigate with -Up/Down arrows, press Enter to see full details, or use shortcut keys: - -- **i** — insert `implement ` into the editor -- **p** — insert `plan ` into the editor -- **n** — insert `intake ` into the editor -- **a** — insert `audit ` into the editor - -### Agent Chat - -Natural language interaction for work item operations. Type commands like: -- "list work items" -- "show WL-123" -- "create a work item: fix login bug" -- "claim next task" - -### Settings - -Press `/wl settings` to open the settings overlay where you can configure: -- Number of items to browse (3, 5, 10, 15, or 20) -- Show/hide icons in the browse list - -## See Also - -- [Pi TUI Extensions README](packages/tui/extensions/README.md) — Details on the - config-driven shortcut system and extension architecture -- `docs/tutorials/04-using-the-tui.md` — Tutorial for getting started with the TUI diff --git a/docs/TUI_PROFILING.md b/docs/TUI_PROFILING.md deleted file mode 100644 index 9f06a7776..000000000 --- a/docs/TUI_PROFILING.md +++ /dev/null @@ -1,126 +0,0 @@ -# TUI Profiling and Freeze Diagnostics - -Use this guide to collect reproducible diagnostics for intermittent TUI keyboard freezes (for example: input pauses for 30–60 seconds and then recovers). - -## Enable profiling - -Profiling is **off by default**. - -### Option A: `--perf` (recommended) - -```bash -npm run cli -- tui --perf -# or -wl tui --perf -``` - -This enables: - -- performance timing metrics (expand/collapse, scroll, etc.) -- keypress diagnostic events -- event-loop lag detection - -### Option B: environment flag - -```bash -TUI_PROFILE=1 npm run cli -- tui -# or -TUI_PROFILE=1 wl tui -``` - -Useful when you want diagnostics without changing command arguments. - -## Optional chord-level debug logging - -```bash -TUI_CHORD_DEBUG=1 TUI_LOG_VERBOSE=1 TUI_LOGFILE=./tui-debug.log npm run cli -- tui --perf -``` - -This records low-level chord activity to the file in `TUI_LOGFILE`. - -Note: `--perf` / `TUI_PROFILE=1` no longer imply verbose file logging by default. This avoids logging overhead on the keypress hot path while still collecting profiling artifacts. - -## Known freeze reproduction path (WSL / slow-mounted filesystem) - -Use this scenario to reproduce key-input stalls observed in WSL/Windows Terminal setups: - -1. Write debug logs to a slow mount (for example `/mnt/c/...` in WSL). -2. Enable high-volume chord/debug logging. -3. Hold navigation keys (`j`, `k`, arrows) for 10–30 seconds. - -Example: - -```bash -TUI_CHORD_DEBUG=1 TUI_LOG_VERBOSE=1 TUI_LOGFILE=/mnt/c/Users//wl-tui-debug.log wl tui --perf -``` - -Expected signal during a bad run: - -- `.worklog/tui-profiling-*.jsonl` contains repeated `event_loop_lag` entries. -- `keypress.handlerDurationMs` rises sharply during lag windows. - -## Root-cause hypothesis and mitigation - -Validated RCA from field traces (Tableau-Card-Engine repro logs): - -1. **Long freeze class (~30s):** main-thread stalls aligned with database refresh lock contention (`scheduleRefreshFromDatabase` and `renderListAndDetail` blocked for ~31s). -2. **Residual sluggish class (~1–2s repeated lag):** frequent file-watch-triggered refreshes caused repeated full tree/detail re-renders even when the dataset did not change. - -Mitigation implemented in this branch: - -- TUI file logging now buffers and flushes asynchronously. -- Logging writes are bounded by an in-memory queue cap to prevent unbounded growth under sustained input. -- TUI mode now uses a shorter SQLite busy timeout to avoid multi-second UI stalls when the DB is contended. -- Watch-refresh path now skips expensive re-render work when the refreshed dataset fingerprint is unchanged (diagnostic event: `refresh_skipped_unchanged`). - -You can tune SQLite busy timeout explicitly: - -```bash -WL_SQLITE_BUSY_TIMEOUT_MS=250 wl tui --perf -``` - -## Output artifacts and default locations - -When profiling is enabled, artifacts are written under your worklog directory (typically `.worklog/`): - -- `.worklog/tui-performance.json` — structured performance metrics -- `.worklog/tui-profiling--.jsonl` — JSONL diagnostics (keypress events, event-loop lag events, startup/shutdown markers) - -If `TUI_LOG_VERBOSE=1` is also enabled: - -- `./tui-prototype.log` by default, or `TUI_LOGFILE=` when provided - -## Collect system-call traces (Linux / WSL) - -Use `strace` while reproducing the freeze: - -```bash -strace -tt -f -o /tmp/wl-tui.strace wl tui --perf -``` - -Then attach these to the work item: - -- `.worklog/tui-performance.json` -- `.worklog/tui-profiling-*.jsonl` -- `tui-debug.log` (if enabled) -- `/tmp/wl-tui.strace` - -## Optional terminal session capture - -```bash -asciinema rec /tmp/wl-tui-freeze.cast -wl tui --perf -# reproduce freeze, then exit and Ctrl-D -``` - -## Interpreting diagnostics quickly - -- `event_loop_lag` records indicate the event loop was delayed beyond `TUI_EVENT_LOOP_LAG_MS` (default: 200ms). -- `keypress` events show key metadata and whether chords consumed the key. -- Long stretches without `keypress` processing, or repeated lag spikes, are strong indicators of blocking work on the main thread. - -You can tune lag sensitivity: - -```bash -TUI_EVENT_LOOP_LAG_MS=100 wl tui --perf -``` diff --git a/docs/icons-design.md b/docs/icons-design.md index b2ba57c26..839a8081b 100644 --- a/docs/icons-design.md +++ b/docs/icons-design.md @@ -159,10 +159,11 @@ fallback** is used instead. See §8 below. Every icon MUST carry an equivalent accessible label so that screen readers and tooling that parses CLI output can identify the icon's meaning. -### 9.1 TUI Output +### 9.1 CLI/Plugin Output -The TUI uses the Pi-based rendering framework which supports accessible labels -natively. Icons can be annotated via the framework's built-in label system. +The retained Pi extension modules (session health footer) and CLI output +use `chalk` for colour. Icons can be annotated via the framework's built-in +label system where supported. ### 9.2 CLI Output @@ -291,109 +292,33 @@ export function iconsEnabled(opts?: { noIcons?: boolean }): boolean; ## 13. Implementation Guide -### 13.1 Pi TUI List Rendering (`packages/tui/extensions/index.ts`) +### 13.1 CLI List Rendering -The Pi TUI browse selection list renders status, stage (or audit-aware icon -for `in_review`), and producer review flag icons before the title in each row. -The third column (previously audit result) now shows the producer review flag -for all stages. The layout is: - -- **Column 1**: Status icon (🔓 open, 🔄 in-progress, ✔️ completed, etc.) -- **Column 2**: Stage icon (💡 idea, 📥 intake, 📋 plan, 🛠️ progress, 🏁 done) - For `in_review` stage, this column becomes audit-aware: - - 🟩 (stale-passed icon) — if the audit is stale but readyToClose === true - (auditedAt <= updatedAt - 60 seconds, but auditResult === true) - - 🔍 (stage icon) — if no audit exists, or the audit is stale - with readyToClose !== true - - ✅ — if a fresh audit exists and readyToClose === true - - ❌ — if a fresh audit exists and readyToClose === false -- **Column 3**: Producer review flag (❌ needs review, ✅ review complete) - Replaces the audit result icon for all stages. -- **Column 4 (optional)**: Epic icon + child count for epic items - -Examples: - -``` -🔄 🛠️ ✅ 🏰(5) Epic feature name ← when icons enabled -[INPR][PROG][PRODUCER_OK][EPIC](5) Epic feature name ← when fallback - -🔄 🔍 ✅ 🏰 Epic feature name ← in_review, no audit -[INPR][REVIEW][PRODUCER_OK][EPIC] Epic feature name ← when fallback - -🔄 🟩 ✅ 🏰 Epic feature name ← in_review, stale audit but passed -[INPR][YES_STALE][PRODUCER_OK][EPIC] Epic feature name ← when fallback - -🔄 ✅ ❌ Regular task ← in_review, fresh audit pass, needs producer review -[INPR][YES][NEEDS_PRODUCER] Regular task ← when fallback -``` - -### Audit Staleness - -The staleness check uses a 60-second buffer to prevent the audit's own -timestamp from falsely appearing as "fresh": - -``` -audit is fresh when: auditedAt > updatedAt - 60000 (milliseconds) -audit is stale when: auditedAt <= updatedAt - 60000 -``` - -When no audit exists or the audit is stale without a pass result -(`auditResult !== true`), column 2 shows the normal `in_review` stage icon -(🔍 / `[REVIEW]`). When the audit is stale but `readyToClose === true`, -column 2 shows the stale-passed icon (🟩 / `[YES_STALE]`). - -The `formatBrowseOption` function prepends the icons before the title. -The icon prefix (status + stage/producer + optional epic icon/child count) -is padded to a fixed visible width via per-list dynamic padding so that -titles start at the same column position across all rows. The padding is -computed as the maximum icon prefix width across all items in the current -list, and each item's prefix is padded to that width with spaces. -See `getIconPrefix()` in the same module for the prefix computation. -The `buildSelectionWidget` preview uses a different format (ID/tags/GH/risk-effort) -without the icon prefix. It shows a single-line summary with risk and effort -icons appended as a final pipe-separated segment at the end: +Icons are prepended before the title in CLI list output: ``` -WL-001 | tags: tui | GH #608 | 🐇 🌱 ← when icons enabled -WL-001 | tags: tui | GH #608 | [S] [MED] ← when fallback +🔓 ✅ Set up CI pipeline ← when icons enabled +[OPEN][PRODUCER_OK] Set up CI pipeline ← when fallback ``` -When effort and/or risk are undefined, the corresponding icon is omitted. -If both are missing, the final segment is omitted entirely. - -### 13.2 TUI List Rendering - -The Pi-based TUI renders list items via the `packages/tui/extensions/` -folder. Icons are prepended before the title in the browse list. -{white-fg}[OPEN]{/white-fg} Set up CI pipeline ← when fallback -``` +> The Pi-based TUI browse list (which rendered status/stage/producer-review/ +> epic icons via `getIconPrefix` in `packages/tui/extensions/Worklog/lib/browse.ts`) +> has been removed — work item browsing is now provided by the Herdr plugin. +> The `dist/icons.ts` functions remain in use by the CLI and the retained +> session-health footer. -The `formatTitleOnlyTUI` helper in `src/commands/helpers.ts` may be extended -or a new wrapper created that injects the icon before the title. +### 13.2 CLI Detail Output -### 13.3 TUI Detail Pane - -File: `src/tui/components/detail.ts`, `src/tui/components/metadata-pane.ts` +File: `src/cli-output.ts`, `src/commands/helpers.ts` (`humanFormatWorkItem`) -The metadata pane already shows `Status:` and `Priority:` lines. These lines -should be updated to include the icon: +The status and priority display lines include the icon: ``` Status: 🟢 [OPEN] Priority: 🔴 [CRIT] ``` -### 13.4 CLI Output - -File: `src/cli-output.ts`, `src/commands/helpers.ts` (`humanFormatWorkItem`) - -The status and priority display lines should include the icon: - -``` -Status: 🟢 Open | Priority: 🔴 Critical -``` - -### 13.5 Tests +### 13.3 Tests Tests should verify: - Icon functions return expected emoji for valid inputs @@ -413,9 +338,9 @@ const useIcons = iconsEnabled({ noIcons: opts.noIcons }); // In a list renderer: const icon = priorityIcon(item.priority, { noIcons: !useIcons }); -const line = `${icon} ${formatTitleOnlyTUI(item)}`; +const line = `${icon} ${item.title}`; -// In a metadata pane: +// In a detail view: const pIcon = priorityIcon(item.priority, { noIcons: !useIcons }); const sIcon = statusIcon(item.status, { noIcons: !useIcons }); lines.push(`Status: ${sIcon} ${item.status}`); @@ -431,8 +356,6 @@ lines.push(`Priority: ${pIcon} ${item.priority}`); | File | Change | |------|--------| | `src/icons.ts` | Core icon module with emoji, fallback, and label functions | -| `src/tui/controller.ts` | Added icon rendering to TUI list rows | -| `src/tui/components/metadata-pane.ts` | Added icon rendering to metadata pane | | `src/commands/helpers.ts` | Added icon formatting to CLI output (summary, concise, normal, full) | | `src/commands/list.ts` | Added `--no-icons` CLI flag | | `src/commands/show.ts` | Added `--no-icons` CLI flag | diff --git a/docs/migrations/dialog-helpers-mapping.md b/docs/migrations/dialog-helpers-mapping.md index f2b7f4c58..5952ec431 100644 --- a/docs/migrations/dialog-helpers-mapping.md +++ b/docs/migrations/dialog-helpers-mapping.md @@ -2,8 +2,9 @@ > **DEPRECATED**: The Blessed TUI (including `src/tui/components/dialogs.ts`) > has been removed from the repository. This migration document is preserved -> for historical reference only. See the `wl tui` command which now delegates -> to the Pi-based TUI (`wl piman`). +> for historical reference only. The Pi-based TUI that succeeded it (launched +> via `wl tui` / `wl piman`) has also been removed — work item browsing and +> management is now provided by the Herdr plugin. This one-page mapping documents how the private helper methods used in src/tui/components/dialogs.ts map to the new exported helpers in @@ -31,4 +32,5 @@ src/tui/components/dialog-helpers.ts. - The extraction was intentionally additive; DialogsComponent retained its private helper implementations to avoid large refactors. - The Blessed TUI was removed in June 2026 and replaced by the Pi-based TUI - (`wl piman` / `wl tui`). + (`wl piman` / `wl tui`). The Pi-based TUI was itself removed later — work + item browsing is now provided by the Herdr plugin. diff --git a/docs/opencode-to-pi-migration.md b/docs/opencode-to-pi-migration.md index 539f80835..d55391869 100644 --- a/docs/opencode-to-pi-migration.md +++ b/docs/opencode-to-pi-migration.md @@ -1,15 +1,19 @@ # Opencode-to-Pi Migration Guide -This guide documents the migration from the legacy OpenCode integration to the new Pi-based agent framework. It is intended for maintainers, reviewers, and anyone working on the TUI codebase. +This guide documents the migration from the legacy OpenCode integration to the Pi-based agent framework. It is intended for maintainers, reviewers, and anyone working on the codebase. + +> **Note:** The Pi-based TUI (chat pane, action palette, `/wl` browse flow) +> has since been removed from the repository — work item browsing and +> management is now provided by the [Herdr plugin](../packages/herdr/). The +> agent-side Pi extension modules (activity indicator, session health, model +> display, guardrails, skill-path tool, error recovery, lease release) +> remain and auto-load into every pi session. ## Overview -The TUI previously relied on an OpenCode client for agent interactions (natural language chat, action palette, and agent-driven flows). This has been replaced with the Pi framework, which provides: +The TUI previously relied on an OpenCode client for agent interactions (natural language chat, action palette, and agent-driven flows). This was replaced with the Pi framework, which provides: -- **PiAdapter** (`packages/tui/extensions/index.ts` (Pi extension)): The core abstraction replacing `OpencodeClient`. Provides a clean interface for agent backend communication. -- **ChatPane** (`packages/tui/extensions/Worklog/chatPane.ts`): A natural language chat interface with keyword-based routing for `wl` commands (list, next, show, create, update, close, search, claim). -- **ActionPalette** (`packages/tui/extensions/Worklog/actionPalette.ts`): A keyboard-first action palette with default actions mapping to `wl` CLI commands. -- **wl CLI Integration** (`packages/tui/extensions/wl-integration.ts`, `src/wl-integration/spawn.ts`): All work item reads/writes now go through the `wl` CLI via `child_process.spawn`, not direct database access. +- **wl CLI Integration** (`packages/tui/extensions/wl-integration.ts`, `src/wl-integration/spawn.ts`): All work item reads/writes go through the `wl` CLI via `child_process.spawn`, not direct database access. ## What Changed @@ -33,10 +37,10 @@ The TUI previously relied on an OpenCode client for agent interactions (natural - `src/tui/controller.ts` — replaced `OpencodeClient` with `PiAdapter`, updated key handlers - `src/tui/constants.ts` — updated key descriptions and references - `packages/tui/extensions/index.ts` (Pi extension) — new PiAdapter implementation -- `packages/tui/extensions/Worklog/chatPane.ts` — new ChatPane component -- `packages/tui/extensions/Worklog/actionPalette.ts` — new ActionPalette component +- `packages/tui/extensions/Worklog/chatPane.ts` — new ChatPane component (removed in the Pi TUI removal) +- `packages/tui/extensions/Worklog/actionPalette.ts` — new ActionPalette component (removed in the Pi TUI removal) - `README.md` — added references to Pi agent features -- `docs/tutorials/04-using-the-tui.md` — updated tutorial with Pi agent chat and action palette +- `docs/tutorials/04-using-the-tui.md` — updated tutorial with Pi agent chat and action palette (removed in the Pi TUI removal) ### Files Retained (for reference) @@ -107,11 +111,10 @@ All 157+ tests should pass. Tests that previously depended on `OpencodeClient` h ### E2E tests -E2E tests for agent-driven flows are in `tests/e2e/agent-flow.test.ts`. They use mocked `child_process.spawn` for CI safety. - -```bash -npx vitest run tests/e2e/agent-flow.test.ts -``` +E2E tests for agent-driven flows lived in `tests/e2e/agent-flow.test.ts` (mocked +`child_process.spawn` for CI safety). That file, together with +`tests/e2e/headless-tui.test.ts`, was removed when the Pi-based TUI was +removed — work item browsing moved to the Herdr plugin (`packages/herdr/`). ## FAQ @@ -133,7 +136,6 @@ A: The PiAdapter uses the standard Pi framework configuration. Check the Pi docu ## Related Documentation -- [Pi TUI Design Checklist](./ux/design-checklist.md) -- [TUI.md](../TUI.md) — TUI controls and usage - [wl CLI Integration API](./wl-integration-api.md) -- [Pi Adapter](../src/tui/pi-adapter.ts) — source code +- [Herdr plugin](../packages/herdr/) — work item browsing and management +- [Pi extension README](../packages/tui/extensions/README.md) — retained agent-side plugin modules diff --git a/docs/tutorials/03-building-a-plugin.md b/docs/tutorials/03-building-a-plugin.md index 96b8eabd6..feb3cba1b 100644 --- a/docs/tutorials/03-building-a-plugin.md +++ b/docs/tutorials/03-building-a-plugin.md @@ -327,6 +327,6 @@ You built a complete Worklog plugin that: ## Next steps -- [Using the TUI](04-using-the-tui.md) -- browse work items interactively +- [Planning and Tracking an Epic](05-planning-an-epic.md) -- plan and track an epic with parent/child relationships - [Plugin Guide](../../PLUGIN_GUIDE.md) -- full plugin API reference with advanced topics - [Example Plugins](../../examples/README.md) -- working plugin examples (stats, bulk-tag, CSV export) diff --git a/docs/tutorials/04-using-the-tui.md b/docs/tutorials/04-using-the-tui.md deleted file mode 100644 index 344a8562e..000000000 --- a/docs/tutorials/04-using-the-tui.md +++ /dev/null @@ -1,235 +0,0 @@ -# Tutorial 4: Using the TUI - -**Target audience:** Any Worklog user who prefers a visual interface -**Time to complete:** 10 minutes -**Prerequisites:** Worklog installed ([Tutorial 1](01-your-first-work-item.md)) with some work items created - -## What you will learn - -By the end of this tutorial you will be able to: - -- Launch and navigate the interactive TUI -- Create, edit, and manage work items visually -- Use keyboard shortcuts for efficient navigation -- Access the built-in Pi agent assistant - -## Step 1: Launch the TUI - -```bash -wl tui -wl piman -``` - -The TUI launches the Pi coding agent with Worklog extensions pre-loaded. -It shows a browse list of recommended next work items. - -> **Note:** The TUI is now Pi-based. Both `wl tui` and `wl piman` launch -> the same interactive browse interface. - -### Filter to in-progress items only - -```bash -wl tui --in-progress -``` - -### Include completed and deleted items - -```bash -wl tui --all -``` - -## Step 2: Navigate the tree - -| Key | Action | -|-----|--------| -| Up / Down | Move selection up or down | -| Right / Enter | Expand a node to show children | -| Left | Collapse a node (or jump to parent) | -| Space | Toggle expand/collapse | -| Mouse click | Select an item | -| Mouse scroll | Scroll the list | - -As you navigate, the details pane on the right updates to show the selected item's full information, including description, comments, timestamps, and metadata. - -Note: The right-hand metadata pane shows a compact, easy-to-scan layout. Risk and Effort are shown on a single line (`Risk/Effort: /`), and a one-line Audit summary (when present) is surfaced as `Audit: — by `. Created/Updated rows are not displayed in the compact pane to reduce noise. - -## Step 3: Manage work items - -The TUI supports common work item operations without leaving the interface: - -| Key | Action | -|-----|--------| -| n | Create a new work item | -| e | Edit the selected item | -| c | Add a comment to the selected item | -| d | Delete the selected item | -| r | Refresh/reload all items | -| / | Search items | -| v | Cycle the needs-producer-review filter (on/off/all) | -| h | Toggle the help menu | - -### Create a work item - -Press `n` to open the creation dialog. Fill in the title, description, and other fields. The new item appears in the tree immediately. - -### Edit an item - -Select an item and press `e`. Modify any field and save. The details pane updates to reflect your changes. - -### Add a comment - -Select an item and press `c`. Type your comment and save. Comments appear in the details pane under the item's existing comments. - -## Step 4: Move and reparent items - -Press `m` on a selected item to enter move mode: - -1. The source item is highlighted with a yellow `[M]` prefix -2. Its descendants are dimmed (they cannot be targets) -3. Navigate to the desired new parent -4. Press `m` or `Enter` to reparent the item under the target -5. Press `m` or `Enter` on the source item itself to unparent it (move to root level) -6. Press `Esc` to cancel - -Other action keys are disabled during move mode to prevent accidental edits. - -## Step 5: Switch between panes - -Use window-management shortcuts to move focus: - -| Key | Action | -|-----|--------| -| Ctrl+W, Ctrl+W | Cycle focus between panes | -| Ctrl+W, h | Focus the list pane | -| Ctrl+W, l | Focus the details pane | -| Ctrl+W, p | Focus the previous pane | - -## Step 6: Use the Pi agent assistant - -Press `O` (capital O) to open the Pi agent assistant dialog. The server starts automatically and a status indicator appears: - -- `[-]` -- Server stopped -- `[~]` -- Server starting -- `[OK] Port: 9999` -- Server running -- `[X]` -- Server error - -### Interact with OpenCode - -| Key | Action | -|-----|--------| -| Type your prompt | Enter your question or instruction | -| Ctrl+S | Send the prompt | -| Enter | Accept autocomplete or add a newline | -| Escape | Close the dialog | - -### Run shell commands - -Prefix your prompt with `!` to run a shell command in the project root: - -``` -! npm test -``` - -The command output streams in the response pane. Press `Ctrl+C` to cancel a running command without closing the prompt. - -### Use slash commands - -Type `/` to see available commands: - -- `/help` -- Get help with OpenCode -- `/create` -- Create a new work item from a description -- `/edit` -- Edit files with AI assistance -- `/test` -- Generate or run tests -- `/fix` -- Fix issues in code - -Example: - -``` -/create Fix the login page redirect when session expires -``` - -This creates a work item with an auto-generated title, description, and appropriate issue type and priority. - -### Navigate OpenCode panes - -When OpenCode is active, the response appears in a bottom pane: - -| Key | Action | -|-----|--------| -| Ctrl+W, k | Focus the response pane | -| Ctrl+W, j | Focus the input pane | -| q or click [x] | Close the response pane | - -## Step 6a: Pi Extension Browse Shortcuts - -When using the Pi agent with the Worklog browse extension (launched via `piman`), you can quickly insert commands into the editor using keyboard shortcuts. These shortcuts are **config-driven** — defined in `packages/tui/extensions/Worklog/shortcuts.json` and dispatched dynamically by the shortcut registry, so they can be extended or customized without editing source code. - -### Browse List View Shortcuts - -In the browse selection list (when you see a list of work items), press one of the following keys to insert a command for the selected item: - -| Key | Command Inserted | Stage filter | -|-----|------------------|-------------| -| `c` | `/intake` (create new item) | — | -| `n` | `/intake ` | `idea` | -| `p` | `/plan ` | `intake_complete` | -| `i` | `/skill:implement ` | `intake_complete`, `plan_complete`, `in_progress` | -| `s` | search | — | -| `r` | producer-review toggle | — | -| `f i` / `f n` / `f p` / `f r` | stage filters (idea / intake / plan / in_review) | — | -| `u p l/m/h/c` | update priority (low/medium/high/critical) | — | -| `u s` | update stage/status | — | -| `u t` | update title | — | -| `x c` / `x d` | close / delete | — | -| `a a` / `a y` / `a r` | audit (automatic / approve / reject) | `in_review` | - -The command text is inserted into the Pi editor (without a trailing newline), allowing you to review or edit it before pressing Enter to submit. Chords (multi-key shortcuts like `u p h`) are entered by pressing each key in sequence. - -### Detail View Shortcuts - -In the detail scrollable view (when viewing a single work item), the same shortcuts work identically: press `i`, `p`, `n`, `c`, `s`, or `r` (plus the `u`, `x`, `a`, and `f` chords) to insert the corresponding command for the currently displayed work item. The detail view also clears its preview widget before closing the modal, giving you a clean editor to work in. - -When viewing details, a shortcut hint line appears at the bottom of the rendered content showing available keys for the current work item's stage (same formatting and filtering as the selection list hints). When a chord leader key (e.g., `u`) is pressed, the hint line updates to show available chord completions. The hint line respects the `showHelpText` setting and can be hidden via `/wl settings`. - -### How It Works - -Each shortcut is defined as a JSON object with: -- `key` (or `chord`): The single-character key or chord sequence (e.g., `"i"` or `["u", "p", "h"]`) -- `command`: The template string to insert (e.g., `/skill:implement `) -- `view`: Which view(s) the shortcut applies to (`"list"`, `"detail"`, or `"both"`) -- `label` / `description`: Human-readable metadata shown in the help-line hints -- `stages` (optional): Restricts the shortcut to items in the listed stages (e.g., audit chords only appear for `in_review` items) - -The `shortcutRegistry` loads `shortcuts.json` at extension init time and dispatches matched shortcuts in both the browse list and detail view handlers. Navigation keys (`Up`, `Down`, `Enter`, `Escape`, `PageUp`, `PageDown`, `G`) remain functional in both views. - -## Step 7: Exit the TUI - -Press `q`, `Esc`, or `Ctrl+C` to quit the TUI. All changes made during the session are saved to the local database. - -## Summary - -| Action | Key | -|--------|-----| -| Launch TUI | `wl tui` | -| Navigate | Arrow keys, Space, Enter | -| Create item | n | -| Edit item | e | -| Add comment | c | -| Delete item | d | -| Search | / | -| Move/reparent | m | -| Pi agent | O | -| Switch panes | Ctrl+W, Ctrl+W | -| Help | h | -| Quit | q / Esc / Ctrl+C | -| Pi extension: implement | `i` (browse view) | -| Pi extension: plan | `p` (browse view) | -| Pi extension: intake | `n` (browse view) | -| Pi extension: create | `c` (browse view) | -| Pi extension: audit | `a` (browse view) | - -## Next steps - -- [Planning and Tracking an Epic](05-planning-an-epic.md) -- organize complex features -- [TUI Reference](../../TUI.md) -- complete TUI documentation -- [Pi TUI Migration Guide](../../docs/opencode-to-pi-migration.md) -- migrating from OpenCode to Pi diff --git a/docs/tutorials/05-planning-an-epic.md b/docs/tutorials/05-planning-an-epic.md index a0bac6b0a..2e32f4f28 100644 --- a/docs/tutorials/05-planning-an-epic.md +++ b/docs/tutorials/05-planning-an-epic.md @@ -260,5 +260,4 @@ wl list --tags "auth" ## Next steps - [Team Collaboration with Git Sync](02-team-collaboration.md) -- share your epic with the team -- [Using the TUI](04-using-the-tui.md) -- visualize epic hierarchy interactively - [CLI Reference](../../CLI.md) -- full command documentation diff --git a/docs/tutorials/README.md b/docs/tutorials/README.md index 4f55b4449..6bd815171 100644 --- a/docs/tutorials/README.md +++ b/docs/tutorials/README.md @@ -9,8 +9,7 @@ Step-by-step guides for learning Worklog. Each tutorial is self-contained and ca | 1 | [Your First Work Item](01-your-first-work-item.md) | New users | 10-15 min | | 2 | [Team Collaboration with Git Sync](02-team-collaboration.md) | Team leads | 15-20 min | | 3 | [Building a CLI Plugin](03-building-a-plugin.md) | Developers | 20-25 min | -| 4 | [Using the TUI](04-using-the-tui.md) | Any user | 10 min | -| 5 | [Planning and Tracking an Epic](05-planning-an-epic.md) | Project leads | 15-20 min | +| 4 | [Planning and Tracking an Epic](05-planning-an-epic.md) | Project leads | 15-20 min | ## Getting started diff --git a/docs/ux/design-checklist.md b/docs/ux/design-checklist.md deleted file mode 100644 index 2fe23acae..000000000 --- a/docs/ux/design-checklist.md +++ /dev/null @@ -1,89 +0,0 @@ -# Pi TUI Design Checklist - -This checklist defines the UI/UX requirements for the Pi-based TUI in the ContextHub project. -Implementors and reviewers should validate against these items before merging changes. - -## 1. Layout & Structure - -- [ ] **Separation of Concerns**: Chat pane and action palette must be visually and functionally separate. -- [ ] **Keyboard-First Navigation**: All primary flows must be operable via keyboard without a mouse. -- [ ] **Responsive Design**: Panes should adapt to terminal size changes (resize events handled gracefully). -- [ ] **Widget Placement**: Widgets (work-item list, details) are placed below the editor, not overlaying it. -- [ ] **Non-Obstructive**: Native chat input and editor remain visible and functional at all times. - -## 2. Keyboard Navigation - -- [ ] **Shortcuts**: Standard keyboard shortcuts must work: - - `Ctrl+/` or `Ctrl+Shift+P` for action palette - - `Esc` to close modals/panels -- [ ] **Tab Order**: Logical tab order across interactive elements. -- [ ] **Focus Management**: Modal dialogs trap focus and restore it on close. - -## 3. Accessibility - -- [ ] **Labels**: All interactive elements have accessible labels. -- [ ] **Color Contrast**: Sufficient contrast between text and background in all themes. -- [ ] **Screen Reader**: Widgets announce state changes via notifications. -- [ ] **Focus Indicators**: Visible focus indicators for keyboard navigation. - -## 4. Chat Pane - -- [ ] **Visibility**: Chat pane is toggleable via a keyboard shortcut. -- [ ] **Message Display**: Agent responses are rendered with markdown support. -- [ ] **Streaming**: Agent responses stream incrementally (no blocking waits). -- [ ] **History**: Conversation history is preserved during a session. -- [ ] **Natural Language**: User can type freeform requests; agent interprets and acts. - -## 5. Action Palette - -- [ ] **Activation**: Opens via keyboard shortcut (configurable). -- [ ] **Filtering**: Typed input narrows results in real-time. -- [ ] **Navigation**: Arrow keys + Enter/Esc for selection and dismissal. -- [ ] **Actions Listed**: All agent-driven actions are discoverable: - - Create work item - - Update work item - - Close work item - - Claim/assign work item - - Run `wl` helper commands (next, list, show, search) - - Start agent conversation - - Trigger higher-level flows (create PR, run tests, delegate) -- [ ] **Confirmation**: State-changing actions require explicit confirmation. - -## 6. Widget System - -- [ ] **Persistence**: Widgets remain visible until explicitly hidden. -- [ ] **Refresh**: Widgets auto-refresh after wl CLI operations. -- [ ] **Details**: Selected item details update when selection changes. -- [ ] **Commands**: `/wl` (worklog browse) is functional. - -## 7. Error Handling - -- [ ] **User-Friendly**: Errors are displayed in a non-crashing, readable format. -- [ ] **Retry**: Transient failures (timeout, network) trigger automatic retry with backoff. -- [ ] **Notifications**: Users are notified of errors via the existing notification system. - -## 8. Theme Support - -- [ ] **Pi Themes**: Respects the current Pi theme configuration. -- [ ] **Custom Styling**: Widget styling uses Pi theme tokens, not hardcoded colors. - -## 9. Performance - -- [ ] **Non-Blocking**: wl CLI calls run off the main UI thread (spawn in child process). -- [ ] **Virtual List**: Long lists are virtualized for performance. -- [ ] **Memory**: No memory leaks in widget lifecycle; proper cleanup on hide/destroy. - -## 10. Pi Best Practices - -- [ ] **Extension Pattern**: TUI follows Pi extension patterns for modularity. -- [ ] **Configuration**: All configurable settings are in config files, not hardcoded. -- [ ] **Plugin Loading**: Plugins load gracefully and handle missing dependencies. -- [ ] **Version Compatibility**: TUI version is reported and compatible with wl CLI version. - -## Implementation Notes - -- Use `ctx.ui.setWidget()` for widget placement below the editor. -- Register shortcuts via `pi.registerShortcut()` for keyboard interactions. -- Use `ctx.ui.notify()` for user notifications on show/hide/selection. -- Avoid embedding theme ANSI codes into cached strings; rebuild on invalidate. -- Document tradeoffs if using `ctx.ui.custom()` or `ctx.ui.setEditorComponent()` for interactive UI. diff --git a/docs/wl-integration.md b/docs/wl-integration.md index 233b89acc..882b5e7ad 100644 --- a/docs/wl-integration.md +++ b/docs/wl-integration.md @@ -2,7 +2,7 @@ ## Overview -The **wl CLI Integration Layer** provides a safe, reliable way for the TUI and Pi agents to execute `wl` commands via subprocess spawn. It handles: +The **wl CLI Integration Layer** provides a safe, reliable way for Pi extension modules and CLI code to execute `wl` commands via subprocess spawn. It handles: - **Command spawning** – wraps `child_process.spawn` with configurable timeout, retries, and working directory. - **JSON parsing** – automatically parses `--json` output with robust recovery from partial/malformed output. @@ -11,12 +11,17 @@ The **wl CLI Integration Layer** provides a safe, reliable way for the TUI and P - **Exponential backoff** – retry delays use exponential backoff with jitter to avoid thundering herd. - **Attempts tracking** – `CommandResult.attempts` reports how many attempts were made. -## Quick Start +## Usage in the Pi extension + +Retained Pi extension modules (`session-health.ts`, `activity-indicator.ts`, +and the Pi extension wrapper `packages/tui/extensions/wl-integration.ts`) use +`runWl` to fetch work item state for the session health footer. The +CLI-level spawn layer lives at `src/wl-integration/spawn.ts`. ```ts import { runWlCommand, runWl, wlEvents } from './packages/tui/extensions/wl-integration.js'; -// Simple usage – TUI wrapper automatically appends --json +// Simple usage – the wrapper automatically appends --json const items = await runWl('list'); // Low-level usage – full control over args and options @@ -79,9 +84,9 @@ shell wrappers, or environment interference) using three strategies: If all strategies fail, a `JSON_PARSE` error is returned and the command is retried (if retries are configured). -## Migration Notes for Existing TUI Code +## Migration Notes -The old pattern in the TUI controller looked like this: +The integration layer replaces raw `child_process.spawn` calls: ```ts // BEFORE: raw spawn @@ -106,7 +111,7 @@ const payload = result.json; /* ... */ ``` -The `runWl` convenience wrapper automatically appends `--json` and throws on error, making it ideal for TUI flows that expect JSON output: +The `runWl` convenience wrapper automatically appends `--json` and throws on error, making it ideal for flows that expect JSON output: ```ts try { diff --git a/package.json b/package.json index 908326dd9..730126adf 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:tui": "bash ./tests/tui-ci-run.sh", "test:timings": "node ./scripts/test-timings.cjs", "benchmark:sort-index": "tsx scripts/benchmark-sort-index.ts", "benchmark:wl-next": "tsx bench/wl-next-perf.ts", diff --git a/packages/tui/extensions/README.md b/packages/tui/extensions/README.md index 08dede1ba..177d5daa5 100644 --- a/packages/tui/extensions/README.md +++ b/packages/tui/extensions/README.md @@ -1,203 +1,25 @@ -# TUI Extensions - -Extension modules for the Worklog TUI and Pi agent integration. - -## Settings - -The extension has five user-configurable settings: - -| Setting | Default | Description | -|---------|---------|-------------| -| `browseItemCount` | `5` | Number of work items shown in the browse list (1–50). Critical and completed/in_review items are always shown regardless of this limit — see [Selection List Behaviour](#selection-list-behaviour) | -| `showIcons` | `true` | Whether to show emoji icons in the browse list and preview widget | -| `showActivityIndicator` | `true` | Whether to show the activity indicator (⏵) in the footer | -| `showHelpText` | `true` | Whether to show the shortcut help text line in the browse selection overlay | -| `autoInjectEnabled` | `true` | Whether to auto-inject relevant work items into the system prompt before each agent turn | - -Settings are stored in Pi's canonical settings files under the `context-hub` -namespace. Settings changed via `/wl settings` are persisted to the project's -`.pi/settings.json`. - -### Project `.pi` Discovery - -The project settings directory is discovered the same way as the `.worklog` -directory (see `resolveWorklogDir()`/`resolvePiDir()` in `src/worklog-paths.ts`): - -1. Walk up from the current working directory toward the git repo root; the - **nearest** directory with a `.pi/settings.json` wins — a local settings - file in the working directory (or a closer ancestor) overrides the repo - root. -2. If no `.pi/settings.json` exists between the working directory and the - repo root, the **git repo root's** `.pi/settings.json` is used. -3. Outside a git repository, the working directory is used as-is. - -This means a project-level `.pi/settings.json` at the repo root is the -canonical settings location: running pi from a subdirectory (e.g. -`packages/herdr/`) loads and persists settings to the repo root unless a -closer `.pi/settings.json` exists. Unlike `.worklog`, worktree isolation is -not applied — `.pi` holds developer configuration, so settings are shared -across the repository. - -### Resolution Order - -Settings are resolved from multiple locations, with later sources overriding -earlier ones: - -| Order | Source | File | -|-------|--------|------| -| 1 | Built-in defaults | `DEFAULT_SETTINGS` (code) | -| 2 | Global settings | `~/.pi/agent/settings.json` → `{ "context-hub": { ... } }` | -| 3 | Project settings | `/.pi/settings.json` → `{ "context-hub": { ... } }` | - -Project settings always win, allowing per-project overrides while individual -team members can set personal defaults globally. `project` is resolved -repo-root aware as described above (nearest `.pi/settings.json` wins, falling -back to the git repo root). - -### Auto-Refresh - -When the browse selection list overlay is open, the item list automatically -refreshes every 5 seconds. This ensures that newly created, updated, or -reassigned work items appear without requiring the user to close and re-open -the browse dialog. - -**Behaviour:** - -- The list re-fetches from the database every 5 seconds using the same - `wl next` command and stage filter as the initial load. -- The currently selected item remains selected after a refresh, matched by - work item ID. If the selected item no longer exists (e.g., was deleted or - filtered out), the selection falls back to the first item. -- The refresh is deferred while a chord shortcut key sequence is in progress - (e.g., after pressing a chord leader like `u`). Once the chord is resolved - or cancelled, normal refresh resumes. -- No visual flash, spinner, or notification is shown — the data updates - silently in-place. -- Auto-refresh is a hardcoded feature (5-second interval) with no - configuration UI. It only applies to the browse list overlay, not - to the detail view. - -**Idle gating (pause-when-idle):** The auto-refresh interval (and its -background `wl sync --if-idle` trigger) pauses when the selection list has -had **no keypresses for 30 seconds** (`IDLE_PAUSE_MS` in -`Worklog/lib/browse.ts`). This stops idle or hidden selection lists from -spawning `wl` subprocesses — each 5s tick previously spawned 4–5 `wl` -processes (~60/min per mounted widget), which with many concurrent pi -sessions caused severe memory and CPU pressure (WL-0MSB1N0HB0007N6N). - -- **Mount counts as interaction** — a freshly opened selection list starts - in the active state and refreshes normally. -- **Resume on keypress** — the first keypress after an idle pause triggers - an immediate refresh and resumes the normal 5s cadence. -- **No settings toggle** — the idle threshold is a named module constant - (30s), always on, fail-open (manual actions and navigation are never - gated). -- While the user actively browses, the cadence is unchanged (5s refresh). - -### Selection List Behaviour - -The default (unfiltered) selection list always shows **all** critical-priority -items and **all** completed/in_review items (the producer-review queue), -regardless of the `browseItemCount` setting: - -- Items with `priority=critical` are always included. -- Items with `status=completed` **and** `stage=in_review` are always included. -- The `browseItemCount` limit applies only to the remaining "other" items. - The number of "other" slots is `browseItemCount − (critical count) − - (completed/in_review count)`, floored at zero. -- When critical + completed/in_review items alone meet or exceed - `browseItemCount`, all of them are shown anyway (no hard cap on the - mandatory set) — the total may exceed the configured count. -- An item that is both critical and completed/in_review counts once - (deduplicated) toward the total. - -Example: with `browseItemCount=15`, 2 critical + 3 completed/in_review + -20 other items → the list shows 2 critical + 3 completed/in_review + the -first 10 others (15 total). If there were 20 completed/in_review items -instead of 3, all 22 mandatory items would be shown (22 > 15). - -The **stage-filtered** views (`/wl idea`, `/wl plan`, …) are unchanged: they -show only items matching the selected stage. - -The "top N of M" title reflects the **actual displayed count** (N), which -may exceed `browseItemCount` when the mandatory set is large. - -### Hierarchical Navigation (Drill into Children) - -The browse selection list supports navigating into child work items -when an item has children. This allows you to drill down through the -work-item hierarchy without leaving the browse dialog. - -**How it works:** - -- When an item in the browse list has children (`childCount > 0`), pressing - **Tab** on that item navigates into its children. All items with children - are visually marked with a child count indicator (e.g., `(3)`), - regardless of their issue type. -- **Enter** on any item (including parents with children) opens the detail - view, as before. -- Pressing **Escape** while viewing children navigates back one level in - the hierarchy. The footer shows a `[esc] back` hint (with `(N levels)` - when nested deeper than one level) while inside a child list. -- You can drill down **arbitrarily deep** through the hierarchy (children - of children of children, etc.) using the same Tab mechanism at each - level. -- When navigating back to a parent level (via Escape), the previously - selected item and list state are restored, so you return to the same - position you left. -- At the root level (no parent context), pressing Enter on an item without - children opens the detail view as before — behavior is unchanged for - non-parent items. - -**Example flow:** - -1. Browse the root list — items with children show `(N)` count indicators. -2. Press Tab on an epic or other item with children → the list updates to - show its child work items. -3. Press Tab on a child that also has children → navigate further down. -4. Press Escape to go back up one level. -5. At root level, pressing Enter on a leaf item opens the detail view. -6. Escape at root level closes the browse overlay. - -**Note:** When navigating within child items, the auto-refresh feature -calls `fetchChildren()` to re-fetch the child items of the current parent -in-place, rather than refreshing the root-level list. This ensures new -children appear, completed children disappear, and re-sorted items are -repositioned — all while staying at the same navigation level. At the -root level, the standard `wl next` refresh is used. - -### `/wl settings` Command - -Open the settings overlay by typing `/wl settings` in the Pi editor. This opens an interactive overlay where you can change settings using the arrow keys and Enter. - -- **Number of items**: Cycle through presets (3, 5, 10, 15, 20). Changes take effect immediately — the next `/wl` browse will use the new count. -- **Show icons**: Toggle between on/off. Changes are applied immediately — the preview widget and browse list reflect the change. -- **Activity indicator**: Toggle the activity indicator (⏵) in the footer on/off. When disabled, the footer line is hidden and no new indicators are shown. Existing indicators are cleared. -- **Help text**: Toggle the shortcut help text line in the browse selection overlay on/off. When disabled, the help line is hidden on the next browse overlay open. -- **Auto-inject items**: Toggle auto-injection of relevant work items before agent turns on/off. When enabled, the extension searches for related work items based on the prompt context and injects them into the system prompt automatically. - -Press `Escape` to close the settings overlay. - -### Settings File Format - -Settings in Pi's settings files are stored under the `context-hub` namespace. -Example `.pi/settings.json`: +# Worklog Pi Extension -```json -{ - "context-hub": { - "browseItemCount": 10, - "showIcons": false, - "showActivityIndicator": true, - "showHelpText": true, - "autoInjectEnabled": true - } -} -``` +Extension modules for the Worklog Pi agent integration. + +This extension auto-loads into every pi session via the global extension +install (`~/.pi/agent/extensions/worklog` → `packages/tui/extensions`). It +registers the agent-side plugin modules that complement the Herdr plugin +(which provides work item browsing and management): -When all settings files are missing or contain no `context-hub` section, -built-in defaults are used (5 items, icons enabled, activity indicator -enabled, help text enabled, auto-inject enabled). +- [Activity Indicator](#activity-indicator) +- [Session Health Footer](#session-health-footer) +- [Model/Provider Display](#modelprovider-display-line-3) +- [Guardrails](#guardrails) +- [Skill-path Tool](#skill-path-tool) +- [Error Recovery Module](#error-recovery-module) (`/retry`) +- [Lease Release](#lease-release) + +> The Pi-based TUI browse UI (chat pane, action palette, `/wl` command, +> `ctrl+shift+b` shortcut, settings overlay, auto-injection, and periodic +> request scheduler) has been removed from the repository — the Herdr plugin +> covers browsing, filtering, detail views, shortcuts, and code-freeze +> awareness. See `packages/herdr/` for that plugin. ## Activity Indicator @@ -210,7 +32,6 @@ the directory path and Git branch info. | Input Type | Example | Indicator Behavior | |------------|---------|-------------------| -| Extension commands (via `/wl` or `Ctrl+Shift+B` shortcut) | `/wl`, `/wl progress` | Shows `⏵ /wl` | | Skills | `/skill:audit WL-123` | Shows `⏵ skill:audit` | | Built-in Pi commands | `/model`, `/settings`, `/new` | Clears the indicator | | Free-form text | `Fix the login bug` | Clears the indicator | @@ -237,8 +58,6 @@ when used outside the Pi TUI. or status usage. - The indicator text is truncated to fit the terminal width with an ellipsis (`…`) for overflow. -- Extension commands registered by the Worklog extension itself (`/wl`, - `Ctrl+Shift+B`) set the indicator directly in their command handlers. - Skills (`/skill:name`) are captured via Pi's `input` event, which fires before skill expansion. - Built-in Pi commands and free-form text clear the indicator via the same @@ -282,7 +101,7 @@ a **three-section layout**. The third line shows the model/provider info and (optionally) an initial prompt preview: ``` -⏵ /wl ← Extension statuses +⏵ /skill:audit WL-123 ← Extension statuses ● Streaming 45s #5 Total: 5m 42s ↑1.2k ↓4.5k 39.1%/128k ← Session health code → openai/gpt-4 │ Fix the bug ← Model + prompt │ │ │ │ │ │ │ │ │ │ @@ -364,6 +183,26 @@ produce errors when used outside the Pi TUI. - Context usage is obtained from `ctx.getContextUsage()` which returns `{ tokens, contextWindow, percent }`. +## Guardrails + +The extension installs **guardrails** that protect worklog data by blocking +direct file writes/edits to protected worklog paths (`.worklog/` database and +JSONL files) and blocking dangerous shell commands that could damage worklog +data. Agents are directed to use `wl` commands instead. See +`docs/guardrails.md` for details. + +Implemented in `Worklog/lib/guardrails.ts` and registered via +`INSTALL_GUARDRAILS(pi, { enabled: true })`. + +## Skill-path Tool + +The extension registers a `skill_path` tool via `pi.registerTool()` that +resolves a skill's installation directory given a skill name (searches +`~/.pi/agent/skills//` and the project's `.pi/skills//`). +Agents use it to locate skill files (e.g., `SKILL.md`) during tasks. + +Implemented in `Worklog/lib/skill-path.ts`. + ## Error Recovery Module The extension includes a built-in automatic error recovery module that replaces the @@ -385,27 +224,23 @@ configuration. ### Configuration -Recovery settings are stored under `context-hub.recovery.*` in Pi's settings files. -Each category can be configured individually: +Recovery behaviour is driven by `DEFAULT_RECOVERY_CONFIG` in +`Worklog/lib/recovery/error-patterns.ts`. Each category can be configured: ```json { - "context-hub": { - "recovery": { - "serverError": { - "enabled": true, - "baseDelayMs": 2000, - "maxDelayMs": 60000 - }, - "timeout": { - "enabled": true, - "baseDelayMs": 2000, - "maxDelayMs": 60000 - }, - "rateLimit": { - "enabled": false - } - } + "serverError": { + "enabled": true, + "baseDelayMs": 2000, + "maxDelayMs": 60000 + }, + "timeout": { + "enabled": true, + "baseDelayMs": 2000, + "maxDelayMs": 60000 + }, + "rateLimit": { + "enabled": false } } ``` @@ -434,353 +269,6 @@ The recovery module is implemented in `Worklog/lib/recovery/` and consists of: The module is auto-registered during extension initialization in `index.ts`. -## Auto-Injection - -The extension automatically injects relevant work items into the system -prompt before each agent turn, providing context without requiring manual -`wl next` or `wl list` calls. - -### How It Works - -When a new agent turn begins, the `before_agent_start` hook triggers the -auto-injection pipeline: - -1. **ID Detection**: The user's prompt text is scanned for work item ID - patterns (e.g., `WL-0MQL0T5TR0060AEH`). All unique IDs are collected. - -2. **ID-Based Scanning** (when IDs are detected in the prompt): - - The referenced work item is fetched via `wl show`. - - Its **description** is scanned for embedded work item IDs. - - Its **comments** are fetched via `wl comment list` and scanned for IDs. - - Its **children** are fetched via `wl list --parent` and included directly. - - Each discovered related work item is fetched via `wl show` and added to - the result set, deduplicated against already-known IDs. - - The originally referenced ID is excluded from the related-items list. - -3. **Keyword Fallback** (only when NO work item ID is detected in the prompt): - A `wl search` is performed using the prompt keywords to find related - items (up to 5 results). This preserves backward compatibility for - prompts like "working on implementation task" that don't reference a - specific work item. - -4. **Formatting**: Found items are formatted as markdown context: - - **Full-detail mode** (≤3 items): Shows ID, title, and inline tags - for priority, status, and stage. - - **Links-only mode** (>3 items): Compact ID + title list. -5. **Injection**: The formatted context is appended to the system prompt - under a `## Relevant Work Items` heading. -6. **Status Indicator**: A status bar notification (e.g., `📋 3 items - auto-injected`) is shown briefly in the footer. - -### What Gets Injected - -**Full-detail mode** (≤3 items): -```markdown -## Relevant Work Items - -- **WL-123**: Fix login bug `high` `open` `in_progress` -- **WL-456**: Add tests `medium` `in_review` -``` - -**Links-only mode** (>3 items): -```markdown -## Relevant Work Items - -- WL-123: Fix login bug -- WL-456: Add tests -``` - -### Configuration - -Auto-injection can be toggled via the `autoInjectEnabled` setting: -- **`/wl settings`** — Toggle the "Auto-inject items" option on/off -- **`.pi/settings.json`** — Set `{ "context-hub": { "autoInjectEnabled": false } }` - -Changes take effect immediately. When disabled, the `before_agent_start` -handler returns without performing any search or injection. - -### Graceful Degradation - -- Missing or invalid work item IDs are silently skipped (no errors surfaced). -- Comment listing or child fetching failures are silently caught — the - handler degrades gracefully to still return the explicitly referenced IDs - and any successfully scanned sources. -- `wl search` keyword fallback failures are silently caught — the handler - returns only the explicitly referenced and discovered related IDs. -- When the prompt contains only IDs (no searchable text), ID-based scanning - still runs — description, comments, and children are scanned for related - work items. -- When no related items are found, the system prompt is left unmodified. -- In non-TUI modes (print, JSON, RPC), the status bar indicator is a no-op - with no errors. - -### Technical Notes - -- Implemented in `Worklog/lib/auto-inject.ts` and registered in `Worklog/index.ts`. -- Uses Pi's `before_agent_start` hook — available in the pi ExtensionAPI. -- The `AUTO_INJECT_STATUS_KEY` (`worklog-auto-inject`) is used for the - status bar indicator to avoid conflicts with other status entries. - -## `/wl` Slash Command — Stage Filtering - -The `/wl` slash command browses work items recommended by the `wl next` algorithm. The number of items shown in the default (unfiltered) view is controlled by the `browseItemCount` setting (default: 5). It also supports an optional stage filter argument. - -When a stage filter is applied (`/wl `), the browse list shows **every open work item** in that stage — there is no `browseItemCount` cap and the `wl next` selection algorithm is not applied. "Open" means `status=open`; blocked, in-progress, and completed items are excluded. Results are ordered by the standard list order (sortIndex), matching the unfiltered browse list. Child items remain hidden in the filtered top-level list and are reachable via Tab drill-down, exactly as in the unfiltered view. - -### Usage - -``` -/wl # Show unfiltered work items (count from settings) -/wl settings # Open the settings overlay -/wl schedule # Manage periodic request schedules (see Periodic Request Scheduler) -/wl idea # Show items in idea stage -/wl intake # Show items in intake_complete stage -/wl plan # Show items in plan_complete stage -/wl progress # Show items in in_progress stage -/wl review # Show items in in_review stage -/wl in_progress # Canonical stage names also work -/wl in_review # Canonical stage names also work -``` - -### Stage Shorthand Aliases - -| Shorthand | Canonical Stage | -|-----------|----------------| -| `intake` | `intake_complete` | -| `plan` | `plan_complete` | -| `progress`| `in_progress` | -| `review` | `in_review` | - -All canonical stage names (`idea`, `in_progress`, `in_review`, `intake_complete`, `plan_complete`) are also recognised directly. - -### Invalid Values - -Typing an unrecognised stage value produces an error notification and falls back to the default unfiltered list without crashing. - -### Autocomplete - -The `/wl` command registers `getArgumentCompletions`, so Pi's editor shows autocomplete suggestions for valid stage values (both shorthand and canonical) and the `settings`/`schedule` commands when typing arguments. - -### Example - -- `/wl progress` — filters to items in `in_progress` stage -- `/wl in_review` — filters to items in `in_review` stage -- `/wl settings` — opens the settings overlay -- `/wl schedule list` — lists all configured periodic request schedules -- `/wl schedule add "0 1 * * *" "Daily audit"` — adds a daily audit schedule -- `/wl` — shows the default unfiltered items (count from settings) -- `/wl ` — whitespace-only arguments are treated as "no arguments" and show unfiltered items - -## Shortcuts - -Source config file: `packages/tui/extensions/Worklog/shortcuts.json` -(installed to `~/.pi/agent/extensions/worklog/Worklog/shortcuts.json`). - -The `shortcuts.json` config file defines a **config-driven shortcut system** that allows keyboard shortcuts in the Pi extension's worklog browse views (list and detail) to be expressed declaratively rather than hardcoded. - -### Schema - -Each shortcut entry is a JSON object. Entries use **either** `key` (single-character immediate dispatch) **or** `chord` (multi-key sequence) — they are mutually exclusive. - -Single-key entry: - -```json -{ - "key": "i", - "command": "implement ", - "view": "both", - "stages": ["intake_complete"], - "label": "implement", - "description": "Run the implement workflow on the selected work item" -} -``` - -Chord entry: - -```json -{ - "chord": ["u", "p"], - "command": "!!wl update --priority ", - "view": "both", - "label": "update priority", - "description": "Update the priority of the selected work item" -} -``` - -| Field | Type | Description | -|-------|------|-------------| -| `key` | string _(mutually exclusive with `chord`)_ | Single character key to trigger the shortcut immediately (e.g., `"i"`, `"p"`). Exactly one of `key` or `chord` must be set. | -| `chord` | string[] _(mutually exclusive with `key`)_ | Two-or-more character sequence that triggers the shortcut. The first key is the **leader** — pressing it enters a pending-chord state and the help line updates to show available completions. The second key (or remaining keys) completes the chord. Example: `["u", "p"]` means press `u` then `p`. | -| `command` | string | Template string to insert into the Pi editor. The placeholder `` is replaced with the selected work item's ID. | -| `view` | string | Which view the shortcut applies to: `"list"` (browse selection only), `"detail"` (detail view only), or `"both"` (both views). | -| `label` | string _(optional)_ | Short display label shown in the browse help line (e.g., `"implement"`, `"plan"`). When provided, overrides the label derived from the command string. Chord entries are displayed as `leader:firstWord...` (e.g., `u:update...`) to keep the help line compact. | -| `description` | string _(optional)_ | One-sentence description of the command for use in help screens (e.g., `"Run the implement workflow on the selected work item"`). | -| `stages` | string[] _(optional)_ | Allow-list of work item stages for which the shortcut is available. When omitted or empty, the shortcut is unconditionally available (backward compatible). The stage comparison is case-sensitive, exact match. | - -### Stage-Based Visibility - -Shortcuts can be made conditional on the selected work item's stage using the optional `stages` field: - -- **With `stages` set**: The shortcut only appears and dispatches when the selected item's stage matches one of the listed values. -- **Without `stages`** (or `stages: []`): The shortcut is always available, preserving backward compatibility. - -This allows contextual shortcuts — for example, showing an **intake** shortcut only for items in the `idea` stage, and an **implement** shortcut only for items in the `intake_complete` stage. - -#### Visibility Rules - -| `stages` value | Behavior | -|----------------|----------| -| `undefined` (omitted) | Shortcut always available | -| `[]` (empty array) | Shortcut always available | -| `["idea"]` | Shortcut only available when item stage is `"idea"` | -| `["idea", "in_progress"]` | Shortcut available when item stage is `"idea"` or `"in_progress"` | - -### Chord Shortcuts - -Chord shortcuts let you dispatch commands with a two-key sequence. Press the **leader** key first — this does not dispatch anything. Instead, the help line updates to show available completions for that leader. Press the second key to complete the chord and dispatch the command. - -#### How Chords Work - -1. Press the leader key (e.g., `u`) — the shortcut does not fire. The help line updates to show available completions for that leader. -2. Press the completion key (e.g., `p`) — the full chord (`u-p`) is dispatched and the command is inserted into the editor. -3. Press `Escape` at any point during chord input to cancel. -4. Press an unrecognised completion key to cancel the pending chord. - -#### Examples - -| Chord | Command | Description | -|-------|---------|-------------| -| `u-p` | `!!wl update --priority ` | Update the priority of the selected work item | -| `u-t` | `!!wl update --title ` | Update the title of the selected work item | -| `f-i` | `/wl idea` | Filter browse list to items in the idea stage | -| `f-n` | `/wl intake` | Filter browse list to items in the intake_complete stage | -| `f-p` | `/wl plan` | Filter browse list to items in the plan_complete stage | -| `f-r` | `/wl review` | Filter browse list to items in the in_review stage | - -#### Chord Help Text - -When a chord leader key is pressed, the help line replaces the normal shortcut hints with the available chord completions for that leader. The pending state shows only the second key and the distinguishing part of the label (the first word is dropped since it's implied by the leader context). - -For example, pressing `u` while the help line is visible would show: -``` -🔗 p:priority t:title -``` - -#### Chord Stage Filtering - -Chord entries respect the same `stages` field as key-based shortcuts. If a chord entry has `stages` set, it only appears in the help line completions and only dispatches when the selected item's stage matches. Chords without a `stages` constraint (or with an empty array) are always available. - -#### Reserved Keys - -The same reserved navigation keys (`g`, `G`, ` `) that cannot be used as shortcut keys also cannot be chord leaders. Any chord entry with a reserved leader key is silently ignored. - -#### Key Differences from Single-Key Shortcuts - -| Aspect | Single-key shortcut | Chord shortcut | -|--------|-------------------|----------------| -| Trigger | Press key once | Press leader key, then completion key | -| Help text | Always visible | Shown after pressing the leader key | -| Cancel | N/A | Press `Escape` or unrecognised key | -| Entry format | `{"key": "i", ...}` | `{"chord": ["u", "p"], ...}` | - -### Current Shortcuts - -| Type | Key(s) | Command | View | Stages | Label | Description | -|------|--------|---------|------|--------|-------|-------------| -| key | `c` | `create ` | both | `["idea"]` | create | Create a new work item with a description and priority template | -| key | `n` | `intake ` | both | `["idea"]` | intake | Create a new work item from the selected item via intake | -| key | `p` | `plan ` | both | `["intake_complete"]` | plan | Run the plan workflow on the selected work item | -| key | `i` | `implement ` | both | `["plan_complete"]` | implement | Run the implement workflow on the selected work item | -| key | `a` | `audit ` | both | (always available) | audit | Run an audit on the selected work item | -| chord | `u-p` | `!!wl update --priority ` | both | (always available) | update priority | Update the priority of the selected work item | -| chord | `u-t` | `!!wl update --title ` | both | (always available) | update title | Update the title of the selected work item | -| chord | `f-i` | `/wl idea` | both | (always available) | filter idea | Filter browse list to items in the idea stage | -| chord | `f-n` | `/wl intake` | both | (always available) | filter intake | Filter browse list to items in the intake_complete stage | -| chord | `f-p` | `/wl plan` | both | (always available) | filter plan | Filter browse list to items in the plan_complete stage | -| chord | `f-r` | `/wl review` | both | (always available) | filter in_review | Filter browse list to items in the in_review stage | - -### Help Text Filtering - -The help text shown in the browse list dynamically filters shortcuts based on the currently selected item's stage. As you navigate between items with different stages, the help text updates to show only applicable shortcuts. Both key-based and chord-based shortcuts are included in the help line. - -For example: -- Selecting an item in the `idea` stage shows `c:create`, `n:intake`, `u:update...`, and `a:audit`. -- Selecting an item in `intake_complete` shows `i:implement`, `p:plan`, `u:update...`, and `a:audit`. -- Selecting an item in `in_progress` shows `u:update...`, and `a:audit`. - -When a chord leader key (e.g. `u`) is pressed, the help line temporarily updates to show only the available chord completions for that leader, prefixed with `🔗`. Each completion is shown as the second key followed by the distinguishing part of the label: - -``` -🔗 p:priority t:title -``` - -### How It Works - -1. **Config loading**: `Worklog/shortcut-config.ts` loads `Worklog/shortcuts.json` at extension initialization and builds a `ShortcutRegistry` in memory. Key-based and chord-based entries are indexed separately for efficient lookup. -2. **Graceful degradation**: If the config file is missing or contains malformed JSON, the registry is empty (no shortcuts) and a warning is logged. Invalid entries (including entries with both `key` and `chord`, or missing required fields) are silently skipped. -3. **Dispatch**: Both the browse list dispatcher (`defaultChooseWorkItem`) and detail view dispatcher (`createScrollableWidget`) check a set of reserved navigation keys before attempting shortcut lookup. If the pressed key is reserved (see [Reserved Navigation Keys](#reserved-navigation-keys)), shortcut lookup is skipped and navigation takes precedence. - - **Single-key shortcuts**: For non-reserved single-character keys, `shortcutRegistry.lookup(key, view)` is called. If a match is found, the command template is substituted (`` → selected item ID) and inserted into the editor. - - **Chord shortcuts**: If no single-key match is found, the registry checks if the key is a chord leader via `shortcutRegistry.getChordByLeader(key, view)`. If chords exist for that leader, the system enters a **pending-chord state** and updates the help line. Pressing a valid completion key triggers `shortcutRegistry.lookupChord([leader, completion], view)`, which dispatches the matching command. -4. **No trailing newline**: The inserted text has no trailing newline, allowing the user to review or edit the command before pressing Enter to submit. - -### Detail View Shortcut Hints - -The detail view overlay displays a shortcut hint line at the bottom of the rendered content (below the work item details). This hint line follows the same formatting and stage-filtering logic as the selection list hints: - -- Available shortcuts whose `view` includes `detail` or `both` are shown, filtered by the selected item's stage. -- When a chord leader key is pressed, the hint line temporarily updates to show available chord completions (same `🔗` prefix as the selection list). -- The hint line respects the `showHelpText` setting — hidden when disabled. -- The hint line is rendered as dim text, truncated to the terminal width, and is not part of the scrollable content. - -### Reserved Navigation Keys - -The following single-character keys are reserved for navigation and **cannot** be used as shortcut keys. Any shortcut entry in `shortcuts.json` with one of these keys will be silently ignored (navigation takes precedence): - -| Key | Navigation Action | View | -|-----|-------------------|------| -| `g` | Scroll to top | detail | -| `G` | Scroll to bottom | detail | -| ` ` (space) | Page down | detail | - -Multi-character navigation keys (e.g., escape sequences for arrow keys, key-id strings like `enter`, `escape`, `up`, `down`) are already excluded from shortcut lookup because the dispatcher only checks single-character keys. - -### Adding a New Shortcut - -#### Key-based Shortcut - -1. Add a new entry to `shortcuts.json` with the desired `key`, `command`, and `view`. -2. Ensure the `key` is not a reserved navigation key (see above). -3. The shortcut is immediately available — no code changes needed. - -Example: - -```json -{ - "key": "c", - "command": "close --reason \"fixed\"", - "view": "detail" -} -``` - -#### Chord-based Shortcut - -1. Add a new entry to `shortcuts.json` with `chord` (an array of 2+ key strings), `command`, and `view`. -2. Ensure the first key in the chord is not a reserved navigation key. -3. Optionally add `label`, `description`, and `stages` fields. -4. The chord shortcut is immediately available — no code changes needed. - -Example: - -```json -{ - "chord": ["u", "p"], - "command": "!!wl update --priority ", - "view": "both", - "label": "update priority", - "description": "Update the priority of the selected work item" -} -``` - ## Lease Release The extension includes a **proactive lease release** module that automatically @@ -816,111 +304,3 @@ releases the previous session's model lease when a new Pi session is created - Results are cached per-extension-lifecycle to avoid repeated filesystem reads. - Registered in `Worklog/index.ts` via `registerLeaseRelease(pi)`. - Tests are in `Worklog/lease-release.test.ts`. - -## Periodic Request Scheduler - -The extension includes a **periodic request scheduler** that automatically submits pi requests on a recurring schedule using cron expressions. This is useful for automating recurring tasks such as hourly audits, daily intake checks, or nightly cleanup while the pi session is running. - -Requests are submitted through pi's normal message flow via `pi.sendUserMessage()`, making both the request and its response fully visible in the session history. - -### How It Works - -1. **Background ticker**: The scheduler runs a background interval (every 30 seconds by default) that checks all configured schedules. -2. **Cron matching**: For each enabled schedule, the current time is checked against the cron expression. If the expression matches the current minute, the request is a candidate for submission. -3. **Idle check**: Before submitting, the scheduler verifies that the pi agent is idle (no active streaming or tool execution) to avoid interrupting active work. If the agent is busy, the scheduled run is skipped for that minute. -4. **Duplicate prevention**: Once a schedule fires, it will not fire again within the same minute, preventing duplicate submissions. -5. **Visibility**: The request is submitted via `pi.sendUserMessage()`, so it appears as a user message in the session log alongside any pi responses. - -### The `/wl schedule` Command - -Manage configured schedules via the `/wl schedule` command. Autocomplete for `schedule` is available when typing `/wl ` in Pi's editor. - -#### Subcommands - -| Subcommand | Description | -|------------|-------------| -| `list` | List all configured schedules with their ID, cron expression, request text, label, and enabled status | -| `add ` | Add a new schedule with a cron expression and pi request text. Optionally add `--label