From 42ad5f991b2f2c15836192cd9aba9706dd5fc10c Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 4 Oct 2026 15:03:14 +0100 Subject: [PATCH 01/27] WL-0MTV67MZU003H7SH: Implementation complete --- docs/dev/pause-when-hidden.md | 34 +++- packages/herdr/README.md | 43 +++-- packages/herdr/src/key-input.test.ts | 76 ++++++++- packages/herdr/src/key-input.ts | 103 ++++++++++- packages/herdr/src/worklist-inflight.test.ts | 171 +++++++++++++++++++ packages/herdr/src/worklist.ts | 48 +++++- src/version.ts | 2 +- 7 files changed, 444 insertions(+), 33 deletions(-) diff --git a/docs/dev/pause-when-hidden.md b/docs/dev/pause-when-hidden.md index 6ee3ed0ea..acb03a6f5 100644 --- a/docs/dev/pause-when-hidden.md +++ b/docs/dev/pause-when-hidden.md @@ -94,13 +94,19 @@ a background `wl sync` / list refresh could fire while the user was typing and replace the render state mid-keystroke. The fix defers that background work while any text-input overlay is open. -- **Shared predicate** — `isInputActive(formState, shipItDialog, - blockedNoticeActive)` in `worklist.ts` returns `true` when - `formState !== null || shipItDialog !== null || blockedNoticeActive`. - It is defined once and exported for unit testing; the scheduler checks it at - the top of both the `refresh` (30s) and `sync` (60s) task callbacks, - alongside the existing `paneGate.visible()` check, and returns early when - true. +- **Shared predicate, enforced on every background path** — + `isInputActive(formState, shipItDialog, blockedNoticeActive)` in + `worklist.ts` returns `true` when `formState !== null || shipItDialog !== null + || blockedNoticeActive`. It is defined once and exported for unit testing. + The scheduler checks it at the top of both the `refresh` (30s) and `sync` + (60s) task callbacks, alongside the existing `paneGate.visible()` check. The + same predicate is also checked **inside** `doRefresh`/`doSync`, so the other + background paths that call them directly — tab-focus resume, the herdr event + subscriber (`pane_focused`), the hidden→visible resume-poll fallback, the + command-completion `onRefresh` hook, and agent-status event re-renders — are + covered too. This is the key hardening over the first cut: gating only the + scheduler ticks left those direct callers able to refresh/re-render during + typing. - **Coverage** — the command-parameter form (`FormState` in `form-dialog.ts`), the Ship It confirmation dialog (`ShipItDialogState` in `ship-it-dialog.ts`), the Ship-mode blocked notice, and `md-note-edit` @@ -117,6 +123,15 @@ while any text-input overlay is open. Before this, `handleInput` only accepted a single character (`key.length === 1`) and silently discarded a multi-character chunk — the residual dropped-keypress bug seen when typing fast. +- **Split bytes: streaming decode (WL-0MTV67MZU003H7SH)** — a raw `data` event + is not guaranteed to end on a key boundary: a multi-byte UTF-8 code point or + an escape sequence can be split across reads. The TUI reads stdin through the + stateful `KeypressDecoder` (`key-input.ts`), which holds the incomplete tail + (`StringDecoder` for UTF-8; the tokeniser for an incomplete CSI sequence) and + completes it from the next chunk. No bytes are dropped, and a split + mouse/arrow/bracketed-paste sequence is not misread as a bare `Esc` that + would cancel the open form. A lone trailing `ESC` is still emitted + immediately as Escape so cancel/submit stays responsive. - **Resume contract: skip, don't coalesce** — ticks that fall inside a typing window are silently dropped; the next regular tick after the overlay closes fires normally. No immediate post-close refresh is queued (avoids refresh @@ -205,7 +220,10 @@ used panes). Before this fix, N idle agents produced dozens of concurrent and the form keeps every keystroke. 4. Type rapidly across a scheduled tick boundary. Expected: **zero dropped characters**. Fast typing can deliver several keystrokes in one stdin - chunk; the shared `splitKeypresses()` tokeniser preserves every one. + chunk; the shared `splitKeypresses()` tokeniser preserves every one. An + escape sequence (arrow / mouse / bracketed paste) split across two `data` + events is reassembled by `KeypressDecoder` and must not insert stray + characters or cancel the form. 5. Close the overlay. Expected: the next scheduled tick refreshes/syncs normally (no queued immediate refresh) and the header returns to its normal state. diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 87e1ef60d..1244a6469 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -1243,26 +1243,39 @@ keypresses mid-keystroke (WL-0MTV67MZU003H7SH). There is no user value in syncing or refreshing the list while a form is active, so the work is deferred until typing finishes. -- **Shared predicate** — `isInputActive(formState, shipItDialog, - blockedNoticeActive)` in `worklist.ts` returns `true` when - `formState !== null || shipItDialog !== null || blockedNoticeActive`. - Both scheduler ticks (the 30s `refresh` and the 60s `sync`) check it - alongside the existing `paneGate.visible()` check and return early when it - is true. The guard is defined once and shared — future text-input overlays - are covered by extending the single predicate, not per-screen copies. +- **Shared predicate, checked on every background path** — + `isInputActive(formState, shipItDialog, blockedNoticeActive)` in + `worklist.ts` returns `true` when `formState !== null || shipItDialog !== null + || blockedNoticeActive`. The 30s `refresh` and 60s `sync` scheduler ticks + check it alongside the existing `paneGate.visible()` check and return early + when it is true. The same predicate is also consulted **inside** + `doRefresh`/`doSync` themselves, so every other background path that calls + them directly is covered too: tab-focus resume, the herdr event subscriber + (`pane_focused`), the hidden→visible resume-poll fallback, the + command-completion `onRefresh` hook, and agent-status event re-renders. The + guard is defined once and shared — future text-input overlays are covered by + extending the single predicate, not per-screen copies. - **All text-input sites covered** — the command-parameter form (`FormState`), the Ship It confirmation dialog (`ShipItDialogState`), the Ship-mode blocked notice, and `md-note-edit` (which opens a `FormState`) are all covered. There is no separate note-edit state to gate. - **Fast typing never drops characters** — a raw stdin `data` event may - carry **several coalesced keystrokes** when the user types quickly (the - PTY batches bytes). Both text-input handlers run every chunk through the - shared `splitKeypresses()` tokeniser (`key-input.ts`) so each key is - applied in order; escape sequences (arrows, Ctrl+Enter, bracketed paste) - stay intact as one token. Previously a multi-character chunk was silently - discarded because only `key.length === 1` was accepted — the residual - "missing keystrokes when typing fast" bug that the tick gate alone could - not fix. + carry **several coalesced keystrokes** when the user types quickly (the PTY + batches bytes). Both text-input handlers run every chunk through the shared + `splitKeypresses()` tokeniser (`key-input.ts`) so each key is applied in + order; escape sequences (arrows, Ctrl+Enter, bracketed paste) stay intact as + one token. Previously a multi-character chunk was silently discarded because + only `key.length === 1` was accepted — the residual "missing keystrokes when + typing fast" bug that the tick gate alone could not fix. +- **Split bytes are reassembled across chunks** — the TUI reads stdin through a + stateful `KeypressDecoder` (`key-input.ts`). Raw TTY input is a byte stream, + not one key per `data` event: a multi-byte UTF-8 code point or an escape + sequence can be split across reads. The decoder buffers the incomplete tail + (`StringDecoder` for UTF-8; the tokeniser for an incomplete CSI sequence) and + completes it from the next chunk, so bytes are never lost and a split + mouse/arrow/bracketed-paste sequence is never misread as a bare `Esc` that + would cancel the open form. A lone trailing `ESC` is still emitted + immediately as the Escape key so cancel/submit stays responsive. - **Resume contract: skip, don't coalesce** — ticks are silently dropped while typing; the next regular tick after the overlay closes fires normally. No queued or immediate post-close refresh is emitted (this diff --git a/packages/herdr/src/key-input.test.ts b/packages/herdr/src/key-input.test.ts index 7b0987c8f..d9e719137 100644 --- a/packages/herdr/src/key-input.test.ts +++ b/packages/herdr/src/key-input.test.ts @@ -10,7 +10,7 @@ */ import { describe, it, expect } from 'vitest'; -import { splitKeypresses } from './key-input.js'; +import { splitKeypresses, splitKeypressesComplete, KeypressDecoder } from './key-input.js'; describe('splitKeypresses', () => { it('returns an empty list for an empty chunk', () => { @@ -80,3 +80,77 @@ describe('splitKeypresses', () => { } }); }); + +describe('splitKeypressesComplete — streaming variant (WL-0MTV67MZU003H7SH)', () => { + it('emits complete keys and withholds an incomplete CSI tail', () => { + expect(splitKeypressesComplete('ab\x1b[')).toEqual({ + keys: ['a', 'b'], + pending: '\x1b[', + }); + expect(splitKeypressesComplete('a\x1b[3')).toEqual({ + keys: ['a'], + pending: '\x1b[3', + }); + }); + + it('returns a complete CSI sequence with no pending tail', () => { + expect(splitKeypressesComplete('\x1b[A')).toEqual({ keys: ['\x1b[A'], pending: '' }); + expect(splitKeypressesComplete('\x1b[200~')).toEqual({ keys: ['\x1b[200~'], pending: '' }); + }); + + it('emits a lone trailing ESC as the Esc key (never withheld)', () => { + expect(splitKeypressesComplete('\x1b')).toEqual({ keys: ['\x1b'], pending: '' }); + expect(splitKeypressesComplete('a\x1b')).toEqual({ keys: ['a', '\x1b'], pending: '' }); + }); +}); + +describe('KeypressDecoder — cross-chunk lossless decoding (WL-0MTV67MZU003H7SH)', () => { + it('splits a coalesced printable chunk into one key per character', () => { + const decoder = new KeypressDecoder(); + expect(decoder.push('hello')).toEqual(['h', 'e', 'l', 'l', 'o']); + expect(decoder.hasPending()).toBe(false); + }); + + it('reassembles a multi-byte UTF-8 character split across two Buffers', () => { + const decoder = new KeypressDecoder(); + // 'é' is 0xC3 0xA9. Splitting mid-sequence would yield U+FFFD if each + // chunk were decoded on its own. + expect(decoder.push(Buffer.from([0xc3]))).toEqual([]); + expect(decoder.push(Buffer.from([0xa9]))).toEqual(['é']); + }); + + it('reassembles an SGR mouse sequence split across chunks instead of cancelling', () => { + const decoder = new KeypressDecoder(); + // A split like this previously delivered a bare ESC first, which the form + // read as Esc and cancelled the whole overlay. + expect(decoder.push('\x1b[<0;10;')).toEqual([]); + expect(decoder.push('5M')).toEqual(['\x1b[<0;10;5M']); + }); + + it('reassembles a bracketed-paste opener split across chunks', () => { + const decoder = new KeypressDecoder(); + expect(decoder.push('\x1b[20')).toEqual([]); + expect(decoder.push('0~')).toEqual(['\x1b[200~']); + }); + + it('emits a lone ESC immediately (Escape stays responsive)', () => { + const decoder = new KeypressDecoder(); + expect(decoder.push('\x1b')).toEqual(['\x1b']); + expect(decoder.hasPending()).toBe(false); + }); + + it('flush() force-emits a stuck incomplete escape as literal keys', () => { + const decoder = new KeypressDecoder(); + expect(decoder.push('\x1b[')).toEqual([]); + expect(decoder.hasPending()).toBe(true); + expect(decoder.flush()).toEqual(['\x1b[']); + expect(decoder.hasPending()).toBe(false); + }); + + it('never drops bytes: held plus completed tokens reconstruct the input', () => { + const decoder = new KeypressDecoder(); + const first = decoder.push('ab\x1b['); + const second = decoder.push('Ac'); + expect([...first, ...second].join('')).toBe('ab\x1b[Ac'); + }); +}); diff --git a/packages/herdr/src/key-input.ts b/packages/herdr/src/key-input.ts index 4fa06ffd8..61558d38b 100644 --- a/packages/herdr/src/key-input.ts +++ b/packages/herdr/src/key-input.ts @@ -19,6 +19,8 @@ * splitting. */ +import { StringDecoder } from 'node:string_decoder'; + /** * Split a raw stdin chunk into individual keypress tokens. * @@ -41,6 +43,44 @@ * @returns Ordered key tokens; empty only when `chunk` is empty. */ export function splitKeypresses(chunk: string): string[] { + return tokeniseKeys(chunk, false).keys; +} + +/** + * Streaming variant of {@link splitKeypresses} used by {@link KeypressDecoder}. + * + * Unlike the pure splitter, a trailing incomplete CSI sequence is returned as + * `pending` (not as a token) so a stateful caller can prepend it to the next + * chunk and complete the escape sequence losslessly. A trailing lone ESC is + * still emitted immediately as the Esc key — see {@link tokeniseKeys}. + * + * @param chunk - Text to tokenise (usually a buffer of committed bytes). + * @returns The complete key tokens plus any withheld incomplete tail. + */ +export function splitKeypressesComplete( + chunk: string, +): { keys: string[]; pending: string } { + return tokeniseKeys(chunk, true); +} + +/** + * Shared tokeniser behind {@link splitKeypresses} and + * {@link splitKeypressesComplete}. + * + * `holdIncomplete` controls what happens to a trailing incomplete CSI + * sequence: `false` keeps it as one remainder token (every byte is emitted); + * `true` withholds it as `pending` so a streaming caller can complete it from + * the next chunk. + * + * A lone trailing ESC is always emitted as the Esc key immediately — a real + * Escape must cancel/submit without waiting for the next chunk. (A genuine + * escape sequence split exactly after its ESC introducer is the one form this + * cannot recover; terminals write CSI sequences atomically, so it is rare.) + */ +function tokeniseKeys( + chunk: string, + holdIncomplete: boolean, +): { keys: string[]; pending: string } { const keys: string[] = []; let i = 0; while (i < chunk.length) { @@ -55,7 +95,7 @@ export function splitKeypresses(chunk: string): string[] { } const next = chunk[i + 1]; - // Lone trailing ESC — the Esc key itself. + // Lone trailing ESC — the Esc key itself, emitted immediately. if (next === undefined) { keys.push(ch); i += 1; @@ -81,8 +121,11 @@ export function splitKeypresses(chunk: string): string[] { continue; } } - // Incomplete CSI (split across reads): keep the remainder whole so an - // owning parser can buffer it — never drop bytes. + // Incomplete CSI (split across reads). The streaming decoder withholds + // it so the next chunk can complete it (mouse / bracketed-paste / arrow + // sequences split across `data` events); the pure splitter keeps the + // remainder whole so no bytes are dropped. + if (holdIncomplete) return { keys, pending: chunk.slice(i) }; keys.push(chunk.slice(i)); break; } @@ -98,5 +141,57 @@ export function splitKeypresses(chunk: string): string[] { keys.push(chunk.slice(i, i + 2)); i += 2; } - return keys; + return { keys, pending: '' }; +} + +/** + * Stateful stdin decoder for the TUI event loop (WL-0MTV67MZU003H7SH). + * + * Raw TTY input is a byte stream, not one key per `data` event: a multi-byte + * UTF-8 code point or an escape sequence can be split across reads, and + * several keystrokes can be coalesced into one chunk. Decoding each chunk in + * isolation loses bytes (a split multi-byte character becomes U+FFFD) and + * mis-delivers a split escape sequence — a chunk ending in the ESC introducer + * would be read as the Esc key and cancel the open form. + * + * `KeypressDecoder` keeps the incomplete tail in a buffer (`StringDecoder` + * holds partial UTF-8 code points; the tokeniser holds an incomplete CSI + * sequence) and completes it from the next chunk. Held bytes are never + * dropped. A trailing lone ESC is still emitted immediately as the Esc key so + * Escape stays responsive. + */ +export class KeypressDecoder { + private readonly utf8 = new StringDecoder('utf8'); + private pending = ''; + + /** + * Feed a raw chunk and return the complete key tokens it completed. + * + * @param chunk - Raw stdin chunk (Buffer) or already-decoded text (string). + * @returns Complete key tokens; empty when the chunk only formed a prefix of + * a held escape sequence (or was itself empty). + */ + push(chunk: Buffer | string): string[] { + const text = typeof chunk === 'string' ? chunk : this.utf8.write(chunk); + if (text.length === 0 && this.pending.length === 0) return []; + const { keys, pending } = splitKeypressesComplete(this.pending + text); + this.pending = pending; + return keys; + } + + /** + * Force-emit any held remainder as keys — e.g. a stray incomplete CSI + * sequence that will never be completed. Returns `[]` when nothing is held. + */ + flush(): string[] { + if (this.pending.length === 0) return []; + const keys = splitKeypresses(this.pending); + this.pending = ''; + return keys; + } + + /** Whether a partial escape sequence is currently held for the next chunk. */ + hasPending(): boolean { + return this.pending.length > 0; + } } diff --git a/packages/herdr/src/worklist-inflight.test.ts b/packages/herdr/src/worklist-inflight.test.ts index ca171d27d..f5d2ac2f1 100644 --- a/packages/herdr/src/worklist-inflight.test.ts +++ b/packages/herdr/src/worklist-inflight.test.ts @@ -44,6 +44,8 @@ vi.mock('./notify.js', () => ({ import { runWorklistTui } from './worklist.js'; import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; import type { WorkItem } from './fetcher.js'; +import { runSync } from './auto-sync.js'; +import { loadShortcutConfig } from './shortcut-config.js'; // --------------------------------------------------------------------------- // Fake stdin/stdout harness (same pattern as worklist-visibility.test.ts) @@ -316,3 +318,172 @@ describe('worklist doRefresh trailing/coalescing refresh (WL-0MTIB7JAN004MQ8N)', await quit(p); }); }); + +// --------------------------------------------------------------------------- +// Typing gate on ALL background paths (WL-0MTV67MZU003H7SH) +// --------------------------------------------------------------------------- +// The scheduler tick gate alone was not enough: focus-resume, the event +// subscriber, the resume-poll fallback, and the command-completion hook all +// call `doRefresh`/`doSync` directly. The gate now lives INSIDE those two +// functions, so every background path pauses while a text-input overlay is +// open and resumes afterwards. + +describe('worklist typing gate on background refresh/sync (WL-0MTV67MZU003H7SH)', () => { + /** Auto-refresh cadence used by the scheduler in these tests. */ + const INTERVAL = 30_000; + + it('pauses auto-refresh while a text-input overlay is open and resumes after close', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock(true) as any); + + const fetcher = vi.fn(async (): Promise => []); + const p = runWorklistTui(fetcher, [], loadShortcutConfig(), { + autoRefresh: true, + refreshIntervalMs: INTERVAL, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + fetcher.mockClear(); + + // Open the lower-case `s` Search form (unknown → FormState). + dataHandler?.(Buffer.from('s')); + await vi.advanceTimersByTimeAsync(0); + + // While the form is open, three refresh intervals must produce ZERO + // fetcher (wl) invocations. + await vi.advanceTimersByTimeAsync(INTERVAL * 3); + expect(fetcher).not.toHaveBeenCalled(); + + // Close the form — the next tick refreshes again (resume within one + // interval). + dataHandler?.(Buffer.from('\x1b')); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(INTERVAL); + expect(fetcher).toHaveBeenCalled(); + + await quit(p); + }); + + it('pauses auto-sync while a text-input overlay is open and resumes after close', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock(true) as any); + const runSyncMock = vi.mocked(runSync); + + const p = runWorklistTui(async () => [], [], loadShortcutConfig(), { + autoRefresh: false, + autoSync: true, + syncIntervalMs: 60_000, + // Disable the DB-change/heartbeat skip so the sync action runs whenever + // the typing gate allows it (the gate is what this test exercises). + maxSyncStalenessMs: 0, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + runSyncMock.mockClear(); // drop the fireImmediately start-up sync + + dataHandler?.(Buffer.from('s')); + await vi.advanceTimersByTimeAsync(0); + + // Two sync intervals while the form is open must spawn ZERO `wl sync`. + await vi.advanceTimersByTimeAsync(120_000); + expect(runSyncMock).not.toHaveBeenCalled(); + + dataHandler?.(Buffer.from('\x1b')); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(60_000); + expect(runSyncMock).toHaveBeenCalled(); + + await quit(p); + }); +}); + +// --------------------------------------------------------------------------- +// Cross-chunk escape reassembly through the real stdin listener +// (WL-0MTV67MZU003H7SH) +// --------------------------------------------------------------------------- +// The form previously decoded each raw `data` event on its own, so an escape +// sequence split across events could leak its tail into the typed text (or, if +// split after the bare ESC introducer, cancel the form). The streaming decoder +// reassembles it. + +describe('worklist stdin decoder — split escape sequences (WL-0MTV67MZU003H7SH)', () => { + /** Strip ANSI SGR sequences for plain-text assertions. */ + const plain = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); + + /** The most recent full form render. */ + const formRender = (): string => plain(writes[writes.length - 1] ?? ''); + + async function openSearchForm(): Promise { + dataHandler?.(Buffer.from('s')); + await vi.advanceTimersByTimeAsync(0); + expect(formRender()).toContain('Command Input'); + } + + it('ignores a split CSI arrow instead of typing its tail characters', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock(true) as any); + + const p = runWorklistTui(async () => [], [], loadShortcutConfig(), { + autoRefresh: false, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + await openSearchForm(); + + // 'ab', then ↑ split into ESC [ + A, then 'cd'. Without reassembly the + // 'A' would be inserted as a literal character. + dataHandler?.(Buffer.from('ab')); + await vi.advanceTimersByTimeAsync(0); + dataHandler?.(Buffer.from('\x1b[')); + await vi.advanceTimersByTimeAsync(0); + dataHandler?.(Buffer.from('A')); + await vi.advanceTimersByTimeAsync(0); + dataHandler?.(Buffer.from('cd')); + await vi.advanceTimersByTimeAsync(0); + + const out = formRender(); + expect(out).toContain('abcd'); + expect(out).not.toContain('abAcd'); + // The form is still open — a split escape must not cancel it. + expect(out).toContain('Command Input'); + + dataHandler?.(Buffer.from('\x1b')); // close the form + await vi.advanceTimersByTimeAsync(0); + await quit(p); + }); + + it('ignores an SGR mouse report split across chunks (no stray digits)', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock(true) as any); + + const p = runWorklistTui(async () => [], [], loadShortcutConfig(), { + autoRefresh: false, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + await openSearchForm(); + + dataHandler?.(Buffer.from('xy')); + await vi.advanceTimersByTimeAsync(0); + // SGR mouse press split before the final 'M'. + dataHandler?.(Buffer.from('\x1b[<0;10;')); + await vi.advanceTimersByTimeAsync(0); + dataHandler?.(Buffer.from('5M')); + await vi.advanceTimersByTimeAsync(0); + + const out = formRender(); + expect(out).toContain('xy'); + expect(out).not.toContain('xy5M'); + + dataHandler?.(Buffer.from('\x1b')); // close the form + await vi.advanceTimersByTimeAsync(0); + await quit(p); + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index a1f11b3de..ae060bdb6 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -67,6 +67,7 @@ import { } from './form-dialog.js'; import { readFromClipboard, writeToClipboard } from './clipboard.js'; import { ShipItDialogState, overlayShipItDialog } from './ship-it-dialog.js'; +import { KeypressDecoder } from './key-input.js'; import { extractFilePaths } from './grouping.js'; import { renderMarkdown, renderMarkdownViewer } from './md-viewer.js'; import { @@ -4874,6 +4875,17 @@ export async function runWorklistTui( */ let blockedNotice: string | null = null; + /** + * Single source of truth for "the user is typing" (WL-0MTV67MZU003H7SH): + * true while any text-input overlay (`formState`, `shipItDialog`, or the + * modal `blockedNotice`) is open. Background refresh/sync paths consult this + * so no re-render or `wl` spawn contends with typing — whichever path + * triggers the work (scheduler tick, focus-resume, event subscriber, or the + * command-completion hook). See {@link isInputActive}. + */ + const inputActive = (): boolean => + isInputActive(formState, shipItDialog, blockedNotice !== null); + /** * Re-read the code-freeze marker tri-state. Fail-open for browsing: an * ambiguous marker keeps `codeFreezeActive` false (browsing and shortcut @@ -4978,6 +4990,10 @@ export async function runWorklistTui( agentEventRenderPending = true; Promise.resolve().then(() => { agentEventRenderPending = false; + // Typing gate (WL-0MTV67MZU003H7SH): do not re-render the TUI (and its + // open overlay) while a text-input overlay is active. Icons catch up on + // the next non-typing render/refresh. + if (inputActive()) return; try { if (agentTracker) { mergeAgentStatesCached(state.items, agentTracker); @@ -5154,6 +5170,13 @@ export async function runWorklistTui( * Fetch and apply updated items, with optional notification. */ const doRefresh = async (showNotification = false): Promise => { + // Typing gate (WL-0MTV67MZU003H7SH): never refresh while a text-input + // overlay is open — a background re-render would contend with the typing + // event loop. Skip silently (not coalesced); the next scheduler tick after + // the overlay closes refreshes normally. Centralised here so it covers ALL + // callers (scheduler tick, focus-resume, event subscriber, and the + // command-completion hook), not just the scheduler tick. + if (inputActive()) return; // Single-flight guard with trailing/coalescing: if a refresh is already // in-flight, record the request as pending and return. The pending flag // is checked in the `finally` block so a trailing refresh runs after the @@ -5312,6 +5335,10 @@ export async function runWorklistTui( // binding was removed (WL-0MSGG5N5Z0074TLY) — doSync is now reached only // from the auto-sync timer path. const doSync = async (ifIdle = false, heartbeatTtlMs?: number): Promise => { + // Typing gate (WL-0MTV67MZU003H7SH): never spawn `wl sync` while a + // text-input overlay is open. Skip silently; the next tick after the + // overlay closes syncs normally. + if (inputActive()) return; const outcome = await runSync(getWorklogDir(), { ifIdle, ...(heartbeatTtlMs !== undefined ? { heartbeat: true, heartbeatTtlMs } : {}), @@ -5443,8 +5470,7 @@ export async function runWorklistTui( const dispatchMouse = (key: string): boolean => handleMouseInput(state, key, termSize, clickState); - const onData = async (chunk: Buffer): Promise => { - const key = chunk.toString(); + const handleKeyInput = async (key: string): Promise => { // Alt+m toggle shortcut (WL-0MT0AP2LR000JFWN): always available, // even in modal states. Toggles mouse tracking on/off so the user @@ -6048,6 +6074,20 @@ export async function runWorklistTui( render(); }; + // ── Streaming stdin decode (WL-0MTV67MZU003H7SH) ────────────────── + // Raw TTY input is a byte stream: a multi-byte UTF-8 code point or a + // mouse/paste/arrow escape sequence can be split across `data` events while + // typing fast. `KeypressDecoder` buffers the incomplete tail and completes + // it from the next chunk (never dropping bytes, never misreading a split + // escape as the Esc key). Complete keys are dispatched through the SAME + // handler as before, one decoded chunk at a time. + const keyDecoder = new KeypressDecoder(); + const onData = (chunk: Buffer): void => { + const keys = keyDecoder.push(chunk); + if (keys.length === 0) return; + void handleKeyInput(keys.join('')); + }; + let resolve: (value: WorkItem | undefined) => void; const promise = new Promise((res) => { resolve = res; @@ -6353,7 +6393,7 @@ export async function runWorklistTui( intervalMs: opts.refreshIntervalMs, singleFlight: true, run: createGatedTick({ - isInputActive: () => isInputActive(formState, shipItDialog, blockedNotice !== null), + isInputActive: inputActive, isVisible: () => paneGate.visible(), onHidden: () => { panePaused = true; @@ -6390,7 +6430,7 @@ export async function runWorklistTui( intervalMs: opts.syncIntervalMs, fireImmediately: true, run: createGatedTick({ - isInputActive: () => isInputActive(formState, shipItDialog, blockedNotice !== null), + isInputActive: inputActive, isVisible: () => paneGate.visible(), onHidden: () => { panePaused = true; diff --git a/src/version.ts b/src/version.ts index e47cbe5bb..dacac41e5 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ // Auto-generated; do not edit. -export const WORKLOG_VERSION = '1.0.17'; +export const WORKLOG_VERSION = '1.0.18'; From 779a578b80366f492c221d3e76a857d5e5afd6b3 Mon Sep 17 00:00:00 2001 From: Sorra the Orc <250240+SorraTheOrc@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:41:55 +0100 Subject: [PATCH 02/27] OSL-0MUTPC7SF0011Y54: normalize worklog-root-relative podcast Key Files paths for the TTS chord resolvePodcastTarget's branch now strips the wiki-root prefix (.llm-wiki/wiki/) from a worklog-root-relative .podcast.md Key File, so the t TTS chord resolves the canonical episode Key Files form to the wiki-dir-relative podcast/... path wiki-tts-generate expects. Bare /<title>.podcast.md (legacy podcast-dir-relative) and already wiki-relative paths keep their existing handling. - worklist.ts: toWikiRelativePodcastPath() helper + hasScript branch - worklist.test.ts: regression tests for the canonical/./-prefixed forms, the real Ep 10 path, and the verbatim w-r/w-b raw form - README.md: marker contract table updated --- packages/herdr/README.md | 2 +- packages/herdr/src/worklist.test.ts | 50 +++++++++++++++++++++++++++++ packages/herdr/src/worklist.ts | 45 ++++++++++++++++++++++---- 3 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 87e1ef60d..1d0255b89 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -1836,7 +1836,7 @@ modal input form: | `<podcast-target>` | `w s` write-script sub-chord: stage `intake_complete` (sourced) → `--doc <first .md> --force-single`; otherwise with open editor-note children → `--rewrite <first .podcast.md>`; otherwise a belt-and-braces error is shown and nothing dispatches (never authors a duplicate) | `/skill:wiki-podcast-script <podcast-target>` | | `<podcast-review>` | `w r` write-review sub-chord: first `.podcast.md` Key File in raw form (runs the 6 reviews with `--review`; belt-and-braces error when no script exists yet) | `/skill:wiki-podcast-script --review <podcast-review>` | | `<podcast-both>` | `w b` write-both sub-chord: first `.podcast.md` Key File in raw form (runs reviews + rewrite in one pass with `--review-rewrite`, 7 LLM calls; belt-and-braces error when no script exists yet) | `/skill:wiki-podcast-script --review-rewrite <podcast-both>` | -| `<podcast-script>` | `t` TTS chord: first `.podcast.md` Key File, normalized to the wiki-dir-relative `podcast/...` path the TTS skill expects (errors when no script exists yet) | `/skill:wiki-tts-generate --podcast-file <podcast-script>` | +| `<podcast-script>` | `t` TTS chord: first `.podcast.md` Key File, normalized to the wiki-dir-relative `podcast/...` path the TTS skill expects (the canonical worklog-root-relative `.llm-wiki/wiki/podcast/...` form is stripped of its wiki-root prefix; errors when no script exists yet) | `/skill:wiki-tts-generate --podcast-file <podcast-script>` | All markers require the chord entry to carry `work_item_types: ["podcast"]` so they are only visible on podcast-typed items (see [Shortcut filtering by diff --git a/packages/herdr/src/worklist.test.ts b/packages/herdr/src/worklist.test.ts index fd940d5cf..b3a2a6c2a 100644 --- a/packages/herdr/src/worklist.test.ts +++ b/packages/herdr/src/worklist.test.ts @@ -2413,6 +2413,41 @@ describe('resolvePodcastTarget — podcast-progression dispatch', () => { expect(result).toEqual({ command: '/skill:wiki-tts-generate --podcast-file wiki/podcast/foo/foo.podcast.md' }); }); + // OSL-0MUTPC7SF0011Y54: the canonical episode Key Files form records the + // script worklog-root-relative (`.llm-wiki/wiki/podcast/...`), which the + // `w`-chords resolve directly and the `t` chord must normalize to the + // wiki-dir-relative `podcast/...` form the TTS skill expects. + it('strips the worklog-root wiki prefix for <podcast-script> (canonical Key Files form)', async () => { + const worklogRelativeItem: WorkItem = { + ...draftedItem, + description: '## Key Files:\n- .llm-wiki/wiki/podcast/foo/foo.podcast.md\n', + }; + const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file <podcast-script>', worklogRelativeItem); + expect(result).toEqual({ command: '/skill:wiki-tts-generate --podcast-file podcast/foo/foo.podcast.md' }); + }); + + it('strips a leading ./ from the worklog-root-relative <podcast-script> form', async () => { + const worklogRelativeItem: WorkItem = { + ...draftedItem, + description: '## Key Files:\n- ./.llm-wiki/wiki/podcast/foo/foo.podcast.md\n', + }; + const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file <podcast-script>', worklogRelativeItem); + expect(result).toEqual({ command: '/skill:wiki-tts-generate --podcast-file podcast/foo/foo.podcast.md' }); + }); + + it('normalizes the real Ep 10 worklog-root-relative Key File to a wiki-dir-relative path', async () => { + const ep10: WorkItem = { + ...draftedItem, + description: + '## Key Files:\n- .llm-wiki/wiki/podcast/tableau-card-engine-ep10-the-complex-game-main-street/tableau-card-engine-ep10-the-complex-game-main-street.podcast.md\n', + }; + const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file <podcast-script>', ep10); + expect(result).toEqual({ + command: + '/skill:wiki-tts-generate --podcast-file podcast/tableau-card-engine-ep10-the-complex-game-main-street/tableau-card-engine-ep10-the-complex-game-main-street.podcast.md', + }); + }); + it('errors on <podcast-script> when no script exists', async () => { const result = await resolvePodcastTarget('/skill:wiki-tts-generate --podcast-file <podcast-script>', sourcedItem); expect(result.error).toMatch(/no podcast script/i); @@ -2452,6 +2487,21 @@ describe('resolvePodcastTarget — podcast-progression dispatch', () => { expect(result).toEqual({ command: '/skill:wiki-podcast-script --review wiki/podcast/foo/foo.podcast.md' }); }); + it('passes the worklog-root-relative Key File through verbatim for w-r/w-b (raw form)', async () => { + const worklogRelativeItem: WorkItem = { + ...draftedItem, + description: '## Key Files:\n- .llm-wiki/wiki/podcast/foo/foo.podcast.md\n', + }; + const review = await resolvePodcastTarget('/skill:wiki-podcast-script --review <podcast-review>', worklogRelativeItem); + expect(review).toEqual({ + command: '/skill:wiki-podcast-script --review .llm-wiki/wiki/podcast/foo/foo.podcast.md', + }); + const both = await resolvePodcastTarget('/skill:wiki-podcast-script --review-rewrite <podcast-both>', worklogRelativeItem); + expect(both).toEqual({ + command: '/skill:wiki-podcast-script --review-rewrite .llm-wiki/wiki/podcast/foo/foo.podcast.md', + }); + }); + it('returns the command unchanged for a command with no podcast markers (review marker absent)', async () => { const result = await resolvePodcastTarget('/skill:wiki-podcast-script --review foo/foo.podcast.md', draftedItem); expect(result).toEqual({ command: '/skill:wiki-podcast-script --review foo/foo.podcast.md' }); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index a1f11b3de..7b3b6a50a 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -4521,6 +4521,35 @@ export interface PodcastTargetResolution { error?: string; } +/** + * Normalize a `.podcast.md` Key File path to the wiki-dir-relative form the + * TTS skill's `--podcast-file` expects (OSL-0MUTPC7SF0011Y54). + * + * The canonical episode `Key Files:` form records the script + * worklog-root-relative — e.g. + * `.llm-wiki/wiki/podcast/<stem>/<stem>.podcast.md` — which is the form the + * `wiki-podcast-script` CLI resolves when it runs with the worklog root as + * its CWD. The TTS skill instead resolves `--podcast-file` against + * `--wiki-dir` (default `.llm-wiki/wiki`), so the wiki-root prefix is + * stripped to leave `podcast/<stem>/<stem>.podcast.md`. + * + * - A wiki-dir-relative path (`podcast/...`) or wiki-relative path + * (`wiki/...`) is returned unchanged. + * - A bare `<title>/<title>.podcast.md` is podcast-dir-relative (the legacy + * form) and gets the `podcast/` prefix. + */ +function toWikiRelativePodcastPath(script: string): string { + const normalized = script.replace(/^\.\//, ''); + const wikiRootPrefix = '.llm-wiki/wiki/'; + if (normalized.startsWith(wikiRootPrefix)) { + return normalized.slice(wikiRootPrefix.length); + } + if (normalized.startsWith('podcast/') || normalized.startsWith('wiki/')) { + return normalized; + } + return `podcast/${normalized}`; +} + /** * Resolve podcast-progression command markers (`<podcast-target>`, * `<podcast-script>`, `<podcast-review>`, `<podcast-both>`) for the selected @@ -4549,8 +4578,12 @@ export interface PodcastTargetResolution { * The `t` TTS chord command * (`/skill:wiki-tts-generate --podcast-file <podcast-script>`) resolves * `<podcast-script>` to the first `.podcast.md` Key File, normalized to the - * wiki-dir-relative `podcast/...` form the TTS skill expects (a bare - * `<title>/<title>.podcast.md` Key File path is podcast-dir-relative). + * wiki-dir-relative `podcast/...` form the TTS skill expects. The canonical + * episode Key File form is worklog-root-relative + * (`.llm-wiki/wiki/podcast/<stem>/<stem>.podcast.md`), so the wiki-root + * prefix is stripped; a bare `<title>/<title>.podcast.md` Key File path is + * podcast-dir-relative (the legacy form) and gets the `podcast/` prefix + * (OSL-0MUTPC7SF0011Y54). * * Markers are resolved BEFORE the generic modal-form check so they never * fall through to the input form. Returns the input command unchanged when @@ -4619,11 +4652,9 @@ export async function resolvePodcastTarget( if (!script) { return { error: 'No podcast script found in Key Files: — author the script first (w)' }; } - // The TTS skill resolves --podcast-file relative to the wiki dir; - // episode Key Files store the script podcast-dir-relative. - const podcastFile = script.startsWith('podcast/') || script.startsWith('wiki/') - ? script - : `podcast/${script}`; + // Episode Key Files record the script worklog-root-relative; normalize + // to the wiki-dir-relative form the TTS skill's --podcast-file expects. + const podcastFile = toWikiRelativePodcastPath(script); resolved = resolved.replace(/<podcast-script>/g, podcastFile); } From f16e759f1c475148097b8ff340c4c61267fed766 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Sun, 4 Oct 2026 16:52:34 +0100 Subject: [PATCH 03/27] WL-0MU2EOHK900425VU: Implementation complete --- docs/dev/downtime-dispatcher.md | 30 +++++++- packages/herdr/src/downtime-worker.test.ts | 88 ++++++++++++++++++++++ packages/herdr/src/downtime-worker.ts | 22 +++--- 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index a9ad26e60..95bb9a40f 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -377,6 +377,21 @@ worklog root is `R`: via `herdr tab list`, so a second dispatch for the same item reuses the same tab (never a duplicate). 3. **Spawn** — `--anchor <itemTabRootPaneId>`, `--no-focus` preserved. +4. **Root-pane cleanup** — after the dispatch pane has spawned, the item + tab's initial root pane (herdr's automatically-provisioned empty bash + pane, used only as the split anchor) is closed, so the tab shows only the + productive dispatch pane (WL-0MU2EOHK900425VU). Only the ROOT pane is ever + closed: the anchor is closed unless liveness POSITIVELY confirms it is a + live downtime dispatch pane. Liveness uses the running **downtime** pane + ids (`paneIds`); the full machine-wide `records` set must never be used + directly for this check because it always contains the anchor itself and + would suppress every close. A `records`-only payload is filtered through + `countRunningDowntimePanes`. When liveness cannot be confirmed + (query failed/absent) the anchor is left open (fail-safe) so a running + agent pane is never closed. On a later dispatch for the same item the + surviving dispatch pane is the anchor and is correctly spared. The + retained `Dispatcher` fallback anchor is deliberately exempt: closing it + would trigger the stale-detection re-provision loop. When the workspace resolves but the item-ID tab cannot be provisioned, the dispatch fails closed with `anchor-unavailable` — it never places the pane in @@ -439,14 +454,23 @@ Lifecycle (`packages/herdr/src/dispatcher-anchor.ts`): - **Project workspace + item-ID tab (primary):** `resolveProjectWorkspace` resolves the plugin pane for the item's root; `getItemTabAnchor` returns the anchor pane for the exact item-ID tab, creating it on first use under the - coordination lock. `dispatchClaimedTier` consults this path first whenever - both deps are wired. + coordination lock. After the first dispatch the tab's initial root pane is + closed (see invariant step 4) so the tab is never left with an empty bash + pane; a later dispatch for the same item reuses the surviving dispatch pane + as the split anchor (WL-0MU2EOHK900425VU). The cleanup is fail-safe: it + closes the anchor only when the running-downtime-pane liveness query + succeeds and the anchor is absent from it — never using the full pane list, + which would include the anchor and suppress the close. `dispatchClaimedTier` + consults this path first whenever both deps are wired. - **Per-prefix tab (fallback):** `getDispatcherTabAnchor(cwd, deps, prefix)` ensures the `Dispatcher` workspace, fast-paths the persisted live anchor, else lists tabs (`herdr tab list --workspace <id>`), else under the coordination lock (with a double-check) runs `herdr tab create --workspace <id> --label <prefix> --no-focus` and returns the new `tab_id` + - `root_pane.pane_id`. Consulted only when no project workspace resolves. + `root_pane.pane_id`. Consulted only when no project workspace resolves. Its + initial root pane is cleaned up after the first dispatch by the same + fail-safe rule as the project item tab (step 4); the fallback + `getDispatcherAnchor` pane is never closed. - **Fallback anchor:** `getDispatcherAnchor(cwd, deps)` provisions one `Dispatcher` workspace + persisted anchor pane in the machine coordination dir (`~/.herdr/downtime/downtime-dispatch-anchor.json`, atomic tmp+rename, diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index b4d0e1932..37a0b64e5 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -1158,6 +1158,94 @@ describe('project workspace + item-ID tab dispatch wiring (primary path)', () => expect(outcome.dispatched).toBe(true); expect(deps.closePane).toBeUndefined(); }); + + // ── Audit-rejection regressions (WL-0MU2EOHK900425VU) ───────────────── + // The producer rejected the first fix because the empty shell pane was + // still present after creating a tab in an existing workspace. Root cause: + // the cleanup preferred `records` (the FULL machine-wide pane set, which + // always contains the anchor itself) over `paneIds` (the running DOWNTIME + // panes), so `liveIds.includes(anchorId)` was always true and every close + // was suppressed. The cleanup now uses the running-downtime set. + + it('AC1 regression: closes the root pane even when records contains the anchor (full pane set)', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + // Production `records` = the FULL pane list: it contains the blank + // anchor (which is not a downtime pane). `paneIds` (running downtime + // panes) is empty → the anchor must be closed. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, + count: 0, + paneIds: [], + records: [ + { paneId: 'wC:tWL-ABC:p1', label: 'bash' }, + { paneId: 'wC:other', label: 'bash' }, + ], + }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).toHaveBeenCalledTimes(1); + expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); + }); + + it('AC1 regression: records-only payload is filtered to downtime panes (anchor still closed)', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + // Legacy payload without `paneIds`; `records` holds the blank anchor + // (not a downtime pane) → filtering yields no live downtime pane. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ + ok: true, + count: 0, + records: [{ paneId: 'wC:tWL-ABC:p1', label: 'bash' }], + }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).toHaveBeenCalledTimes(1); + expect(closePane).toHaveBeenCalledWith('wC:tWL-ABC:p1', '/repo'); + }); + + it('AC2 fail-safe regression: leaves the anchor open when liveness fails (ok:false)', async () => { + const closePane = vi.fn().mockResolvedValue(true); + const deps = makeDeps({ + resolveProjectWorkspace: vi + .fn() + .mockResolvedValue({ paneId: 'wC:pB', workspaceId: 'wC', tabId: 'wC:tPlugin' }), + getItemTabAnchor: vi + .fn() + .mockResolvedValue({ tabId: 'wC:tWL-ABC', paneId: 'wC:tWL-ABC:p1' }), + // Liveness query FAILED → cannot confirm → leave the anchor open. + getRunningDowntimePanes: vi.fn().mockResolvedValue({ ok: false, error: 'herdr unavailable' }), + closePane, + getNextItem: vi.fn().mockResolvedValue({ ok: true, candidate }), + }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(outcome.dispatched).toBe(true); + expect(closePane).not.toHaveBeenCalled(); + }); }); diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 5b5f3e415..8387e944f 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -3093,19 +3093,23 @@ async function dispatchClaimedTier( typeof deps.closePane === 'function' ) { try { - // Fail-safe: the anchor is only closed when liveness POSITIVELY - // confirms it is not a live dispatch pane. An absent/failed liveness - // query leaves it open rather than risk closing a running agent pane. + // Fail-safe: close the anchor unless liveness POSITIVELY confirms it is + // a live downtime dispatch pane. The check must use the running DOWNTIME + // pane ids (`paneIds`), NEVER the full machine-wide pane set (`records` + // contains the anchor itself, so checking it would suppress every + // close — the WL-0MU2EOHK900425VU audit regression). A `records`-only + // payload is filtered to downtime panes via `countRunningDowntimePanes`. + // When liveness cannot be confirmed (query failed/absent) the anchor is + // left open (fail-safe), so a live agent pane is never closed. let confirmedRootPane = false; if (typeof deps.getRunningDowntimePanes === 'function') { const running = await deps.getRunningDowntimePanes(opts.cwd); if (running.ok) { - const liveIds = - running.records !== undefined - ? running.records.map((rec) => rec.paneId) - : Array.isArray(running.paneIds) - ? running.paneIds - : null; + const liveIds = Array.isArray(running.paneIds) + ? running.paneIds + : running.records !== undefined + ? countRunningDowntimePanes(running.records) + : null; if (liveIds !== null) confirmedRootPane = !liveIds.includes(anchorId); } } From a22442d036d0b730437725a60e757675fe693a56 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Sun, 4 Oct 2026 17:08:31 +0100 Subject: [PATCH 04/27] WL-0MUNMD0BE001TBL7: Implementation complete --- packages/herdr/src/mode-switch-worker.test.ts | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/packages/herdr/src/mode-switch-worker.test.ts b/packages/herdr/src/mode-switch-worker.test.ts index d1265f288..1794ad7e2 100644 --- a/packages/herdr/src/mode-switch-worker.test.ts +++ b/packages/herdr/src/mode-switch-worker.test.ts @@ -586,6 +586,134 @@ describe('drain before cheap switch', () => { expect(worker.getIsDraining()).toBe(false); expect(setModePosts(api)).toHaveLength(0); }); + + it('no redundant switching: a persisted cheap mode short-circuits before drain entry', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + // The proxy is already cheap: even with the idle window met and the cheap + // budget unmet, the worker must not enter drain nor POST again. + api.setGetMode(() => 'cheap'); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(false); + expect(setModePosts(api)).toHaveLength(0); + expect(worker.getLastKnownMode()).toBe('cheap'); + }); + + it('drain completing into a 409 restart no-op leaves the mode fast and retries on a later tick', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(true); + + // The proxy reports a mode-switch restart in progress (409) when the + // drain completes: treated as a no-op, the mode stays fast. + api.setSetMode(() => ({ status: 409, body: { error: 'mode-switch restart in progress' } })); + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotDrainComplete(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); // attempted once + expect(worker.getLastKnownMode()).toBe('fast'); // 409 → no mode change + expect(worker.getIsDraining()).toBe(false); + + // The next tick retries and succeeds once the restart has settled. + api.setSetMode(() => ({ status: 200, body: { ok: true } })); + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotDrainComplete(), + }); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + expect(worker.getLastKnownMode()).toBe('cheap'); + }); + + it('operator command during drain while already fast follows the normal fast path with no redundant POST', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(true); + expect(worker.getLastKnownMode()).toBe('fast'); + + // The proxy is already fast: the fast path dedups, so cancelling the + // drain fires no redundant POST and the tracked mode is unchanged. + api.reset(); + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + expect(worker.getIsDraining()).toBe(false); + expect(setModePosts(api)).toHaveLength(0); + expect(worker.getLastKnownMode()).toBe('fast'); + }); + + it('a cross-pane shared activity broadcast during drain cancels it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'wl-mode-drain-activity-')); + try { + clock = 1_000_000; + const api = mockAdminApi(); + const worker = createModeSwitchWorker({ + fetcher: api.fetcher, + now, + isLeader: () => true, + coordinationDir: dir, + }); + advance(900_000); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(true); + + // Another pane receives an operator command and broadcasts its activity: + // the idle clock advances, so the next tick cancels the drain. + advance(10_000); + writeSharedActivity(dir, clock); + api.reset(); + await worker.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotOperatorFree(), + }); + await flushAsync(); + expect(worker.getIsDraining()).toBe(false); + expect(setModePosts(api)).toHaveLength(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); // ── Fail-closed paths ──────────────────────────────────────────────── From 188775648b1b687fcaf7337239e487671db3ded1 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Sun, 4 Oct 2026 17:14:46 +0100 Subject: [PATCH 05/27] WL-0MUNS8X97007C9H9: Implementation complete --- docs/dev/downtime-dispatcher.md | 99 +++++++---- packages/herdr/README.md | 20 ++- packages/herdr/src/browse-window.ts | 45 +++++ .../herdr/src/coordination-dispatch.test.ts | 30 ++++ packages/herdr/src/downtime-worker.test.ts | 167 ++++++++++++++---- packages/herdr/src/downtime-worker.ts | 79 +++++---- packages/herdr/src/index.test.ts | 13 +- packages/herdr/src/index.ts | 10 +- packages/herdr/src/settings.ts | 28 ++- 9 files changed, 356 insertions(+), 135 deletions(-) create mode 100644 packages/herdr/src/browse-window.ts diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index 95bb9a40f..b05957a57 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -32,38 +32,61 @@ critical work is never starved by lower-priority items occupying the window (see *Critical-first tier* section). If no head item passes the filters, the dispatcher reports "no candidate" rather than falling back to a second ranking. No `wl next`/database scoring change is required; the observable contract is -"dispatcher == Herdr list head". - -### Extended dispatch window (WL-0MU6UL3GQ0015AA5) - -The Herdr head is **windowed**: mandatory items (critical + `completed`/`in_review`) are -always included and consume window slots, and the remaining slots are filled from "other" -items. When the mandatory set is large, the first genuinely dispatchable candidate can fall -**outside** the window — the 2026-09-18 incident (a 30-item head whose only dispatchable -item was the 22nd "other") left the machine with **zero dispatches for 31 h** while a -healthy backlog existed. The dispatcher therefore **extends the dispatch window when the -head yields no candidate**: - -- It re-reads the **same ranking path** (`fetchNextItems` → `selectWorkItems` → - `regroupWorkItems`) with a bounded larger count and skips the items already seen — a - **window extension, never a second ranking**. Ordering is unchanged; only more "other" - items become visible. -- The extension is bounded by `DOWNTIME_DISPATCH_EXTEND_MAX` (`downtime-worker.ts`, 30): at - most `head length + 30` items are scanned per dispatch cycle (a default 30-item head - therefore scans at most 60 items). -- The extension runs on **both** dispatch paths — `dispatchDowntimeWork` (direct dispatch) - and `computeMostImportantItem` (the coordination check-in offer) — so an instance never - offers "nothing" while its backlog holds dispatchable work. -- The **TUI worklist is unchanged**: it keeps rendering exactly `browseItemCount` items - (clamped 1–50). The extension is dispatch-only. -- **Fail-open:** a failed/empty extended lookup degrades to the original terminal reason, so - the extension can never convert a defined outcome into a new failure. - -Because of the extension, the **"no candidate" contract applies only to a genuinely empty -dispatchable backlog** (or a backlog fully blocked by a safety gate) — not to an item hidden -beyond the `browseItemCount` window. Other terminal reasons (code-freeze, `audit-in-flight`, -`audit-host-saturated`, `fresh-audit-skip`, `review-queue-hold`, `wl-error`) keep their -existing semantics. +"dispatcher == Herdr list head". The head is fetched with the **live per-root +`browseItemCount`** limit (clamped 1–50), so the dispatcher head equals the +rendered sprint view — see *Sprint-view-only dispatch window* below. + +### Sprint-view-only dispatch window (WL-0MUNS8X97007C9H9) + +The dispatcher head **is** the sprint view. Both selection paths pass the live +per-root `browseItemCount` setting — clamped to the supported `1`–`50` range by +`resolveSprintViewWindow` (`packages/herdr/src/browse-window.ts`), the same +clamp the TUI worklist applies — as the `getHerdrListHead` limit: + +- `dispatchDowntimeWork` (direct dispatch), and +- `computeMostImportantItem` (the coordination check-in offer). + +`getHerdrListHead` forwards the limit to `fetchNextItems(limit)`, so the head +contains exactly the items the sprint view renders (`f-s-s` → `/wl`): the +mandatory set (all `critical` plus all completed/`in_review`) and enough +"other" items to fill the remaining `browseItemCount` slots. The setting is +re-read live on every dispatch cycle, so a change applies without a restart. A +caller that passes no limit falls back to the sprint-view default (20), so an +unwindowed head is never produced. + +**Non-critical work outside the view is never dispatched.** When the head +yields no candidate the dispatcher reports the terminal reason derived from the +in-view flags (`no-candidate`, `review-queue-hold`, `code-freeze`, +`fresh-audit-skip`, `audit-in-flight`, `audit-host-saturated`, `in-flight-hold`, +`wl-error`) and does **not** fall back to a second ranking or an out-of-view +non-critical scan. When every visible item is filtered by a safety gate (or the +view is empty) the outcome is a NOP. + +**Critical escape hatch (defensive).** A bounded out-of-window read +(`fetchExtendedHerdrItems`, `DOWNTIME_DISPATCH_EXTEND_MAX = 30`) re-reads the +**same ranking path** with a larger window — a window extension, never a second +ranking — and returns **only** `critical` items. Because every `critical` item +is already mandatory in the view, this escape hatch is normally a no-op; it +exists purely as a safety net so a release blocker can never be windowed out. +Non-critical items beyond the sprint view are filtered out, so the operator can +never be surprised by hidden-backlog dispatch. The extension runs on both +dispatch paths; the **TUI worklist is unchanged** (it keeps rendering exactly +`browseItemCount` items, plus the mandatory set). + +**Supersedes WL-0MU6UL3GQ0015AA5 AC1/AC4.** That item widened the non-critical +window to prevent a 31-hour starvation incident. This item deliberately reverses +the non-critical part of that behaviour: the dispatch window now tracks the +operator-curated sprint view. The accepted trade-off (recorded on +WL-0MUNS8X97007C9H9) is that if every visible item is blocked and the only +dispatchable item lies beyond `browseItemCount`, the dispatcher no-ops — +mitigated operationally by raising `browseItemCount` (up to 50) or clearing a +blocker. The WL-0MU6UL3GQ0015AA5 starvation regression now asserts this NOP +behaviour rather than a dispatch. + +**Fail-open preserved.** A failed head lookup still resolves `{ok:false}` (the +caller keeps its entry / reports `wl-error`); a failed or empty extended +critical read degrades to the original terminal reason, so the escape hatch can +never convert a defined outcome into a new failure. **Coordination leader (F3, WL-0MTK1ILM2009QYB2):** the shared coordination file holds ONE entry per instance — an **offer** of that instance's own Herdr list head (computed at the @@ -523,13 +546,11 @@ only to automated downtime dispatch). The no-candidate cooldown (WL-0MSI7DQL10016QYX) pauses the worker entirely (no poll, no idle tracking, no dispatch) for `downtimeNoCandidateCooldownMs` (default 60 min) after a genuinely empty backlog, resetting the idle -tracker so a fresh full idle period is required after the pause. The -**extended dispatch window** (WL-0MU6UL3GQ0015AA5, see *Ranking contract* -above) guarantees a `no-candidate` outcome means the *whole* bounded -dispatch backlog — not merely the initial head — held nothing dispatchable: -the window is extended at least to the first dispatchable candidate, up to -`DOWNTIME_DISPATCH_EXTEND_MAX` additional items, before `no-candidate` is -reported. In +tracker so a fresh full idle period is required after the pause. A +`no-candidate` outcome means the *sprint view* — the live `browseItemCount` +window (plus mandatory items) — held nothing dispatchable: the dispatcher does +not scan hidden backlog (WL-0MUNS8X97007C9H9; see *Sprint-view-only dispatch +window* above). In coordination mode (WL-0MTEZ4XZJ006Y9U7) the shared runtime file (`.worklog/downtime-coordination.json`) is an **offer list, not the backlog**: the leader removes each entry after dispatching (see step 4 diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 1244a6469..a3d44c5e3 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -405,14 +405,18 @@ critical items at the Herdr head, so a critical item dispatches as soon as it is first classifyable list item (WL-0MSI8H3HP000K0RG audit, WL-0MSMAYPQP001FLR6 implement, WL-0MT3FM8VA005XBHE critical). -**Extended dispatch window (WL-0MU6UL3GQ0015AA5):** the Herdr head is windowed -(mandatory items always included, remaining slots filled from "other" items), so a large -mandatory set can push the only dispatchable candidate past the window. When the head -yields no candidate the dispatcher re-reads the **same ranking path** with a bounded -larger count (`DOWNTIME_DISPATCH_EXTEND_MAX`, 30 additional items) and skips the items -already seen — a window extension, never a second ranking. The TUI worklist still renders -exactly `browseItemCount` items; the extension is dispatch-only. A `no-candidate` outcome -therefore means the whole bounded dispatch backlog held nothing dispatchable. +**Sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes +WL-0MU6UL3GQ0015AA5 AC1/AC4):** the dispatcher head is the sprint view. Both selection +paths pass the live per-root `browseItemCount` (clamped 1–50) to `getHerdrListHead`, so +the head equals the rendered worklist (mandatory items always included, remaining slots +filled from "other" items). Non-critical work outside the view is never dispatched: when +the head yields no candidate the dispatcher reports the in-view terminal reason +(`no-candidate`, `review-queue-hold`, `code-freeze`, …) and does not scan hidden backlog. +A bounded out-of-window escape hatch (`DOWNTIME_DISPATCH_EXTEND_MAX`, 30 additional +items) re-reads the **same ranking path** but returns **only** `critical` items — because +critical items are already mandatory in the view this is normally a no-op, and it can +never surface hidden non-critical work. The TUI worklist still renders exactly +`browseItemCount` items. A "valid" audit is defined by the review-icon freshness rule: the audit is current — i.e. the review icon is **neither** the hourglass `⏳` (stale passed) diff --git a/packages/herdr/src/browse-window.ts b/packages/herdr/src/browse-window.ts new file mode 100644 index 000000000..16e797b43 --- /dev/null +++ b/packages/herdr/src/browse-window.ts @@ -0,0 +1,45 @@ +/** + * packages/herdr/src/browse-window.ts — the canonical sprint-view window. + * + * The `browseItemCount` setting bounds the default worklist ("sprint view", + * `f-s-s` → `/wl`): `fetchNextItems` renders the mandatory set (all `critical` + * plus all completed/`in_review`) and enough "other" items to fill + * `browseItemCount` slots. + * + * This leaf module owns the bounds, the default and the clamp so that both the + * TUI (`settings.ts` / `worklist.ts`) and the downtime dispatcher + * (`downtime-worker.ts`) resolve the SAME window. `settings.ts` imports + * constants from `downtime-worker.ts`, so `downtime-worker.ts` cannot import + * `settings.ts` without creating a top-level evaluation cycle (WL-0MUNS8X97007C9H9 AC1). + */ + +/** Minimum allowed browseItemCount. */ +export const MIN_BROWSE_ITEM_COUNT = 1; + +/** Maximum allowed browseItemCount. */ +export const MAX_BROWSE_ITEM_COUNT = 50; + +/** + * Default `browseItemCount` — the sprint-view window when the setting is + * absent/invalid. Matches `defaultSettings.browseItemCount`. + */ +export const DEFAULT_BROWSE_ITEM_COUNT = 20; + +/** + * Clamp a browseItemCount value to the supported [1, 50] range. + * Used at load time so persisted/parsed values cannot exceed the bounds. + */ +export function clampBrowseItemCount(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_BROWSE_ITEM_COUNT; + return Math.min(Math.max(Math.round(value), MIN_BROWSE_ITEM_COUNT), MAX_BROWSE_ITEM_COUNT); +} + +/** + * Resolve the effective sprint-view dispatch window: the live per-root + * `browseItemCount`, clamped exactly as the TUI worklist does. An + * absent/invalid value falls back to `DEFAULT_BROWSE_ITEM_COUNT` (20), so the + * dispatcher head always equals the rendered sprint view (WL-0MUNS8X97007C9H9 AC1). + */ +export function resolveSprintViewWindow(browseItemCount?: number): number { + return clampBrowseItemCount(browseItemCount ?? DEFAULT_BROWSE_ITEM_COUNT); +} diff --git a/packages/herdr/src/coordination-dispatch.test.ts b/packages/herdr/src/coordination-dispatch.test.ts index 5bfbabc94..f60fab386 100644 --- a/packages/herdr/src/coordination-dispatch.test.ts +++ b/packages/herdr/src/coordination-dispatch.test.ts @@ -964,6 +964,36 @@ describe('runCoordinationCheckIn', () => { expect(entry?.directory).toBe('/repo'); }); + it('offers the head using the effective browseItemCount (AC1, WL-0MUNS8X97007C9H9)', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ + ok: true, + items: [headItem({ id: 'WL-IMP', stage: 'idea', status: 'open' })], + }); + const deps = makeCoordinationDeps({ getHerdrListHead }); + await runCoordinationCheckIn(deps, { + cwd: '/repo', + coordinationDir: testDir, + instanceId: 'inst-1', + browseItemCount: 33, + }); + expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 33); + }); + + it('clamps an out-of-range browseItemCount on the check-in offer path', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ + ok: true, + items: [headItem({ id: 'WL-IMP', stage: 'idea', status: 'open' })], + }); + const deps = makeCoordinationDeps({ getHerdrListHead }); + await runCoordinationCheckIn(deps, { + cwd: '/repo', + coordinationDir: testDir, + instanceId: 'inst-1', + browseItemCount: 999, + }); + expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 50); + }); + it('updates the entry when the most-important (Herdr head) item changes', async () => { const deps = makeCoordinationDeps({ getHerdrListHead: vi.fn() diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 37a0b64e5..6ab7099e3 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -9920,7 +9920,7 @@ describe('one dispatch per in-flight critical item (WL-0MUBVKS5I007LSWB / AC5)', // path too — the field is threaded into `classifyItemForDispatch` as of // WL-0MU72WJ8C0005GIE / 022f1b24.) -describe('dispatch-window extension — head-cap starvation (WL-0MU6UL3GQ0015AA5)', () => { +describe('sprint-view-only dispatch window (WL-0MUNS8X97007C9H9; supersedes WL-0MU6UL3GQ0015AA5 AC1/AC4)', () => { const OLD = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); /** @@ -9974,10 +9974,74 @@ describe('dispatch-window extension — head-cap starvation (WL-0MU6UL3GQ0015AA5 sortIndex: 999, }); + /** A dispatchable CRITICAL implement item (the AC4 escape-hatch shape). */ + const dispatchableCriticalImplement = (id: string): DowntimeHerdrItem => ({ + id, + title: `Critical ${id}`, + status: 'open', + stage: 'plan_complete', + priority: 'critical', + risk: 'Low', + effort: 'S', + sortIndex: 500, + }); + const fullHead = (): DowntimeHerdrItem[] => [...mandatorySet(), ...blockedOthers()]; + describe('AC1 — head limit equals the live sprint-view browseItemCount', () => { + it('direct dispatch passes the effective browseItemCount to getHerdrListHead', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 20); + }); + + it('clamps an out-of-range browseItemCount to the supported [1, 50] range', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 999 }); + expect(getHerdrListHead).toHaveBeenLastCalledWith('/repo', 50); + + getHerdrListHead.mockClear(); + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 0 }); + expect(getHerdrListHead).toHaveBeenLastCalledWith('/repo', 1); + }); + + it('defaults to the sprint-view default (20) when browseItemCount is undefined', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); + + expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 20); + }); + + it('computeMostImportantItem passes the effective browseItemCount to getHerdrListHead', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await computeMostImportantItem(deps, '/repo', Date.now(), 33); + + expect(getHerdrListHead).toHaveBeenCalledWith('/repo', 33); + }); + + it('re-reads the live browseItemCount on every dispatch cycle (settings change applies without restart)', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ ok: true, items: [dispatchableIntake('CG-INWINDOW')] }); + const deps = makeDeps({ getHerdrListHead }); + + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 15 }); + await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 40 }); + + expect(getHerdrListHead).toHaveBeenNthCalledWith(1, '/repo', 15); + expect(getHerdrListHead).toHaveBeenNthCalledWith(2, '/repo', 40); + }); + }); + describe('AC3 — regression: the dispatchable item beyond the window cap is found', () => { - it('direct dispatch (dispatchDowntimeWork) dispatches the out-of-window candidate', async () => { + it('regression (WL-0MU6UL3GQ0015AA5 updated): a non-critical item beyond the sprint view is NOP, not dispatched', async () => { const initialHead = fullHead(); const target = dispatchableIntake('CG-0MTZO0YIM000VAIQ'); const getHerdrListHead = vi.fn() @@ -9987,25 +10051,24 @@ describe('dispatch-window extension — head-cap starvation (WL-0MU6UL3GQ0015AA5 const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('intake'); - expect(outcome.candidate?.id).toBe('CG-0MTZO0YIM000VAIQ'); - // The extension re-read the SAME ranking path with a bounded larger - // window (never a second ranking). + // The out-of-window item is NON-critical: the sprint-view-only contract + // reports no-candidate instead of dispatching hidden backlog work + // (supersedes WL-0MU6UL3GQ0015AA5 for non-critical work). + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('no-candidate'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.claimItem).not.toHaveBeenCalled(); + // The critical-only extension still consulted the SAME ranking path + // (never a second ranking). expect(getHerdrListHead).toHaveBeenCalledTimes(2); expect(getHerdrListHead).toHaveBeenNthCalledWith( 2, '/repo', initialHead.length + DOWNTIME_DISPATCH_EXTEND_MAX, ); - expect(deps.claimItem).toHaveBeenCalledWith( - 'CG-0MTZO0YIM000VAIQ', - { status: 'open', stage: 'idea' }, - '/repo', - ); }); - it('the coordination check-in offer (computeMostImportantItem) also uses the extended window', async () => { + it('the coordination check-in offer (computeMostImportantItem) also no-ops on a non-critical out-of-window item', async () => { const initialHead = fullHead(); const target = dispatchableIntake('CG-OFFER'); const getHerdrListHead = vi.fn() @@ -10015,11 +10078,7 @@ describe('dispatch-window extension — head-cap starvation (WL-0MU6UL3GQ0015AA5 const result = await computeMostImportantItem(deps, '/repo'); - expect(result).toMatchObject({ - ok: true, - kind: 'intake', - candidate: { id: 'CG-OFFER' }, - }); + expect(result).toMatchObject({ ok: true, noCandidate: true }); expect(getHerdrListHead).toHaveBeenCalledTimes(2); }); @@ -10036,10 +10095,10 @@ describe('dispatch-window extension — head-cap starvation (WL-0MU6UL3GQ0015AA5 expect(getHerdrListHead).toHaveBeenCalledTimes(1); }); - it('extends even under a code freeze to reach plan/intake work beyond the window', async () => { - // Under a freeze, audit/implement candidates are paused, but plan/intake - // prep work still dispatches. A dispatchable intake item beyond the - // window must therefore still be reachable. + it('stays code-freeze under a freeze even when a non-critical item lies beyond the window', async () => { + // Under a freeze, audit/implement candidates are paused; a non-critical + // item beyond the sprint view is not an escape hatch, so the terminal + // reason stays derived from the in-view flags. const initialHead = fullHead(); const target = dispatchableIntake('CG-FROZEN-OUT-OF-WINDOW'); const getHerdrListHead = vi.fn() @@ -10052,10 +10111,8 @@ describe('dispatch-window extension — head-cap starvation (WL-0MU6UL3GQ0015AA5 const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo' }); - expect(outcome.dispatched).toBe(true); - expect(outcome.kind).toBe('intake'); - expect(outcome.candidate?.id).toBe('CG-FROZEN-OUT-OF-WINDOW'); - expect(getHerdrListHead).toHaveBeenCalledTimes(2); + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('code-freeze'); }); }); @@ -10100,26 +10157,70 @@ describe('dispatch-window extension — head-cap starvation (WL-0MU6UL3GQ0015AA5 }); }); - describe('fetchExtendedHerdrItems — window extension primitive', () => { - const item = (id: string): DowntimeHerdrItem => ({ + describe('AC4 — critical items remain dispatchable', () => { + it('a critical item in the head still dispatches while every non-critical item is filtered', async () => { + const getHerdrListHead = vi.fn().mockResolvedValue({ + ok: true, + items: [dispatchableCriticalImplement('CG-CRIT-DISPATCH'), ...blockedOthers()], + }); + const deps = makeDeps({ getHerdrListHead }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('CG-CRIT-DISPATCH'); + }); + + it('the critical-only extension still reaches an out-of-window critical item', async () => { + const initialHead = fullHead(); + const target = dispatchableCriticalImplement('CG-CRIT-OUT-OF-WINDOW'); + const getHerdrListHead = vi.fn() + .mockResolvedValueOnce({ ok: true, items: initialHead }) + .mockResolvedValueOnce({ ok: true, items: [...initialHead, target] }); + const deps = makeDeps({ getHerdrListHead }); + + const outcome = await dispatchDowntimeWork(deps, { model: 'plan', cwd: '/repo', browseItemCount: 20 }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('CG-CRIT-OUT-OF-WINDOW'); + }); + }); + + describe('fetchExtendedHerdrItems — critical-only escape hatch primitive', () => { + const item = (id: string, priority = 'medium'): DowntimeHerdrItem => ({ id, title: `Item ${id}`, status: 'open', stage: 'idea', + priority, }); - it('returns only the newly visible items, preserving the canonical order', async () => { - const current = [item('A'), item('B')]; + it('returns only newly visible CRITICAL items, preserving the canonical order', async () => { + const current = [item('A')]; const dep = vi.fn().mockResolvedValue({ ok: true, - items: [item('A'), item('B'), item('C'), item('D')], + items: [item('A'), item('B'), item('C', 'critical'), item('D'), item('E', 'critical')], }); const deps = makeDeps({ getHerdrListHead: dep }); const extended = await fetchExtendedHerdrItems(deps, '/repo', current); - expect(extended.map((i) => i.id)).toEqual(['C', 'D']); - expect(dep).toHaveBeenCalledWith('/repo', 2 + DOWNTIME_DISPATCH_EXTEND_MAX); + expect(extended.map((i) => i.id)).toEqual(['C', 'E']); + expect(extended.every((i) => i.priority === 'critical')).toBe(true); + expect(dep).toHaveBeenCalledWith('/repo', 1 + DOWNTIME_DISPATCH_EXTEND_MAX); + }); + + it('never returns a non-critical item beyond the sprint view (AC4)', async () => { + const current = [item('A')]; + const dep = vi.fn().mockResolvedValue({ + ok: true, + items: [item('A'), item('B', 'high'), item('C'), item('D', 'low')], + }); + const deps = makeDeps({ getHerdrListHead: dep }); + + expect(await fetchExtendedHerdrItems(deps, '/repo', current)).toEqual([]); }); it('returns [] when the extended head adds nothing (limit-ignoring dep)', async () => { diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 8387e944f..8520d5652 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -133,6 +133,7 @@ import { spawn } from 'node:child_process'; import { isAuditFresh } from '@worklog/shared/icons'; +import { DEFAULT_BROWSE_ITEM_COUNT, resolveSprintViewWindow } from './browse-window.js'; import type { CodeFreezeStatus } from './code-freeze.js'; import { disableMarkerExists, removeDisableMarker, writeDisableMarker } from './downtime-disable-marker.js'; import type { ScheduledPrompt } from './scheduled-prompts.js'; @@ -306,25 +307,29 @@ export const DISPATCH_PIPELINE_SINGLE_FLIGHT = 1; * dispatch is held until audits / producer review drain it back below. * Re-read live from settings each tick (no restart); root-only counting * via `wl list --status completed --stage in_review --root-only --json`. + * + * The canonical definition lives in the leaf `browse-window.ts` module + * (shared with the TUI sprint view without an import cycle, + * WL-0MUNS8X97007C9H9 AC1); re-exported here for backward compatibility. */ -export const DEFAULT_BROWSE_ITEM_COUNT = 20; +export { DEFAULT_BROWSE_ITEM_COUNT }; /** * Maximum number of additional items to fetch when extending the dispatch - * window (WL-0MU6UL3GQ0015AA5). + * window (WL-0MU6UL3GQ0015AA5; restricted to critical-only by + * WL-0MUNS8X97007C9H9 AC2/AC4). * - * When the Herdr list head contains no dispatchable candidate but the - * broader backlog does, the dispatcher re-reads the SAME ranking path with a - * window of `head length + DOWNTIME_DISPATCH_EXTEND_MAX` items and iterates - * the newly visible ones in the same Herdr priority order. This prevents - * starvation when mandatory items (critical + completed/in_review) consume - * most of the head slots and the only dispatchable item falls outside the - * window. + * The dispatcher head is the sprint view, so a defensive out-of-window read + * re-checks the SAME ranking path with a window of + * `head length + DOWNTIME_DISPATCH_EXTEND_MAX` items. The extension returns + * ONLY `critical` items: every non-critical candidate must be visible in the + * sprint view, so a non-critical item beyond `browseItemCount` is never + * dispatched. Because all `critical` items are mandatory in the view, this + * escape hatch is normally a no-op. * - * The extension is bounded: a default 30-item head plus an extension of 30 - * means at most 60 items are ever scanned per dispatch cycle. The TUI - * worklist continues to show exactly `browseItemCount` items (clamped - * 1–50). + * The extension is bounded: at most `head length + 30` items are ever scanned + * per dispatch cycle. The TUI worklist continues to show exactly + * `browseItemCount` items (clamped 1–50). */ export const DOWNTIME_DISPATCH_EXTEND_MAX = 30; @@ -2464,24 +2469,24 @@ export function isImplementHeldByReviewGate( return candidate.priority !== 'critical'; } -// ── Dispatch-window extension (WL-0MU6UL3GQ0015AA5) ───────────────── +// ── Critical-only dispatch-window escape hatch (WL-0MUNS8X97007C9H9) ───────────────── /** - * Fetch the EXTENDED Herdr head for dispatch (WL-0MU6UL3GQ0015AA5). + * Fetch the CRITICAL-ONLY extended Herdr head for dispatch + * (WL-0MU6UL3GQ0015AA5, restricted by WL-0MUNS8X97007C9H9 AC2/AC4). * * The dispatcher consumes the Herdr list head produced by the canonical - * ranking path (fetcher → smart-selection → grouping). Mandatory items - * (critical + `completed`/`in_review`) are always included and consume - * window slots, so when they are numerous the first genuinely dispatchable - * candidate can fall beyond the initial window — the dispatcher then sees - * no candidate even though the broader backlog holds one (the 2026-09-18 - * 31-hour starvation incident). - * - * This helper re-reads the SAME ranking path with a larger window + * ranking path (fetcher → smart-selection → grouping), windowed to the live + * `browseItemCount` so the head equals the rendered sprint view. Every + * `critical` item is mandatory in that view, so this helper is a DEFENSIVE + * safety net: it re-reads the SAME ranking path with a larger window * (`current.length + DOWNTIME_DISPATCH_EXTEND_MAX`) and returns only the - * items NOT already present in `current`, preserving the canonical order. + * `critical` items NOT already present in `current`, preserving the canonical + * order. NON-CRITICAL items beyond the sprint view are never returned — the + * operator must be able to see any work the dispatcher acts on. + * * It is a WINDOW EXTENSION, never a second ranking: the same * `selectWorkItems` / `regroupWorkItems` contract decides the order, just - * with more "other" items included. + * with more items included. * * Fail-open: an absent optional `limit` support (test mocks ignoring the * argument), a `{ok:false}` CLI failure, a thrown error, or an extended head @@ -2502,7 +2507,9 @@ export async function fetchExtendedHerdrItems( ); if (!extended.ok) return []; const seen = new Set(current.map((i) => i.id)); - return extended.items.filter((i) => !seen.has(i.id)); + // Critical-only escape hatch: a non-critical item beyond the sprint view + // is never dispatchable (WL-0MUNS8X97007C9H9 AC2/AC4). + return extended.items.filter((i) => !seen.has(i.id) && i.priority === 'critical'); } catch { return []; // fail-open: the extension never breaks a defined outcome } @@ -3473,7 +3480,13 @@ export async function computeMostImportantItem( const freezeStatus = deps.readCodeFreezeStatus(cwd); const frozen = freezeStatus === 'frozen' || freezeStatus === 'ambiguous'; - const head = await deps.getHerdrListHead(cwd); + // The dispatch window is the sprint view: the live per-root + // `browseItemCount`, clamped exactly as the TUI worklist does + // (WL-0MUNS8X97007C9H9 AC1). Passing the effective value means the head — + // and therefore every candidate — equals the rendered view. + const sprintWindow = resolveSprintViewWindow(browseItemCount); + + const head = await deps.getHerdrListHead(cwd, sprintWindow); if (!head.ok) return { ok: false, error: head.error }; // CLI error — fail-open (entry kept) if (head.items.length === 0) return { ok: true, noCandidate: true }; @@ -3503,7 +3516,7 @@ export async function computeMostImportantItem( if (reviewGate === undefined) { // Frozen already pauses audit/implement offers (filter below), so the // gate is never consulted under a freeze. - reviewGate = frozen ? null : await readReviewQueueGate(deps, cwd, browseItemCount); + reviewGate = frozen ? null : await readReviewQueueGate(deps, cwd, sprintWindow); } return reviewGate; }; @@ -4270,6 +4283,12 @@ export async function dispatchDowntimeWork( const panesEligible = freeSlots === undefined || freeSlots >= DOWNTIME_PANE_MIN_FREE_SLOTS; const auditEligible = freeSlots === undefined || freeSlots >= DOWNTIME_AUDIT_MIN_FREE_SLOTS; + // Sprint-view dispatch window (WL-0MUNS8X97007C9H9 AC1): the live per-root + // `browseItemCount` clamped exactly as the TUI worklist does, so the Herdr + // head below equals the rendered sprint view. Non-critical work outside + // this window is never dispatched. + const sprintWindow = resolveSprintViewWindow(opts.browseItemCount); + let auditInFlight = false; let auditCheckFailed = false; let freshnessSkip = false; @@ -4306,7 +4325,7 @@ export async function dispatchDowntimeWork( // intake/critical flow unconditionally. let reviewGate: ReviewQueueGateResult | null = null; if (!frozen && panesEligible) { - reviewGate = await readReviewQueueGate(deps, opts.cwd, opts.browseItemCount); + reviewGate = await readReviewQueueGate(deps, opts.cwd, sprintWindow); } // ── Herdr list head consumes the ranking (WL-0MTK1ILM2009QYB2 ACs 1–2) ── @@ -4321,7 +4340,7 @@ export async function dispatchDowntimeWork( // once the suite is fully on Herdr-head stubs. { const flags = { auditInFlight, auditCheckFailed, auditCheckError: undefined, freshnessSkip, reviewHeld: false, cooldownHeld: false, auditHostSaturated }; - const head = await deps.getHerdrListHead(opts.cwd); + const head = await deps.getHerdrListHead(opts.cwd, sprintWindow); if (!head.ok) return { dispatched: false, reason: 'wl-error', error: (head as { error?: string }).error }; if (head.items.length > 0) { // Item-scoped in-flight state (WL-0MUBEZ6PE002WLP4 / F3): resolved diff --git a/packages/herdr/src/index.test.ts b/packages/herdr/src/index.test.ts index 7b39d5514..7134b64e0 100644 --- a/packages/herdr/src/index.test.ts +++ b/packages/herdr/src/index.test.ts @@ -662,11 +662,12 @@ describe('createDowntimeDeps', () => { } }); - // Herdr list head wiring + extended dispatch window (WL-0MU6UL3GQ0015AA5): - // the default head is fetched with `wl next -n 30`; an explicit limit — used - // by the dispatcher's bounded window extension — is forwarded verbatim so - // the extension re-reads the SAME ranking path with a larger window. - it('getHerdrListHead forwards the requested limit to wl next and defaults to 30', async () => { + // Herdr list head wiring (WL-0MUNS8X97007C9H9): callers (the dispatcher's + // two selection paths) pass the live sprint-view `browseItemCount` limit, + // which is forwarded verbatim so the head equals the rendered worklist. A + // caller that passes no limit falls back to the sprint-view default (20) so + // an unwindowed head is never produced. + it('getHerdrListHead forwards the requested limit to wl next and defaults to the sprint-view default (20)', async () => { const mockExec = vi.fn().mockImplementation((_bin: string, args: string[]) => { if (args.includes('next')) { return Promise.resolve({ stdout: JSON.stringify({ success: true, results: [] }), stderr: '' }); @@ -681,7 +682,7 @@ describe('createDowntimeDeps', () => { const nextCalls = mockExec.mock.calls.filter(([, a]) => (a as string[]).includes('next')); expect(nextCalls).toHaveLength(2); - expect(nextCalls[0][1]).toEqual(expect.arrayContaining(['-n', '30'])); + expect(nextCalls[0][1]).toEqual(expect.arrayContaining(['-n', '20'])); expect(nextCalls[1][1]).toEqual(expect.arrayContaining(['-n', '60'])); }); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 48182836f..3a7339e72 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -939,12 +939,14 @@ export function createDowntimeDeps( getRunningDowntimePanes: runningPanesResolver, // Herdr list head (WL-0MTK1ILM2009QYB2): canonical ranking via fetcher → smart-selection → grouping. // The dispatcher treats this as the single ranking source; remaining safety gates are filters. - // Batch size 30: enough to filter through (code-freeze, dispatched-marker, single-flight) - // without excessive overhead; fetchNextItems applies mandatory-always, browseItemCount - // windowing, and regroupWorkItems grouping — the sole ranking path. + // Callers pass the live sprint-view window (`browseItemCount`, clamped 1–50) so the head + // equals the rendered worklist (WL-0MUNS8X97007C9H9 AC1). The fallback applies only to a + // caller that passes no limit; it defaults to the sprint-view default so an unwindowed head + // is never produced. `fetchNextItems` applies mandatory-always, browseItemCount windowing, + // and regroupWorkItems grouping — the sole ranking path. getHerdrListHead: async (_cwd: string, limit?: number): Promise<import('./downtime-worker.js').DowntimeHerdrListResult> => { try { - const items = await fetchNextItems(limit ?? 30); + const items = await fetchNextItems(limit ?? defaultSettings.browseItemCount); return { ok: true, items }; } catch (err) { return { ok: false, error: String(err) }; diff --git a/packages/herdr/src/settings.ts b/packages/herdr/src/settings.ts index 8fddb7eb7..88a42b6ae 100644 --- a/packages/herdr/src/settings.ts +++ b/packages/herdr/src/settings.ts @@ -7,6 +7,18 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { clampSyncInterval } from './auto-sync.js'; +import { + clampBrowseItemCount, + DEFAULT_BROWSE_ITEM_COUNT, + MIN_BROWSE_ITEM_COUNT, + MAX_BROWSE_ITEM_COUNT, +} from './browse-window.js'; + +// Re-exported for backward compatibility: the sprint-view window bounds and +// clamp moved to the leaf `browse-window.ts` module so the downtime dispatcher +// (`downtime-worker.ts`) can share them without an import cycle +// (WL-0MUNS8X97007C9H9 AC1). +export { clampBrowseItemCount, MIN_BROWSE_ITEM_COUNT, MAX_BROWSE_ITEM_COUNT, DEFAULT_BROWSE_ITEM_COUNT }; import { clampDowntimeIdleThresholdMs, clampDowntimeMarkerStaleWindowMs, @@ -145,7 +157,7 @@ export const defaultSettings: PluginSettings = { showIcons: true, autoSync: true, syncIntervalMs: 60000, - browseItemCount: 20, + browseItemCount: DEFAULT_BROWSE_ITEM_COUNT, showHelpText: true, downtimeEnabled: true, downtimeIdleThresholdMs: DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, @@ -165,25 +177,11 @@ export const defaultSettings: PluginSettings = { maxSyncStalenessMs: 60_000, }; -/** Minimum allowed browseItemCount. */ -export const MIN_BROWSE_ITEM_COUNT = 1; -/** Maximum allowed browseItemCount. */ -export const MAX_BROWSE_ITEM_COUNT = 50; - /** Minimum allowed maxSyncStalenessMs (1 s). */ export const MIN_MAX_SYNC_STALENESS_MS = 1_000; /** Maximum allowed maxSyncStalenessMs (5 min). */ export const MAX_MAX_SYNC_STALENESS_MS = 300_000; -/** - * Clamp a browseItemCount value to the supported [1, 50] range. - * Used at load time so persisted/parsed values cannot exceed the bounds. - */ -export function clampBrowseItemCount(value: number): number { - if (!Number.isFinite(value)) return defaultSettings.browseItemCount; - return Math.min(Math.max(Math.round(value), MIN_BROWSE_ITEM_COUNT), MAX_BROWSE_ITEM_COUNT); -} - /** * Clamp maxSyncStalenessMs to the supported [1000, 300000] range. */ From 0a45de0e18d68c43e6ab035b777e86a60b051085 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Sun, 4 Oct 2026 17:47:57 +0100 Subject: [PATCH 06/27] WL-0MUKYEXMK0033MFK: Implementation complete --- docs/dev/downtime-dispatcher.md | 49 +++- packages/herdr/README.md | 20 ++ packages/herdr/src/claim.test.ts | 45 +++ packages/herdr/src/downtime-log.test.ts | 64 +++++ packages/herdr/src/downtime-log.ts | 50 ++++ packages/herdr/src/downtime-worker.test.ts | 304 +++++++++++++++++++++ packages/herdr/src/downtime-worker.ts | 224 ++++++++++++++- packages/herdr/src/fetcher.ts | 25 ++ packages/herdr/src/index.ts | 10 + packages/herdr/src/settings.test.ts | 29 ++ packages/herdr/src/settings.ts | 15 + 11 files changed, 823 insertions(+), 12 deletions(-) diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index b05957a57..826b7b20e 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -348,6 +348,50 @@ re-read from settings every tick (live) and wired through `dispatchDowntimeWork`, `computeMostImportantItem`, `runCoordinationCheckIn` and `dispatchFromCoordination`. +### Per-item, per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK) + +The dispatcher enforces a **per-`(item, kind)` attempt budget** derived from +the rolling dispatch log (`countAttempts`), so an item that repeatedly closes +non-terminally (`agent-ended-no-terminal`, `audit-ended-no-result`) cannot +consume local-LLM slots across many idle cycles without escalation. + +- **Attempt count (AC1).** An attempt is a dispatch marker for the kind + (the same predicate the success-marker readers use). Non-terminal pane + closes count once, via the marker that opened their pane; `spawn-failed` + traces and post-spawn `enrichment` entries are excluded (they never opened + a pane). No new persistent store is introduced — the count is derived from + the existing bounded log and is **fail-open**: a missing/unreadable/corrupt + log yields 0. +- **Cap enforced (AC2).** Once the count reaches `maxAttempts`, the item is + excluded as a sequential filter on both paths — the Herdr-list-head scan + (`dispatchFromHerdrList`, including the critical-first scan) and the + coordination offer/leader path (`computeMostImportantItem`, + `dispatchFromCoordination`). The cap is evaluated after the + duplicate-dispatch marker guard and the in-flight guard, so it never + bypasses either. +- **Escalation (AC3).** Reaching the cap flags the item + `needsProducerReview = true` (`markNeedsProducerReview`, backed by + `wl reviewed <id> true`) so it surfaces for human triage. The flag is set + at most once per dispatch cycle, and once set the existing global + producer-review exclusion stops all further automatic dispatch. +- **Stage advancement resets (AC4).** Only markers whose dispatched-at + `stage` equals the item's current stage count. A stage advancement leaves + the earlier markers at the old stage, so the item gets a fresh budget at + its new stage. +- **Neutral skip (AC5).** The skip reason is `attempt-budget-exhausted` — + distinct from `no-candidate` and never a CLI-error strike (the worker's + three-strike rule is unaffected; the offer computation reports the distinct + `{ok:true, attemptBudgetHold:true}` variant so a budget-held backlog keeps + polling). + +**Configuration.** `downtimeMaxAttempts` is a plugin setting +(default **3**, `DEFAULT_DOWNTIME_MAX_ATTEMPTS`), clamped by +`clampDowntimeMaxAttempts` to **[1, 10]** +(`DOWNTIME_MAX_ATTEMPTS_MIN` / `..._MAX`). It is re-read from settings every +tick (live) and wired through `DowntimeWorkerConfig.config().maxAttempts` +into `dispatchDowntimeWork`, `computeMostImportantItem`, +`runCoordinationCheckIn` and `dispatchFromCoordination`. + ### Pane placement: project workspace first, `Dispatcher` fallback (WL-0MUR5FUWD00024XN) Automated downtime panes spawn **inside the owning project's herdr @@ -1125,6 +1169,7 @@ status refresh unchanged at 30s.** | No-candidate cooldown | 60 min (`downtimeNoCandidateCooldownMs`; probe-before-pause in coordination mode, re-offer cancels) | `downtime-worker.ts` | | Success-marker staleness window | **24 h** (`downtimeMarkerStaleWindowMs`; clamped to 1 h – 7 d; releases a stranded success marker at an unchanged stage, WL-0MU6UL0RJ008IHGT) | `DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS`, `clampDowntimeMarkerStaleWindowMs` (`downtime-worker.ts`) | | Non-terminal pane-close cooldown | **30 min** (`downtimeNonTerminalCooldownMs`; clamped to 1 min – 24 h; holds same-kind re-dispatch after a non-terminal pane close, WL-0MUKYERLZ006ELL5) | `DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS`, `clampDowntimeNonTerminalCooldownMs` (`downtime-worker.ts`) | +| Per-item/per-kind attempt cap | **3** (`downtimeMaxAttempts`; clamped to 1 – 10; flags `needsProducerReview` and stops re-dispatch of that kind once the cap is reached at the current stage, WL-0MUKYEXMK0033MFK) | `DEFAULT_DOWNTIME_MAX_ATTEMPTS`, `clampDowntimeMaxAttempts` (`downtime-worker.ts`), `countAttempts` (`downtime-log.ts`) | | Pane-closure reaper cadence | **60 s** (`PANE_CLOSE_REAPER_INTERVAL_MS`; gated by `paneCloseEnabled`, WL-0MUJL1NAH0042GOS) | `pane-close-scheduler.ts` | | Pane-closure idle threshold | **30 min** (`paneCloseIdleThresholdMinutes`; clamped to 1 min – 24 h) | `pane-close-scheduler.ts` | | (removed) Max running downtime panes | **none** — no client-side pane cap; the LLM idle / free-slot check is the concurrency limiter (WL-0MU2EP6JL006A1U3) | `downtime-worker.ts` | @@ -1137,7 +1182,9 @@ load (see `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` in (`downtimeMarkerStaleWindowMs`) is likewise configurable and clamped on load (`clampDowntimeMarkerStaleWindowMs`). The non-terminal pane-close cooldown (`downtimeNonTerminalCooldownMs`) is likewise configurable and clamped on load -(`clampDowntimeNonTerminalCooldownMs`). +(`clampDowntimeNonTerminalCooldownMs`). The per-item/per-kind attempt cap +(`downtimeMaxAttempts`) is likewise configurable and clamped on load +(`clampDowntimeMaxAttempts`). ## Files & runtime artifacts diff --git a/packages/herdr/README.md b/packages/herdr/README.md index a3d44c5e3..2db04020b 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -467,6 +467,13 @@ without per-slot data it fails closed to all-slots-free for `0 < N < total` no-candidate) released early when the item advances past its dispatched-at stage (default: `1800000` = 30 minutes, clamped to 1 min – 24 h) +- `downtimeMaxAttempts` — Per-item, per-kind dispatch-attempt cap. Once an + item has been dispatched this many times for a kind at its current worklog + stage (including non-terminal pane closes), it is flagged + `needsProducerReview` and excluded from further automatic dispatch of that + kind — a neutral skip (reason `attempt-budget-exhausted`, never a strike, + never a no-candidate) reset by a stage advancement (default: `3`, clamped + to 1 – 10) The worker polls `GET {proxyUrl}/llama/local/status` on the poll interval. Idle means: llama-server running, no active **local** query (when the proxy @@ -1002,6 +1009,19 @@ neutral — reason `non-terminal-cooldown`, never a strike and never filter runs on the direct Herdr-head path, the coordination offer computation, and leader dispatch of a remote offer. +**Per-item/per-kind attempt cap (WL-0MUKYEXMK0033MFK)** — the dispatcher +counts dispatch attempts per `(item, kind)` from the rolling dispatch log +(`countAttempts`) and, once the count reaches `downtimeMaxAttempts` +(default 3, clamped to 1 – 10) at the item's current stage, flags the item +`needsProducerReview` and stops auto-dispatching that kind. Non-terminal +closes count via their dispatch marker; `spawn-failed` traces and post-spawn +enrichment entries do not. Stage advancement resets the budget (earlier-stage +markers no longer count). The skip is neutral — reason +`attempt-budget-exhausted`, never a strike and never `no-candidate`. The same +filter runs on the direct Herdr-head path (including the critical-first +scan), the coordination offer computation, and leader dispatch of a remote +offer. A missing/unreadable/corrupt log is fail-open (count 0). + **Empty-backlog cooldown** — when the implement, plan, and intake `wl next` lookups and the critical-tier lookup genuinely return no candidate (the tab's project has nothing to dispatch), the worker enters a full **pause** diff --git a/packages/herdr/src/claim.test.ts b/packages/herdr/src/claim.test.ts index 5b24204d1..0e0d169ae 100644 --- a/packages/herdr/src/claim.test.ts +++ b/packages/herdr/src/claim.test.ts @@ -13,6 +13,7 @@ import { extractWorkItemId, claimItemForAgentCommand } from './index.js'; import { claimWorkItem, rollbackClaimWorkItem, + markNeedsProducerReviewWorkItem, setWorklogDir, resetWorklogDir, setExecFileAsync, @@ -281,6 +282,50 @@ describe('rollbackClaimWorkItem (fetcher, WL-0MT32F908002YFFA)', () => { }); }); +describe('markNeedsProducerReviewWorkItem (fetcher, WL-0MUKYEXMK0033MFK)', () => { + beforeEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + }); + + afterEach(() => { + resetExecFileAsync(); + resetWorklogDir(); + }); + + it('runs `wl reviewed <id> true --json`', async () => { + const mockFn = vi.fn().mockResolvedValue({ stdout: '{"success":true}', stderr: '' }); + setExecFileAsync(mockFn as any); + + const ok = await markNeedsProducerReviewWorkItem('WL-ABC'); + + expect(ok).toBe(true); + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toContain('reviewed'); + expect(callArgs).toContain('WL-ABC'); + expect(callArgs).toContain('true'); + expect(callArgs).toContain('--json'); + }); + + it('targets the given worklog root with --worklog-dir (cross-root flag)', async () => { + const mockFn = vi.fn().mockResolvedValue({ stdout: '{"success":true}', stderr: '' }); + setExecFileAsync(mockFn as any); + + await markNeedsProducerReviewWorkItem('AH-ABC', '/foreign/root'); + + const callArgs = mockFn.mock.calls[0][1] as string[]; + expect(callArgs).toContain('--worklog-dir'); + expect(callArgs[callArgs.indexOf('--worklog-dir') + 1]).toBe('/foreign/root/.worklog'); + }); + + it('returns false (never throws) when wl reviewed errors', async () => { + const mockFn = vi.fn().mockRejectedValue(new Error('worklog: no such item')); + setExecFileAsync(mockFn as any); + + await expect(markNeedsProducerReviewWorkItem('WL-NOTREAL')).resolves.toBe(false); + }); +}); + describe('claimItemForAgentCommand (onCommand seam)', () => { beforeEach(() => { resetExecFileAsync(); diff --git a/packages/herdr/src/downtime-log.test.ts b/packages/herdr/src/downtime-log.test.ts index 6d8754fc2..95f15635b 100644 --- a/packages/herdr/src/downtime-log.test.ts +++ b/packages/herdr/src/downtime-log.test.ts @@ -42,6 +42,7 @@ import { DOWNTIME_LOG_MAX_ENTRIES, recentAuditDispatchedItemIds, isNonTerminalCooldownActive, + countAttempts, } from './downtime-log.js'; import type { DowntimeLogEntry, RecentDispatchRow } from './downtime-log.js'; @@ -1429,3 +1430,66 @@ describe('isNonTerminalCooldownActive (WL-0MUKYERLZ006ELL5)', () => { expect(active(markers)).toBe(false); }); }); + +// ── countAttempts (WL-0MUKYEXMK0033MFK AC1/AC4/AC7e) ─────────────────── + +describe('countAttempts (WL-0MUKYEXMK0033MFK)', () => { + const marker = (overrides: Partial<DowntimeLogEntry> = {}): DowntimeLogEntry => ({ + itemId: 'WL-A', + kind: 'implement', + stage: 'plan_complete', + dispatchedAt: '2026-09-28T10:00:00.000Z', + ...overrides, + }); + + it('counts every dispatch marker for the (item, kind) at the current stage', () => { + const entries = [marker(), marker(), marker()]; + expect(countAttempts(entries, 'WL-A', 'implement', 'plan_complete')).toBe(3); + }); + + it('is scoped by kind — markers of another kind do not count', () => { + const entries = [marker(), marker({ kind: 'plan' })]; + expect(countAttempts(entries, 'WL-A', 'implement', 'plan_complete')).toBe(1); + }); + + it('is scoped by item — markers of another item do not count', () => { + const entries = [marker(), marker({ itemId: 'WL-B' })]; + expect(countAttempts(entries, 'WL-A', 'implement', 'plan_complete')).toBe(1); + }); + + it('resets the budget on stage advancement: only markers at the current stage count (AC4)', () => { + const entries = [ + marker({ stage: 'intake_complete' }), + marker({ stage: 'intake_complete' }), + marker({ stage: 'plan_complete' }), + ]; + // The item advanced to plan_complete: the two earlier attempts no longer count. + expect(countAttempts(entries, 'WL-A', 'implement', 'plan_complete')).toBe(1); + }); + + it('counts non-terminal pane closes once, via their dispatch marker', () => { + const entries = [ + marker(), + { entryType: 'pane-close', itemId: 'WL-A', kind: 'implement', stage: 'plan_complete', timestamp: '2026-09-28T10:05:00.000Z', reasonCode: 'agent-ended-no-terminal' } as DowntimeLogEntry, + ]; + expect(countAttempts(entries, 'WL-A', 'implement', 'plan_complete')).toBe(1); + }); + + it('excludes spawn-failed traces and post-spawn enrichment entries (no double count)', () => { + const entries = [ + marker(), + marker({ outcome: 'spawn-failed' }), + marker({ enrichment: true }), + ]; + expect(countAttempts(entries, 'WL-A', 'implement', 'plan_complete')).toBe(1); + }); + + it('does not count legacy markers without a recorded stage (fail-open under-count)', () => { + const entries = [{ itemId: 'WL-A', kind: 'implement', dispatchedAt: '2026-09-28T10:00:00.000Z' } as DowntimeLogEntry]; + expect(countAttempts(entries, 'WL-A', 'implement', 'plan_complete')).toBe(0); + }); + + it('yields 0 for an empty/missing log (AC7e fail-open)', () => { + expect(countAttempts([], 'WL-A', 'implement', 'plan_complete')).toBe(0); + }); +}); diff --git a/packages/herdr/src/downtime-log.ts b/packages/herdr/src/downtime-log.ts index 684c4f00e..01b7d5d9c 100644 --- a/packages/herdr/src/downtime-log.ts +++ b/packages/herdr/src/downtime-log.ts @@ -788,6 +788,56 @@ export function markerStillExcludes( return now - t <= stalenessWindowMs; } +// ── Per-item/per-kind dispatch-attempt budget (WL-0MUKYEXMK0033MFK) ─── + +/** + * Count dispatch ATTEMPTS for one `(itemId, kind)` pair at the item's + * CURRENT stage (WL-0MUKYEXMK0033MFK AC1). + * + * An "attempt" is a dispatch-marker entry for the kind — the same predicate + * the success-marker readers use (`isDispatchMarkerEntry`), so pane-close + * lifecycle entries are not counted on their own: each non-terminal close + * still counts exactly once, via the dispatch marker that opened its pane. + * Two entry kinds are excluded from the count because they do not represent + * an opened pane: + * + * - `outcome: 'spawn-failed'` traces — the pane never appeared (same + * non-excluding convention as the marker readers, WL-0MT32F908002YFFA); + * - `enrichment: true` entries — the best-effort post-spawn enrichment + * copies the marker verbatim and would otherwise double-count a single + * dispatch (WL-0MUBVL251006JAQ0). + * + * BUDGET RESET ON STAGE ADVANCEMENT (AC4): only markers whose dispatched-at + * `stage` equals the item's current stage count. A marker written before an + * advancement carries the old stage and no longer counts, giving the item a + * fresh budget at its new stage. Legacy markers without a recorded stage + * are treated as `''` and never match a real stage, so a partial/legacy log + * under-counts (fail-open) rather than over-counting. + * + * FAIL-OPEN (AC7e): a missing/unreadable/corrupt log yields no entries (see + * `readDowntimeLogEntries`) and therefore a count of 0 — a bad log can never + * block all dispatch. Never throws. + */ +export function countAttempts( + entries: DowntimeLogEntry[], + itemId: string, + kind: string, + itemStage: string | undefined, +): number { + const stage = itemStage ?? ''; + let count = 0; + for (const e of entries) { + if (!isDispatchMarkerEntry(e)) continue; + if (e.outcome === 'spawn-failed') continue; + if (e.enrichment === true) continue; + if (e.kind !== kind || e.itemId !== itemId) continue; + const entryStage = typeof e.stage === 'string' ? e.stage : ''; + if (entryStage !== stage) continue; + count += 1; + } + return count; +} + // ── Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5) ──────────── /** diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index 6ab7099e3..ecc480af2 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -69,6 +69,7 @@ import { createPerSlotIdleTracker, dispatchDowntimeWork, computeMostImportantItem, + dispatchFromCoordination, fetchExtendedHerdrItems, createDowntimeWorker, buildDowntimePrompt, @@ -103,6 +104,10 @@ import { clampDowntimeNoCandidateCooldownMs, clampDowntimeNonTerminalCooldownMs, clampDowntimeMarkerStaleWindowMs, + clampDowntimeMaxAttempts, + DEFAULT_DOWNTIME_MAX_ATTEMPTS, + DOWNTIME_MAX_ATTEMPTS_MIN, + DOWNTIME_MAX_ATTEMPTS_MAX, countFreeUnownedSlots, isSlotOwned, parseHerdrPaneListOutput, @@ -175,6 +180,7 @@ import { } from './leader-election.js'; import { writeCoordinationFile, + type CoordinationEntry, } from './coordination.js'; import { statusFixtures, @@ -12124,3 +12130,301 @@ describe('stale/empty slots fallback + count-based spare capacity (WL-0MUFP30T20 expect(deps.spawnAgentPane).not.toHaveBeenCalled(); }); }); + +// ── Per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK) ─────── +// +// A repeatedly non-terminal item must stop consuming dispatch slots. Once +// the rolling dispatch log records `maxAttempts` attempts for an +// `(item, kind)` at the item's current stage, the item is flagged +// `needsProducerReview` and excluded from further automatic dispatch of that +// kind, with a neutral `attempt-budget-exhausted` reason (never a strike, +// never `no-candidate`). A stage advancement resets the budget; a +// missing/corrupt log is fail-open (count 0). + +describe('per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK)', () => { + // 1s marker-stale window so same-stage markers written in the past are + // RELEASED (otherwise the duplicate-dispatch marker guard would skip the + // item before the budget filter runs). + const WINDOW_MS = 1_000; + const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); + }); + + function makeCwd(): string { + const dir = mkdtempSync(join(tmpdir(), 'dt-attempt-budget-')); + tempDirs.push(dir); + return dir; + } + + function writeRawLog(cwd: string, raw: string): void { + mkdirSync(join(cwd, '.worklog'), { recursive: true }); + writeFileSync(join(cwd, '.worklog', DOWNTIME_LOG_FILE), raw, 'utf8'); + } + + function writeLog(cwd: string, entries: Array<Record<string, unknown>>): void { + writeRawLog(cwd, entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); + } + + const implementHead = (id: string): DowntimeHerdrItem => ({ + id, + title: `Implement ${id}`, + status: 'open', + stage: 'plan_complete', + priority: 'high', + risk: 'Low', + effort: 'S', + sortIndex: 10, + }); + + const planHead = (id: string): DowntimeHerdrItem => ({ + id, + title: `Plan ${id}`, + status: 'open', + stage: 'intake_complete', + priority: 'medium', + sortIndex: 30, + }); + + const marker = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({ + itemId: 'IMP-1', + kind: 'implement', + stage: 'plan_complete', + dispatchedAt: ago(60_000), + ...overrides, + }); + + function depsFor(items: DowntimeHerdrItem[], overrides: Partial<DowntimeWorkerDeps> = {}): DowntimeWorkerDeps { + return makeDeps({ + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items }), + markNeedsProducerReview: vi.fn().mockResolvedValue(true), + ...overrides, + }); + } + + it('AC7a: below the cap the item is eligible for dispatch', async () => { + const cwd = makeCwd(); + writeLog(cwd, [marker(), marker()]); // 2 attempts < 3 + const deps = depsFor([implementHead('IMP-1')]); + + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd, + markerStaleWindowMs: WINDOW_MS, + maxAttempts: 3, + }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + expect(outcome.candidate?.id).toBe('IMP-1'); + expect(deps.markNeedsProducerReview).not.toHaveBeenCalled(); + }); + + it('AC7b: at the cap the item is skipped with the neutral attempt-budget-exhausted reason', async () => { + const cwd = makeCwd(); + writeLog(cwd, [marker(), marker(), marker()]); // 3 attempts == 3 + const deps = depsFor([implementHead('IMP-1')]); + + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd, + markerStaleWindowMs: WINDOW_MS, + maxAttempts: 3, + }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('attempt-budget-exhausted'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + // Neutral: never a CLI-error strike. + expect(deps.recordError).not.toHaveBeenCalled(); + }); + + it('AC7c: the producer-review flag is set exactly once when the cap is reached', async () => { + const cwd = makeCwd(); + writeLog(cwd, [marker(), marker(), marker()]); + const deps = depsFor([implementHead('IMP-1')]); + + await dispatchDowntimeWork(deps, { + model: 'plan', + cwd, + markerStaleWindowMs: WINDOW_MS, + maxAttempts: 3, + }); + + expect(deps.markNeedsProducerReview).toHaveBeenCalledTimes(1); + expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('IMP-1', cwd); + }); + + it('AC7d: a stage advancement resets the budget (earlier-stage attempts do not count)', async () => { + const cwd = makeCwd(); + // Three attempts recorded at the PRIOR stage; the item has since advanced + // to plan_complete, so the earlier attempts no longer count. + writeLog(cwd, [ + marker({ stage: 'intake_complete' }), + marker({ stage: 'intake_complete' }), + marker({ stage: 'intake_complete' }), + ]); + const deps = depsFor([implementHead('IMP-1')]); + + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd, + markerStaleWindowMs: WINDOW_MS, + maxAttempts: 3, + }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.candidate?.id).toBe('IMP-1'); + expect(deps.markNeedsProducerReview).not.toHaveBeenCalled(); + }); + + it('AC7e: a corrupt/missing log is fail-open (never blocks all dispatch)', async () => { + const cwd = makeCwd(); + writeRawLog(cwd, '{not valid json}\nalso-not-json\n'); + const deps = depsFor([implementHead('IMP-1')]); + + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd, + markerStaleWindowMs: WINDOW_MS, + maxAttempts: 3, + }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.candidate?.id).toBe('IMP-1'); + }); + + it('AC4: the budget is per-kind — attempts of another kind do not exhaust it', async () => { + const cwd = makeCwd(); + writeLog(cwd, [ + { itemId: 'IMP-1', kind: 'plan', stage: 'plan_complete', dispatchedAt: ago(60_000) }, + { itemId: 'IMP-1', kind: 'plan', stage: 'plan_complete', dispatchedAt: ago(60_000) }, + { itemId: 'IMP-1', kind: 'plan', stage: 'plan_complete', dispatchedAt: ago(60_000) }, + ]); + const deps = depsFor([implementHead('IMP-1')]); + + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd, + markerStaleWindowMs: WINDOW_MS, + maxAttempts: 3, + }); + + expect(outcome.dispatched).toBe(true); + expect(outcome.kind).toBe('implement'); + }); + + it('AC6 (offer path): computeMostImportantItem reports attemptBudgetHold, never noCandidate', async () => { + const cwd = makeCwd(); + writeLog(cwd, [marker(), marker(), marker()]); + const deps = depsFor([implementHead('IMP-1')]); + + const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, WINDOW_MS, undefined, 3); + + expect(result.ok).toBe(true); + if (result.ok) { + expect('attemptBudgetHold' in result).toBe(true); + expect('noCandidate' in result).toBe(false); + expect('candidate' in result).toBe(false); + } + expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('IMP-1', cwd); + }); + + it('AC6 (offer path): below the cap computeMostImportantItem still offers the item', async () => { + const cwd = makeCwd(); + writeLog(cwd, [marker(), marker()]); + const deps = depsFor([implementHead('IMP-1')]); + + const result = await computeMostImportantItem(deps, cwd, Date.now(), undefined, WINDOW_MS, undefined, 3); + + expect(result.ok).toBe(true); + if (result.ok && 'candidate' in result) { + expect(result.candidate.id).toBe('IMP-1'); + expect(result.kind).toBe('implement'); + } else { + throw new Error(`expected a candidate offer, got ${JSON.stringify(result)}`); + } + }); + + it('AC6 (leader/coordination path): dispatchFromCoordination enforces the cap and flags the item', async () => { + const cwd = makeCwd(); + const coordinationDir = makeCwd(); + writeLog(cwd, [marker(), marker(), marker()]); + const deps = makeDeps({ + fetchItem: vi.fn().mockResolvedValue({ + ok: true, + info: { + id: 'IMP-1', + title: 'Implement IMP-1', + status: 'open', + stage: 'plan_complete', + priority: 'high', + risk: 'Low', + effort: 'S', + sortIndex: 10, + }, + }), + markNeedsProducerReview: vi.fn().mockResolvedValue(true), + }); + const entries: CoordinationEntry[] = [{ + instanceId: 'inst-1', + workItemId: 'IMP-1', + directory: cwd, + worklogRoot: cwd, + assignedAt: ago(60_000), + lastUpdated: ago(60_000), + }]; + + const outcome = await dispatchFromCoordination(deps, entries, { + model: 'plan', + cwd, + coordinationDir, + markerStaleWindowMs: WINDOW_MS, + maxAttempts: 3, + }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('attempt-budget-exhausted'); + expect(deps.claimItem).not.toHaveBeenCalled(); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('IMP-1', cwd); + }); + + it('plan kind: the cap is enforced for a plan candidate too', async () => { + const cwd = makeCwd(); + writeLog(cwd, [ + { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60_000) }, + { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60_000) }, + { itemId: 'PLN-1', kind: 'plan', stage: 'intake_complete', dispatchedAt: ago(60_000) }, + ]); + const deps = depsFor([planHead('PLN-1')]); + + const outcome = await dispatchDowntimeWork(deps, { + model: 'plan', + cwd, + markerStaleWindowMs: WINDOW_MS, + maxAttempts: 3, + }); + + expect(outcome.dispatched).toBe(false); + expect(outcome.reason).toBe('attempt-budget-exhausted'); + expect(deps.markNeedsProducerReview).toHaveBeenCalledWith('PLN-1', cwd); + }); +}); + +describe('clampDowntimeMaxAttempts (WL-0MUKYEXMK0033MFK)', () => { + it('defaults on non-finite input', () => { + expect(clampDowntimeMaxAttempts(Number.NaN)).toBe(DEFAULT_DOWNTIME_MAX_ATTEMPTS); + expect(clampDowntimeMaxAttempts(Number.POSITIVE_INFINITY)).toBe(DEFAULT_DOWNTIME_MAX_ATTEMPTS); + }); + + it('clamps into the documented [1, 10] range', () => { + expect(clampDowntimeMaxAttempts(0)).toBe(DOWNTIME_MAX_ATTEMPTS_MIN); + expect(clampDowntimeMaxAttempts(-5)).toBe(DOWNTIME_MAX_ATTEMPTS_MIN); + expect(clampDowntimeMaxAttempts(99)).toBe(DOWNTIME_MAX_ATTEMPTS_MAX); + expect(clampDowntimeMaxAttempts(4)).toBe(4); + }); +}); diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 8520d5652..36fe74ddc 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -161,6 +161,7 @@ import { dispatchedItemMarkers as _dispatchedMarkers, markerStillExcludes as _markerStillExcludes, isNonTerminalCooldownActive as _isNonTerminalCooldownActive, + countAttempts as _countAttempts, appendPaneCloseLogEntry as _appendPaneCloseLogEntry, type DispatchMarker, type DowntimeLogEntry, @@ -289,6 +290,23 @@ export const DOWNTIME_NON_TERMINAL_COOLDOWN_FLOOR_MS = 60 * 1000; */ export const DOWNTIME_NON_TERMINAL_COOLDOWN_MAX_MS = 24 * 60 * 60 * 1000; +/** + * Default per-item, per-kind dispatch-ATTEMPT cap (WL-0MUKYEXMK0033MFK). + * + * After this many dispatch attempts (panes opened) for the same `(item, + * kind)` at the SAME worklog stage, the item is excluded from further + * automatic dispatch of that kind and flagged for producer review. Chosen + * from the observed pattern (2–4 non-terminal retries before human + * intervention is warranted). + */ +export const DEFAULT_DOWNTIME_MAX_ATTEMPTS = 3; + +/** Hard floor for `maxAttempts` (1): at least one automatic attempt is always allowed. */ +export const DOWNTIME_MAX_ATTEMPTS_MIN = 1; + +/** Hard ceiling for `maxAttempts` (10): above this the cap is effectively disabled. */ +export const DOWNTIME_MAX_ATTEMPTS_MAX = 10; + /** * Per-process dispatch-PIPELINE single-flight bound (WL-0MT50LKAK001EF5Q). * @@ -1462,6 +1480,8 @@ export type MostImportantItemResult = | { ok: true; inFlightHold: true } /** The only remaining offerable candidates are held by the non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): nothing offerable NOW, but the backlog is NOT empty — the caller must not pause or drop the entry. */ | { ok: true; nonTerminalCooldownHold: true } + /** The only remaining offerable candidates have exhausted their per-item/per-kind attempt budget (WL-0MUKYEXMK0033MFK): nothing offerable NOW, but the backlog is NOT empty — the caller must not pause or drop the entry. */ + | { ok: true; attemptBudgetHold: true } | { ok: false; error?: string }; @@ -2188,6 +2208,19 @@ export interface DowntimeWorkerDeps { original: DowntimeClaimExpected, cwd: string, ): Promise<boolean>; + /** + * Flag an item for producer review (`needsProducerReview = true`) after its + * automatic dispatch budget is exhausted (WL-0MUKYEXMK0033MFK AC3). Called + * once when the per-item/per-kind attempt count reaches `maxAttempts`, so a + * repeatedly non-terminal item surfaces for human triage instead of + * looping through idle cycles forever. `cwd` is the item's worklog root + * (cross-root safe). OPTIONAL — when absent the dispatcher still SKIPS the + * budget-exhausted item (the cap is enforced) but cannot persist the flag + * (legacy/test callers). Must never throw (fail-closed): a flag write + * failure must not crash the worker, and the item stays budget-skipped so + * the flag is retried on the next cycle. + */ + markNeedsProducerReview?(itemId: string, cwd: string): Promise<boolean>; /** * Record a persistent CLI-error event (three consecutive wl failures). * Must never throw (fail-closed): logging must not crash the worker. @@ -2568,14 +2601,59 @@ export function selectCriticalFirstCandidates( return out; } +/** + * True when the per-item, per-kind dispatch-ATTEMPT budget is exhausted + * (WL-0MUKYEXMK0033MFK AC1/AC2): the rolling dispatch log records + * `maxAttempts` or more dispatch attempts for this `(itemId, kind)` at the + * item's CURRENT stage. + * + * The count is derived from the log (`countAttempts`), scoped to the current + * stage so a stage advancement resets the budget (AC4), and fail-open: a + * missing/corrupt/empty log yields 0 (AC7e). A non-finite or < 1 cap is + * likewise fail-open (never blocks dispatch). + */ +export function isAttemptBudgetExhausted( + entries: DowntimeLogEntry[], + itemId: string, + kind: DowntimeSkillKind, + itemStage: string | undefined, + maxAttempts: number, +): boolean { + if (!Number.isFinite(maxAttempts) || maxAttempts < 1) return false; // fail-open + return _countAttempts(entries, itemId, kind, itemStage) >= maxAttempts; +} + +/** + * Best-effort producer-review flag for a budget-exhausted item + * (WL-0MUKYEXMK0033MFK AC3). Never throws; a missing dep or a failed write is + * swallowed — the caller still skips the item via the budget filter, so the + * flag is retried on the next cycle (fail-closed). + */ +async function flagBudgetExhausted( + deps: DowntimeWorkerDeps, + itemId: string, + cwd: string, +): Promise<void> { + if (typeof deps.markNeedsProducerReview !== 'function') return; + try { + await deps.markNeedsProducerReview(itemId, cwd); + } catch { + // fail-closed: a flag write failure must never crash the worker + } +} + async function dispatchFromHerdrList( deps: DowntimeWorkerDeps, items: DowntimeHerdrItem[], - ctx: { cwd: string; model: string; freeSlots?: number; frozen: boolean; panesEligible: boolean; auditEligible: boolean; reviewGate: ReviewQueueGateResult | null; markerStaleWindowMs: number; nonTerminalCooldownMs: number; inFlight: InFlightPanes; spawnConfig?: DowntimeSpawnConfig }, - flags: { auditInFlight: boolean; auditCheckFailed: boolean; auditCheckError?: string; freshnessSkip: boolean; reviewHeld: boolean; cooldownHeld: boolean; auditHostSaturated: boolean }, + ctx: { cwd: string; model: string; freeSlots?: number; frozen: boolean; panesEligible: boolean; auditEligible: boolean; reviewGate: ReviewQueueGateResult | null; markerStaleWindowMs: number; nonTerminalCooldownMs: number; maxAttempts: number; inFlight: InFlightPanes; spawnConfig?: DowntimeSpawnConfig }, + flags: { auditInFlight: boolean; auditCheckFailed: boolean; auditCheckError?: string; freshnessSkip: boolean; reviewHeld: boolean; cooldownHeld: boolean; budgetExhausted: boolean; auditHostSaturated: boolean }, ): Promise<DowntimeDispatchOutcome | null> { if (items.length === 0) return null; const entries = await _readDowntimeEntries(ctx.cwd); + // Item ids already flagged this call so the producer-review flag is set at + // most ONCE per dispatch even when the same critical item is visited by + // both the critical-first scan and the normal scan (AC7c). + const flaggedForBudget = new Set<string>(); // Staleness-aware success-marker maps (WL-0MU6UL0RJ008IHGT): id → the // dispatched-at stage + timestamp, read once per dispatch and consulted // below through `markerStillExcludes`. @@ -2621,6 +2699,20 @@ async function dispatchFromHerdrList( ctx.markerStaleWindowMs, ); if (!guard.escalate) continue; + // Per-item/per-kind attempt budget (WL-0MUKYEXMK0033MFK AC1/AC2/AC3): a + // critical item that has exhausted its automatic attempts is flagged for + // producer review and skipped — escalation, not starvation. The budget is + // a stronger escalation than the in-flight guard, so it is evaluated + // after the guard (a genuinely live pane is never pre-empted) but before + // the dispatch attempt. + if (isAttemptBudgetExhausted(entries, candidate.id, kind, candidate.stage, ctx.maxAttempts)) { + if (!flaggedForBudget.has(candidate.id)) { + flaggedForBudget.add(candidate.id); + await flagBudgetExhausted(deps, candidate.id, ctx.cwd); + } + flags.budgetExhausted = true; + continue; + } const cand: DowntimeCandidate = { id: candidate.id, title: candidate.title, @@ -2699,6 +2791,20 @@ async function dispatchFromHerdrList( ) { continue; } + // Per-item/per-kind attempt budget (WL-0MUKYEXMK0033MFK AC1/AC2/AC3): + // once the item has been dispatched `maxAttempts` times for this kind at + // its current stage (including non-terminal closes — each is a marker), + // it is flagged for producer review and excluded from further automatic + // dispatch. Neutral sequential filter: never a strike, never + // 'no-candidate' (AC5). + if (isAttemptBudgetExhausted(entries, item.id, k, item.stage, ctx.maxAttempts)) { + if (!flaggedForBudget.has(item.id)) { + flaggedForBudget.add(item.id); + await flagBudgetExhausted(deps, item.id, ctx.cwd); + } + flags.budgetExhausted = true; + continue; + } // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): a neutral // sequential filter — hold an item whose previous same-kind pane closed // without reaching a terminal stage until the cooldown elapses (or the @@ -3470,6 +3576,7 @@ export async function computeMostImportantItem( browseItemCount: number = DEFAULT_BROWSE_ITEM_COUNT, markerStaleWindowMs: number = DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, nonTerminalCooldownMs: number = DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, + maxAttempts: number = DEFAULT_DOWNTIME_MAX_ATTEMPTS, ): Promise<MostImportantItemResult> { // The check-in (and the worker's no-candidate probe) requires the Herdr // head lookup: without it there is no canonical ranking to offer from — @@ -3524,6 +3631,12 @@ export async function computeMostImportantItem( // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): neutral hold — // the backlog is not empty, so the caller must not pause. let heldByCooldown = false; + // Per-item/per-kind attempt-budget hold (WL-0MUKYEXMK0033MFK): neutral hold + // — the backlog is not empty, so the caller must not pause. An item already + // flagged this call is recorded so the producer-review flag is set at most + // once (AC7c). + let heldByBudget = false; + const flaggedForBudget = new Set<string>(); // Item-scoped in-flight guard (WL-0MUBEZ6PE002WLP4 / F4 // WL-0MUBVKYH5009CGBI): resolved for THIS instance's OWN root (`cwd`) — @@ -3558,6 +3671,18 @@ export async function computeMostImportantItem( heldByInFlight = true; continue; } + // Per-item/per-kind attempt budget (WL-0MUKYEXMK0033MFK AC1/AC2/AC3): a + // critical item that has exhausted its automatic attempts is flagged and + // NOT offered. Evaluated after the in-flight guard so a live pane is + // never pre-empted. + if (isAttemptBudgetExhausted(entries, criticalFirst.item.id, criticalFirst.kind, criticalFirst.item.stage, maxAttempts)) { + if (!flaggedForBudget.has(criticalFirst.item.id)) { + flaggedForBudget.add(criticalFirst.item.id); + await flagBudgetExhausted(deps, criticalFirst.item.id, cwd); + } + heldByBudget = true; + continue; + } const candidate: DowntimeCandidate = { id: criticalFirst.item.id, title: criticalFirst.item.title, @@ -3602,6 +3727,17 @@ export async function computeMostImportantItem( ) { continue; } + // Per-item/per-kind attempt budget (WL-0MUKYEXMK0033MFK AC1/AC2/AC3): + // flag the item for producer review and do not offer it once its + // attempts at the current stage reach the cap. Neutral hold (AC5). + if (isAttemptBudgetExhausted(entries, item.id, k, item.stage, maxAttempts)) { + if (!flaggedForBudget.has(item.id)) { + flaggedForBudget.add(item.id); + await flagBudgetExhausted(deps, item.id, cwd); + } + heldByBudget = true; + continue; + } // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): neutral // sequential filter (same placement as the dispatch path). if (_isNonTerminalCooldownActive(entries, item.id, k, item.stage, nonTerminalCooldownMs, now)) { @@ -3671,9 +3807,11 @@ export async function computeMostImportantItem( ? { ok: true, reviewQueueHold: true } : heldByCooldown ? { ok: true, nonTerminalCooldownHold: true } - : heldByInFlight - ? { ok: true, inFlightHold: true } - : { ok: true, noCandidate: true }; + : heldByBudget + ? { ok: true, attemptBudgetHold: true } + : heldByInFlight + ? { ok: true, inFlightHold: true } + : { ok: true, noCandidate: true }; } /** @@ -3727,7 +3865,7 @@ export async function computeMostImportantItem( export async function dispatchFromCoordination( deps: DowntimeWorkerDeps, entries: CoordinationEntry[], - opts: { model: string; cwd: string; coordinationDir: string; freeSlots?: number; leaseTtlMs?: number; browseItemCount?: number; nonTerminalCooldownMs?: number; spawnConfig?: DowntimeSpawnConfig }, + opts: { model: string; cwd: string; coordinationDir: string; freeSlots?: number; leaseTtlMs?: number; browseItemCount?: number; nonTerminalCooldownMs?: number; maxAttempts?: number; spawnConfig?: DowntimeSpawnConfig }, now: number = Date.now(), ): Promise<DowntimeDispatchOutcome> { // The leader path REQUIRES the item-fetch dep: without it the leader can @@ -3797,6 +3935,13 @@ export async function dispatchFromCoordination( }; const cooldownMs = opts.nonTerminalCooldownMs ?? DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS; let cooldownHold = false; + // Per-item/per-kind attempt-budget hold (WL-0MUKYEXMK0033MFK): neutral hold + // — the backlog is not empty, so the caller must not pause or drop the + // entry. A flagged set keeps the producer-review flag to at most one write + // per dispatch (AC7c). + const maxAttempts = opts.maxAttempts ?? DEFAULT_DOWNTIME_MAX_ATTEMPTS; + let budgetHold = false; + const flaggedForBudget = new Set<string>(); // Scheduled-prompts tier (WL-0MSS1Q5ER007QDKX): FIRST dispatch stage, // gated by the SAME fresh-read code-freeze marker as the audit/implement @@ -3872,6 +4017,18 @@ export async function dispatchFromCoordination( removeEntry(opts.coordinationDir, entry.instanceId); continue; } + // Per-item/per-kind attempt budget (WL-0MUKYEXMK0033MFK AC1/AC2/AC3): + // flag the item for producer review and keep the entry (it is still a + // valid offer once a human clears the flag) but skip it this cycle. + // Neutral — never a strike, never 'no-candidate' (AC5). + if (isAttemptBudgetExhausted(await cooldownEntriesForRoot(worklogRoot), result.info.id, kind, result.info.stage, maxAttempts)) { + if (!flaggedForBudget.has(result.info.id)) { + flaggedForBudget.add(result.info.id); + await flagBudgetExhausted(deps, result.info.id, worklogRoot); + } + budgetHold = true; + continue; + } // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5): neutral // sequential filter — keep the entry (it is still a valid offer) but // skip it this cycle while the cooldown holds. Never a strike, never @@ -3999,7 +4156,9 @@ export async function dispatchFromCoordination( ? { dispatched: false, reason: REVIEW_QUEUE_HOLD_REASON } : cooldownHold ? { dispatched: false, reason: 'non-terminal-cooldown' } - : inFlightHold + : budgetHold + ? { dispatched: false, reason: 'attempt-budget-exhausted' } + : inFlightHold ? { dispatched: false, reason: 'in-flight-pane' } : { dispatched: false, reason: 'no-candidate' }; } @@ -4027,11 +4186,13 @@ export async function runCoordinationCheckIn( markerStaleWindowMs?: number; /** Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5); defaults to DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS. */ nonTerminalCooldownMs?: number; + /** Per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK); defaults to DEFAULT_DOWNTIME_MAX_ATTEMPTS. */ + maxAttempts?: number; }, now: number = Date.now(), ): Promise<{ offered: string | null; updated: boolean }> { const current = getEntry(coordinator.coordinationDir, coordinator.instanceId); - const result = await computeMostImportantItem(deps, coordinator.cwd, now, coordinator.browseItemCount, coordinator.markerStaleWindowMs, coordinator.nonTerminalCooldownMs); + const result = await computeMostImportantItem(deps, coordinator.cwd, now, coordinator.browseItemCount, coordinator.markerStaleWindowMs, coordinator.nonTerminalCooldownMs, coordinator.maxAttempts); if (!result.ok) { // wl/CLI errors — keep the existing entry (fail-open), retry next check-in. return { offered: current?.workItemId ?? null, updated: false }; @@ -4236,6 +4397,13 @@ export async function dispatchDowntimeWork( * `DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS`. */ nonTerminalCooldownMs?: number; + /** + * Per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK): once an + * item has been dispatched this many times for a kind at its current + * stage it is flagged for producer review and skipped. Optional — absent + * falls back to `DEFAULT_DOWNTIME_MAX_ATTEMPTS` (3). + */ + maxAttempts?: number; /** Mode-aware Phase 2 parallelism inputs (WL-0MT50S9JW001DHME). */ spawnConfig?: DowntimeSpawnConfig; }, @@ -4339,7 +4507,7 @@ export async function dispatchDowntimeWork( // work exists, so the fallback is unreachable there and will be removed // once the suite is fully on Herdr-head stubs. { - const flags = { auditInFlight, auditCheckFailed, auditCheckError: undefined, freshnessSkip, reviewHeld: false, cooldownHeld: false, auditHostSaturated }; + const flags = { auditInFlight, auditCheckFailed, auditCheckError: undefined, freshnessSkip, reviewHeld: false, cooldownHeld: false, budgetExhausted: false, auditHostSaturated }; const head = await deps.getHerdrListHead(opts.cwd, sprintWindow); if (!head.ok) return { dispatched: false, reason: 'wl-error', error: (head as { error?: string }).error }; if (head.items.length > 0) { @@ -4349,7 +4517,7 @@ export async function dispatchDowntimeWork( // spawn. `resolveInFlightPanes` never throws — an unavailable query // degrades to the marker-TTL fallback inside the decision table. const inFlight = await resolveInFlightPanes(deps, opts.cwd); - const ctx = { cwd: opts.cwd, model: opts.model, freeSlots, frozen, panesEligible, auditEligible, reviewGate, markerStaleWindowMs: opts.markerStaleWindowMs ?? DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, nonTerminalCooldownMs: opts.nonTerminalCooldownMs ?? DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, inFlight, spawnConfig: opts.spawnConfig }; + const ctx = { cwd: opts.cwd, model: opts.model, freeSlots, frozen, panesEligible, auditEligible, reviewGate, markerStaleWindowMs: opts.markerStaleWindowMs ?? DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, nonTerminalCooldownMs: opts.nonTerminalCooldownMs ?? DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, maxAttempts: opts.maxAttempts ?? DEFAULT_DOWNTIME_MAX_ATTEMPTS, inFlight, spawnConfig: opts.spawnConfig }; const herdrOutcome = await dispatchFromHerdrList(deps, head.items, ctx, flags); if (herdrOutcome !== null) return herdrOutcome; @@ -4377,6 +4545,7 @@ export async function dispatchDowntimeWork( freshnessSkip = flags.freshnessSkip; const reviewHeld = flags.reviewHeld; const cooldownHeld = flags.cooldownHeld; + const budgetExhausted = flags.budgetExhausted; // Herdr list had items but every one was filtered by a safety gate: // compose the terminal reason and DO NOT fall through to the legacy // chain (AC2 — gates are filters, not a fallback ranking). @@ -4405,6 +4574,12 @@ export async function dispatchDowntimeWork( // empty, so the no-candidate cooldown must not fire // (WL-0MUKYERLZ006ELL5 AC3/AC6). if (cooldownHeld) return { dispatched: false, reason: 'non-terminal-cooldown' }; + // Per-item/per-kind attempt budget exhausted (WL-0MUKYEXMK0033MFK + // AC2/AC5): neutral 'attempt-budget-exhausted', NEVER 'no-candidate' + // — the backlog is not empty and no strike is recorded; polling + // continues so other work still dispatches and the flag write is + // retried if it failed. + if (budgetExhausted) return { dispatched: false, reason: 'attempt-budget-exhausted' }; return { dispatched: false, reason: 'no-candidate' }; } // Genuinely empty Herdr list → keep flags and fall through to the @@ -5151,6 +5326,14 @@ export interface DowntimeWorkerConfig { * for backward compat — defaults to DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS. */ nonTerminalCooldownMs?: number; + /** + * Per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK). Optional + * for backward compat — defaults to DEFAULT_DOWNTIME_MAX_ATTEMPTS (3). + * Once an item reaches the cap for a kind at its current stage it is + * flagged for producer review and excluded from further automatic + * dispatch of that kind. + */ + maxAttempts?: number; /** Sprint-complete threshold (parent WL-0MTHSHN5V008R5L0). Optional for backward compat — defaults to 20. */ browseItemCount?: number; /** @@ -5704,6 +5887,7 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker browseItemCount: cfg.browseItemCount, markerStaleWindowMs: cfg.markerStaleWindowMs, nonTerminalCooldownMs: cfg.nonTerminalCooldownMs, + maxAttempts: cfg.maxAttempts, }, tickNow); lastOfferEmpty = checkIn.offered === null; // WL-0MTEZ4XZJ006Y9U7 (AC2): a successful re-offer proves the @@ -6088,6 +6272,7 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker freeSlots, browseItemCount: cfg.browseItemCount, nonTerminalCooldownMs: cfg.nonTerminalCooldownMs, + maxAttempts: cfg.maxAttempts, leaseTtlMs: opts.leaseTtlSeconds ? opts.leaseTtlSeconds * 1000 : DEFAULT_LEASE_TTL_SECONDS * 1000, @@ -6108,6 +6293,8 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker markerStaleWindowMs: cfg.markerStaleWindowMs, // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5). nonTerminalCooldownMs: cfg.nonTerminalCooldownMs, + // Per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK). + maxAttempts: cfg.maxAttempts, spawnConfig, }); // Record the refusal reason (WL-0MU8808ZY0091JIA): actionable @@ -6152,7 +6339,7 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker // and reset the idle tracker (fresh full idle period required // after the pause). if (opts.coordinationDir && typeof opts.deps.fetchItem === 'function') { - const probe = await computeMostImportantItem(opts.deps, cfg.cwd, Date.now(), cfg.browseItemCount, cfg.markerStaleWindowMs, cfg.nonTerminalCooldownMs); + const probe = await computeMostImportantItem(opts.deps, cfg.cwd, Date.now(), cfg.browseItemCount, cfg.markerStaleWindowMs, cfg.nonTerminalCooldownMs, cfg.maxAttempts); if (probe.ok && 'noCandidate' in probe && probe.noCandidate) { errorStrikes = 0; // the CLI answered — it is healthy cooldownUntil = Date.now() + cfg.noCandidateCooldownMs; @@ -7240,6 +7427,21 @@ export function clampDowntimeMarkerStaleWindowMs(value: number): number { ); } +/** + * Clamp the per-item, per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK): + * reject negative/non-finite (fall back to the 3-attempt default) and clamp + * to [DOWNTIME_MAX_ATTEMPTS_MIN, DOWNTIME_MAX_ATTEMPTS_MAX] (1–10) so the + * control can neither disable retries entirely nor be set so high that a + * stuck item loops indefinitely. + */ +export function clampDowntimeMaxAttempts(value: number): number { + if (!Number.isFinite(value)) return DEFAULT_DOWNTIME_MAX_ATTEMPTS; + return Math.min( + Math.max(Math.round(value), DOWNTIME_MAX_ATTEMPTS_MIN), + DOWNTIME_MAX_ATTEMPTS_MAX, + ); +} + // ── Round-robin helpers (WL-0MTJE0FXC006WAOX) ────────────────────────── /** Internal tier-group entry: a CoordinationEntry paired with its dispatch info. */ diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index 1210647b1..71f960eb6 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -960,3 +960,28 @@ export async function rollbackClaimWorkItem( return false; } } + +/** + * Flag a work item as needing producer review (`needsProducerReview = true`) + * via `wl reviewed <id> true` (WL-0MUKYEXMK0033MFK AC3). The downtime + * dispatcher calls this once an item exhausts its per-item/per-kind attempt + * budget so it surfaces for human triage instead of looping through idle + * cycles. `worklogRoot` targets that root's database via per-call + * `--worklog-dir` (the same cross-root convention as `claimWorkItem`). + * + * Idempotent: setting the flag true when it is already true is a no-op. + * Never throws — a failure resolves `false` (fail-closed: the dispatcher + * still skips the budget-exhausted item and retries the flag next cycle). + */ +export async function markNeedsProducerReviewWorkItem( + id: string, + worklogRoot?: string, +): Promise<boolean> { + try { + const dirOverride = worklogRoot !== undefined ? join(worklogRoot, '.worklog') : undefined; + await runWl(['reviewed', id, 'true'], true, CLAIM_TIMEOUT_MS, dirOverride); + return true; + } catch { + return false; + } +} diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 3a7339e72..53a675271 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -34,6 +34,7 @@ import { setWorklogDir, claimWorkItem, rollbackClaimWorkItem, + markNeedsProducerReviewWorkItem, getExecFileAsync, buildWlArgs, buildWlArgsForRoot, @@ -1652,6 +1653,13 @@ export function createDowntimeDeps( // Race-safe (--if-status in_progress) and fail-closed (never throws). return rollbackClaimWorkItem(itemId, original, cwd); }, + // Producer-review flag (WL-0MUKYEXMK0033MFK AC3): set + // `needsProducerReview = true` when an item exhausts its automatic + // dispatch-attempt budget so it surfaces for human triage. Cross-root + // (`cwd` is the item's worklog root) and fail-closed (never throws). + async markNeedsProducerReview(itemId: string, cwd: string): Promise<boolean> { + return markNeedsProducerReviewWorkItem(itemId, cwd); + }, }; } @@ -1883,6 +1891,8 @@ async function main(): Promise<void> { noCandidateCooldownMs: s.downtimeNoCandidateCooldownMs, // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5). nonTerminalCooldownMs: s.downtimeNonTerminalCooldownMs, + // Per-item/per-kind dispatch-attempt cap (WL-0MUKYEXMK0033MFK). + maxAttempts: s.downtimeMaxAttempts, // Dispatched success-marker staleness window (WL-0MU6UL0RJ008IHGT). markerStaleWindowMs: s.downtimeMarkerStaleWindowMs, browseItemCount: s.browseItemCount, diff --git a/packages/herdr/src/settings.test.ts b/packages/herdr/src/settings.test.ts index b9647255f..30b5494a7 100644 --- a/packages/herdr/src/settings.test.ts +++ b/packages/herdr/src/settings.test.ts @@ -409,3 +409,32 @@ describe('paneClose settings (WL-0MUJL1NAH0042GOS)', () => { expect(loadSettings(path).paneCloseGracePeriodMinutes).toBe(5); }); }); + +// ── downtimeMaxAttempts (WL-0MUKYEXMK0033MFK) ───────────────────────── + +describe('downtimeMaxAttempts setting (WL-0MUKYEXMK0033MFK)', () => { + it('defaults to 3', () => { + expect(defaultSettings.downtimeMaxAttempts).toBe(3); + }); + + it('a persisted value overrides the default', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, downtimeMaxAttempts: 7 }); + expect(loadSettings(path).downtimeMaxAttempts).toBe(7); + }); + + it('clamps an out-of-range persisted value into [1, 10]', () => { + const path = tempSettingsPath(); + saveSettings(path, { ...defaultSettings, downtimeMaxAttempts: 999 }); + expect(loadSettings(path).downtimeMaxAttempts).toBe(10); + + saveSettings(path, { ...defaultSettings, downtimeMaxAttempts: 0 }); + expect(loadSettings(path).downtimeMaxAttempts).toBe(1); + }); + + it('falls back to the default when the persisted value is not a number', () => { + const path = tempSettingsPath(); + writeFileSync(path, JSON.stringify({ ...defaultSettings, downtimeMaxAttempts: 'lots' }), 'utf-8'); + expect(loadSettings(path).downtimeMaxAttempts).toBe(3); + }); +}); diff --git a/packages/herdr/src/settings.ts b/packages/herdr/src/settings.ts index 88a42b6ae..06a58c81b 100644 --- a/packages/herdr/src/settings.ts +++ b/packages/herdr/src/settings.ts @@ -22,12 +22,14 @@ export { clampBrowseItemCount, MIN_BROWSE_ITEM_COUNT, MAX_BROWSE_ITEM_COUNT, DEF import { clampDowntimeIdleThresholdMs, clampDowntimeMarkerStaleWindowMs, + clampDowntimeMaxAttempts, clampDowntimeNoCandidateCooldownMs, clampDowntimeNonTerminalCooldownMs, clampDowntimePollInterval, clampDowntimeRequiredFreeSlots, DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS, DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, + DEFAULT_DOWNTIME_MAX_ATTEMPTS, DEFAULT_DOWNTIME_MODEL, DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, @@ -98,6 +100,15 @@ export interface PluginSettings { * Neutral skip, never a strike. Default 30 min; clamped to [1 min, 24 h]. */ downtimeNonTerminalCooldownMs: number; + /** + * Per-item, per-kind dispatch-ATTEMPT cap (WL-0MUKYEXMK0033MFK). Once an + * item has been dispatched this many times for a kind at its current + * worklog stage (including non-terminal pane closes), it is flagged + * `needsProducerReview` and excluded from further automatic dispatch of + * that kind. A stage advancement resets the budget. Default 3; clamped to + * [1, 10]. + */ + downtimeMaxAttempts: number; /** * Dispatched success-marker staleness window (WL-0MU6UL0RJ008IHGT): a * SUCCESS dispatch marker whose item is still at the marker's dispatched-at @@ -167,6 +178,7 @@ export const defaultSettings: PluginSettings = { downtimeModel: DEFAULT_DOWNTIME_MODEL, downtimeNoCandidateCooldownMs: DEFAULT_DOWNTIME_NO_CANDIDATE_COOLDOWN_MS, downtimeNonTerminalCooldownMs: DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS, + downtimeMaxAttempts: DEFAULT_DOWNTIME_MAX_ATTEMPTS, downtimeMarkerStaleWindowMs: DEFAULT_DOWNTIME_MARKER_STALE_WINDOW_MS, modeSwitchEnabled: true, modeSwitchIdleThresholdMs: DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS, @@ -264,6 +276,9 @@ export function loadSettings(settingsPath?: string): PluginSettings { downtimeNonTerminalCooldownMs: typeof parsed.downtimeNonTerminalCooldownMs === 'number' ? clampDowntimeNonTerminalCooldownMs(parsed.downtimeNonTerminalCooldownMs) : defaultSettings.downtimeNonTerminalCooldownMs, + downtimeMaxAttempts: typeof parsed.downtimeMaxAttempts === 'number' + ? clampDowntimeMaxAttempts(parsed.downtimeMaxAttempts) + : defaultSettings.downtimeMaxAttempts, downtimeMarkerStaleWindowMs: typeof parsed.downtimeMarkerStaleWindowMs === 'number' ? clampDowntimeMarkerStaleWindowMs(parsed.downtimeMarkerStaleWindowMs) : defaultSettings.downtimeMarkerStaleWindowMs, From 84d714bc2f60cfbcccc6ee0617bc03a4d49061b7 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Sun, 4 Oct 2026 22:13:10 +0100 Subject: [PATCH 07/27] OSL-0MUU1HBAE000KWHC: wl doctor check for resolvable podcast Key Files scripts Adds an artifact-based check to `wl doctor`: a podcast item at a script-bearing stage (plan_complete/in_review/done) whose Key Files has no .podcast.md path that resolves under the worklog root is reported with an actionable message, as is any podcast item whose listed .podcast.md path does not resolve. `wl doctor --fix` backfills the canonical worklog-root-relative path when a matching script is found. Refs OSL-0MUU1HBAE000KWHC. --- DOCTOR_AND_MIGRATIONS.md | 1 + docs/FILE_PATH_CONVENTION.md | 3 + src/commands/doctor.ts | 10 +- src/doctor/podcast-scripts-check.ts | 266 ++++++++++++++++++++++++++++ test/doctor-podcast-scripts.test.ts | 181 +++++++++++++++++++ 5 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 src/doctor/podcast-scripts-check.ts create mode 100644 test/doctor-podcast-scripts.test.ts diff --git a/DOCTOR_AND_MIGRATIONS.md b/DOCTOR_AND_MIGRATIONS.md index 13ac129f4..5c6a47595 100644 --- a/DOCTOR_AND_MIGRATIONS.md +++ b/DOCTOR_AND_MIGRATIONS.md @@ -10,6 +10,7 @@ This document describes the `wl doctor` command and the migration policy for Wor - **Status/stage compatibility** — validates every work item's status and stage against the rules defined in `.worklog/config.yaml` (see `docs/validation/status-stage-inventory.md` for the full rule set). - **Dependency edges** — checks that all dependency edges reference existing work items. +- **Podcast script `Key Files:`** — flags a `podcast` item at a script-bearing stage (`plan_complete` / `in_review` / `done`) whose `Key Files:` contains no `.podcast.md` path that resolves to an existing file under the worklog root, and any `podcast` item whose listed `.podcast.md` path does not resolve. Detection is artifact-based (filesystem), not stage/status-based. `wl doctor --fix` backfills the path when a matching script is found (see `docs/FILE_PATH_CONVENTION.md`). - **Pending migrations** — the `upgrade` subcommand detects and applies schema migrations. - **Stale deleted items** — the `prune` subcommand removes soft-deleted items older than a configurable threshold. - **Audit gaps** — the `audit-gaps` subcommand reports `completed`/`in_review` items with no audit record (read-only). diff --git a/docs/FILE_PATH_CONVENTION.md b/docs/FILE_PATH_CONVENTION.md index c3e357b0d..ed8fb99dd 100644 --- a/docs/FILE_PATH_CONVENTION.md +++ b/docs/FILE_PATH_CONVENTION.md @@ -136,6 +136,9 @@ the extraction logic. It is shared between: - The `wl next --groups/-g` grouping algorithm - The `wl doctor file-paths` subcommand +- The `wl doctor` podcast-script check (`src/doctor/podcast-scripts-check.ts`), + which additionally verifies that a podcast item's `.podcast.md` `Key Files:` + entry resolves to an existing file (see [DOCTOR_AND_MIGRATIONS.md](../DOCTOR_AND_MIGRATIONS.md)) - The automatic advisory check on transition to `intake_complete` ## Related diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 08c3a0240..c329ba78e 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -9,6 +9,7 @@ import { validateDependencyEdges } from '../doctor/dependency-check.js'; import { listPendingMigrations, runMigrations } from '../migrations/index.js'; import { dryRunHooks, upgradeHooks, detectHooksTargetDir, type HookDryRunResult, type HookUpgradeResult } from '../doctor/hook-upgrade.js'; import { validateFilePaths, applyFilePathsFix, DEFAULT_INTAKE_STAGES } from '../doctor/file-paths-check.js'; +import { validatePodcastScripts } from '../doctor/podcast-scripts-check.js'; import { importFromJsonl } from '../jsonl.js'; import { mergeWorkItems, mergeComments, mergeAuditResults, rewriteAndForcePushDataFile } from '../sync.js'; import { getSyncDefaults } from './sync.js'; @@ -1009,6 +1010,7 @@ ${withIncorrect.length} item(s) with incorrect **Key Files:** sections:`); ...validateStatusStageItems(items, rules), ...validateDependencyEdges(items, dependencyEdges), ...priorityFindings, + ...validatePodcastScripts(items), ]; // If --fix was provided, attempt to apply safe fixes and prompt per non-safe finding @@ -1102,6 +1104,7 @@ ${withIncorrect.length} item(s) with incorrect **Key Files:** sections:`); if ((f.proposedFix as any).status) update.status = (f.proposedFix as any).status; if ((f.proposedFix as any).stage) update.stage = (f.proposedFix as any).stage; if ((f.proposedFix as any).priority) update.priority = (f.proposedFix as any).priority; + if ((f.proposedFix as any).description) update.description = (f.proposedFix as any).description; if (Object.keys(update).length > 0) { try { db.update(itemId, update); @@ -1145,7 +1148,8 @@ ${withIncorrect.length} item(s) with incorrect **Key Files:** sections:`); const hasActionableFix = f.proposedFix && typeof f.proposedFix === 'object' && ( Object.prototype.hasOwnProperty.call(f.proposedFix, 'status') || Object.prototype.hasOwnProperty.call(f.proposedFix, 'stage') || - Object.prototype.hasOwnProperty.call(f.proposedFix, 'priority') + Object.prototype.hasOwnProperty.call(f.proposedFix, 'priority') || + Object.prototype.hasOwnProperty.call(f.proposedFix, 'description') ); if (!hasActionableFix) { @@ -1171,6 +1175,7 @@ ${withIncorrect.length} item(s) with incorrect **Key Files:** sections:`); if ((f.proposedFix as any).status) update.status = (f.proposedFix as any).status; if ((f.proposedFix as any).stage) update.stage = (f.proposedFix as any).stage; if ((f.proposedFix as any).priority) update.priority = (f.proposedFix as any).priority; + if ((f.proposedFix as any).description) update.description = (f.proposedFix as any).description; if (Object.keys(update).length > 0) { try { db.update(f.itemId, update); continue; } catch (err) { /* fall through to keep in report */ } } @@ -1225,7 +1230,8 @@ ${withIncorrect.length} item(s) with incorrect **Key Files:** sections:`); const hasActionableFix = proposed && typeof proposed === 'object' && ( Object.prototype.hasOwnProperty.call(proposed, 'status') || Object.prototype.hasOwnProperty.call(proposed, 'stage') || - Object.prototype.hasOwnProperty.call(proposed, 'priority') + Object.prototype.hasOwnProperty.call(proposed, 'priority') || + Object.prototype.hasOwnProperty.call(proposed, 'description') ); return !!ctx.requiresManualFix || !hasActionableFix; }); diff --git a/src/doctor/podcast-scripts-check.ts b/src/doctor/podcast-scripts-check.ts new file mode 100644 index 000000000..0d6af0cb9 --- /dev/null +++ b/src/doctor/podcast-scripts-check.ts @@ -0,0 +1,266 @@ +/** + * Podcast episode `Key Files:` validation. + * + * The herdr `w` / `t` chords resolve an episode's `.podcast.md` script from + * the first `.podcast.md` entry under the `Key Files:` heading of the + * episode work item (see `PODCAST_WORKFLOW.md` and OSL-0MUTPC7SF0011Y54). + * Nothing used to require that entry to exist, so a producer could reach a + * script-bearing stage with no resolvable script and every chord would fail + * silently. + * + * This check is deliberately **artifact-based**: it resolves the + * `.podcast.md` paths against the filesystem rather than trusting the + * item's stage/status to describe reality. + * + * Follows the pattern from `file-paths-check.ts` and `status-stage-check.ts`. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { WorkItem } from '../types.js'; +import { extractFilePaths } from '../commands/helpers.js'; + +export type DoctorSeverity = 'info' | 'warning' | 'error'; + +export interface PodcastScriptsFinding { + checkId: string; + type: string; + severity: DoctorSeverity; + itemId: string; + message: string; + proposedFix: Record<string, unknown> | null; + safe: boolean; + context: Record<string, unknown>; +} + +/** Default podcast directory relative to the worklog root. */ +export const DEFAULT_PODCAST_DIR = '.llm-wiki/wiki/podcast'; + +/** + * Stages at which a script is expected to exist. A `podcast` item at one of + * these stages with no `.podcast.md` `Key Files:` entry is flagged. The + * stage only scopes *whether absence is a problem*; resolvability of any + * listed path is always checked against the filesystem. + */ +export const SCRIPT_BEARING_STAGES = ['plan_complete', 'in_review', 'done']; + +const CHECK_ID_MISSING = 'podcast-scripts.missing'; +const CHECK_ID_UNRESOLVED = 'podcast-scripts.unresolved'; +const TYPE_MISSING = 'missing-podcast-script'; +const TYPE_UNRESOLVED = 'unresolved-podcast-script'; +const SEVERITY: DoctorSeverity = 'warning'; + +const KEY_FILES_HEADER_RE = /^#{0,3}\s*\*{0,2}key files:?\*{0,2}\s*$/i; +const HEADING_RE = /^#{1,6}\s/; +const BULLET_RE = /^([-*])\s+(.*)$/; + +/** + * Return the `.podcast.md` paths listed under the item's `Key Files:` heading. + */ +export function extractPodcastScriptPaths(description: string): string[] { + return extractFilePaths(description).filter(p => p.toLowerCase().endsWith('.podcast.md')); +} + +/** Normalise a `Key Files:` bullet to a bare path for duplicate detection. */ +function normaliseForCompare(value: string): string { + let text = value.trim(); + if (text.startsWith('`')) { + const end = text.indexOf('`', 1); + text = end !== -1 ? text.slice(1, end) : text.slice(1); + } + text = text.trim(); + const token = text.split(/\s+/)[0] || text; + return token.replace(/^\.\//, ''); +} + +/** + * Append *relpath* to the `Key Files:` section of *description*. + * + * Idempotent and append-only: existing entries are preserved in order and a + * path already present is not added again. When no section exists, a + * canonical `**Key Files:**` section is appended. + */ +export function appendKeyFileEntry(description: string, relpath: string): string { + const lines = description.split('\n'); + let headerIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (KEY_FILES_HEADER_RE.test(lines[i].trim())) { + headerIdx = i; + break; + } + } + + if (headerIdx === -1) { + const base = description.replace(/\n+$/, ''); + return `${base}\n\n**Key Files:**\n- \`${relpath}\``; + } + + const existing = new Set<string>(); + let insertAfter = headerIdx; + let j = headerIdx + 1; + while (j < lines.length) { + const stripped = lines[j].trim(); + if (!stripped) { + j++; + continue; + } + if (HEADING_RE.test(stripped) || KEY_FILES_HEADER_RE.test(stripped)) { + break; + } + const bullet = stripped.match(BULLET_RE); + if (bullet) { + existing.add(normaliseForCompare(bullet[2])); + insertAfter = j; + j++; + continue; + } + break; + } + + if (existing.has(normaliseForCompare(relpath))) { + return description; + } + lines.splice(insertAfter + 1, 0, `- \`${relpath}\``); + return lines.join('\n'); +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Best-effort lookup of a `.podcast.md` script for *item* under the default + * podcast directory. Matches on the slugified item title against the script + * filename stem. Returns a worklog-root-relative POSIX path, or null. + */ +export function findPodcastScriptForItem(item: WorkItem, rootDir: string): string | null { + const base = path.resolve(rootDir, DEFAULT_PODCAST_DIR); + if (!fs.existsSync(base)) { + return null; + } + const slug = slugify(item.title || ''); + if (!slug) { + return null; + } + + let match: string | null = null; + const walk = (dir: string): void => { + if (match) return; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + if (match) return; + continue; + } + if (!entry.name.toLowerCase().endsWith('.podcast.md')) { + continue; + } + const stem = entry.name.slice(0, -'.podcast.md'.length).toLowerCase(); + if (stem === slug || stem.includes(slug) || slug.includes(stem)) { + match = path.relative(rootDir, full).split(path.sep).join('/'); + return; + } + } + }; + walk(base); + return match; +} + +/** + * Validate `podcast` items' `Key Files:` script entries. + * + * Findings: + * - `unresolved-podcast-script`: a listed `.podcast.md` path does not exist + * under *rootDir* — flagged at any stage (artifact-based). + * - `missing-podcast-script`: a `podcast` item at a script-bearing stage has + * no `.podcast.md` entry at all. + * + * Valid items (a resolvable script entry, or a non-script-bearing item with + * no entry) produce no finding. + * + * @param items - All work items in the database. + * @param rootDir - Worklog root the `Key Files:` paths are relative to + * (defaults to the current working directory). + */ +export function validatePodcastScripts( + items: WorkItem[], + rootDir: string = process.cwd(), +): PodcastScriptsFinding[] { + const findings: PodcastScriptsFinding[] = []; + + for (const item of items) { + if (item.issueType !== 'podcast' || item.status === 'deleted') { + continue; + } + const description = item.description || ''; + const scriptPaths = extractPodcastScriptPaths(description); + + const unresolved = scriptPaths.filter(p => { + try { + return !fs.existsSync(path.resolve(rootDir, p)); + } catch { + return true; + } + }); + + if (unresolved.length > 0) { + findings.push({ + checkId: CHECK_ID_UNRESOLVED, + type: TYPE_UNRESOLVED, + severity: SEVERITY, + itemId: item.id, + message: + `Podcast Key Files: script path does not resolve to an existing ` + + `.podcast.md under the worklog root: ${unresolved.join(', ')}. ` + + `Fix: correct the path (write it worklog-root-relative, e.g. ` + + `\`${DEFAULT_PODCAST_DIR}/<stem>/<stem>.podcast.md\`) or regenerate the script.`, + proposedFix: null, + safe: false, + context: { + itemTitle: item.title, + stage: item.stage, + unresolvedPaths: unresolved, + }, + }); + continue; + } + + if (scriptPaths.length === 0 && SCRIPT_BEARING_STAGES.includes(item.stage || '')) { + const candidate = findPodcastScriptForItem(item, rootDir); + findings.push({ + checkId: CHECK_ID_MISSING, + type: TYPE_MISSING, + severity: SEVERITY, + itemId: item.id, + message: candidate + ? `Podcast item at '${item.stage}' has no .podcast.md entry in ` + + `Key Files:. Fix: add \`${candidate}\` under **Key Files:**.` + : `Podcast item at '${item.stage}' has no .podcast.md entry in ` + + `Key Files:, and no matching script was found under ` + + `${DEFAULT_PODCAST_DIR}. Fix: add the worklog-root-relative ` + + `script path under **Key Files:**.`, + proposedFix: candidate + ? { description: appendKeyFileEntry(description, candidate) } + : null, + safe: Boolean(candidate), + context: { + itemTitle: item.title, + stage: item.stage, + candidatePath: candidate, + }, + }); + } + } + + return findings; +} diff --git a/test/doctor-podcast-scripts.test.ts b/test/doctor-podcast-scripts.test.ts new file mode 100644 index 000000000..21f37c230 --- /dev/null +++ b/test/doctor-podcast-scripts.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { WorkItem } from '../src/types.js'; +import { + SCRIPT_BEARING_STAGES, + appendKeyFileEntry, + extractPodcastScriptPaths, + findPodcastScriptForItem, + validatePodcastScripts, +} from '../src/doctor/podcast-scripts-check.js'; + +const baseItem = (overrides: Partial<WorkItem>): WorkItem => ({ + id: 'WL-POD-1', + title: 'My Episode', + description: '', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: '2026-02-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + tags: [], + assignee: '', + stage: 'plan_complete', + issueType: 'podcast', + createdBy: '', + deletedBy: '', + deleteReason: '', + risk: '', + effort: '', + ...overrides, +}); + +describe('doctor podcast-scripts check', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'podcast-scripts-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + const writeScript = (relpath: string): string => { + const full = path.join(root, relpath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, 'Nova: hello'); + return relpath; + }; + + it('does not flag a podcast item with a resolvable script entry', () => { + const rel = writeScript('.llm-wiki/wiki/podcast/my-episode/my-episode.podcast.md'); + const items = [ + baseItem({ + description: `Scope.\n\n**Key Files:**\n- \`${rel}\``, + }), + ]; + expect(validatePodcastScripts(items, root)).toHaveLength(0); + }); + + it('flags a podcast item at a script-bearing stage with no script entry', () => { + for (const stage of SCRIPT_BEARING_STAGES) { + const items = [ + baseItem({ + id: `WL-${stage}`, + stage, + description: 'Scope only.\n\n**Key Files:**\n- `docs/other.md`', + }), + ]; + const findings = validatePodcastScripts(items, root); + expect(findings).toHaveLength(1); + const finding = findings[0]; + expect(finding.checkId).toBe('podcast-scripts.missing'); + expect(finding.type).toBe('missing-podcast-script'); + expect(finding.severity).toBe('warning'); + expect(finding.message).toContain('Key Files:'); + expect(finding.message).toContain('Fix:'); + } + }); + + it('flags an unresolved podcast script path regardless of stage', () => { + const items = [ + baseItem({ + stage: 'idea', + description: + 'Scope.\n\n**Key Files:**\n- `.llm-wiki/wiki/podcast/gone/gone.podcast.md`', + }), + ]; + const findings = validatePodcastScripts(items, root); + expect(findings).toHaveLength(1); + expect(findings[0].checkId).toBe('podcast-scripts.unresolved'); + expect(findings[0].type).toBe('unresolved-podcast-script'); + expect(findings[0].context.unresolvedPaths).toEqual([ + '.llm-wiki/wiki/podcast/gone/gone.podcast.md', + ]); + }); + + it('does not flag a podcast item at an early stage with no entry', () => { + const items = [baseItem({ stage: 'idea', description: 'Just an idea.' })]; + expect(validatePodcastScripts(items, root)).toHaveLength(0); + }); + + it('does not flag non-podcast items', () => { + const items = [ + baseItem({ issueType: 'task', stage: 'plan_complete', description: 'No files.' }), + ]; + expect(validatePodcastScripts(items, root)).toHaveLength(0); + }); + + it('skips deleted items', () => { + const items = [ + baseItem({ status: 'deleted', stage: 'done', description: 'No files.' }), + ]; + expect(validatePodcastScripts(items, root)).toHaveLength(0); + }); + + it('proposes an idempotent fix when a matching script can be found', () => { + const rel = writeScript('.llm-wiki/wiki/podcast/my-episode/my-episode.podcast.md'); + const items = [baseItem({ title: 'My Episode', description: '' })]; + const findings = validatePodcastScripts(items, root); + expect(findings).toHaveLength(1); + const finding = findings[0]; + expect(finding.safe).toBe(true); + expect(finding.context.candidatePath).toBe(rel); + + const proposed = (finding.proposedFix as Record<string, string>).description; + expect(proposed).toContain(`- \`${rel}\``); + // Idempotent: appending again is a no-op. + expect(appendKeyFileEntry(proposed, rel)).toBe(proposed); + }); + + it('reports no proposed fix when no matching script exists', () => { + const items = [baseItem({ title: 'Nothing Here', description: 'Scope.' })]; + const findings = validatePodcastScripts(items, root); + expect(findings).toHaveLength(1); + expect(findings[0].proposedFix).toBeNull(); + expect(findings[0].safe).toBe(false); + }); +}); + +describe('podcast-scripts helpers', () => { + it('extractPodcastScriptPaths only returns .podcast.md entries', () => { + const description = + '**Key Files:**\n- `.llm-wiki/wiki/syntheses/source.md`\n' + + '- `.llm-wiki/wiki/podcast/x/x.podcast.md`'; + expect(extractPodcastScriptPaths(description)).toEqual([ + '.llm-wiki/wiki/podcast/x/x.podcast.md', + ]); + }); + + it('appendKeyFileEntry preserves existing entries and is idempotent', () => { + const description = + 'Scope.\n\n**Key Files:**\n- `.llm-wiki/wiki/syntheses/source.md`'; + const out = appendKeyFileEntry( + description, + '.llm-wiki/wiki/podcast/x/x.podcast.md', + ); + expect(out).toContain('- `.llm-wiki/wiki/syntheses/source.md`'); + expect(out).toContain('- `.llm-wiki/wiki/podcast/x/x.podcast.md`'); + expect(appendKeyFileEntry(out, '.llm-wiki/wiki/podcast/x/x.podcast.md')).toBe(out); + }); + + it('findPodcastScriptForItem returns a worklog-root-relative path', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'podcast-find-')); + try { + const full = path.join( + root, + '.llm-wiki/wiki/podcast/my-episode/my-episode.podcast.md', + ); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, 'x'); + const found = findPodcastScriptForItem(baseItem({ title: 'My Episode' }), root); + expect(found).toBe('.llm-wiki/wiki/podcast/my-episode/my-episode.podcast.md'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); From 1db87a55666f99f8f8bc72d8296f4fd9926ebe2f Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Sun, 4 Oct 2026 22:28:27 +0100 Subject: [PATCH 08/27] WL-0MUUAMP7M008Z4GK: Implementation complete --- CLI.md | 49 ++++++-- LOCAL_LLM.md | 4 +- src/commands/interview.ts | 224 ++++++++++++++++++++++++++++++------ tests/cli/interview.test.ts | 220 +++++++++++++++++++++++++++++++++-- 4 files changed, 437 insertions(+), 60 deletions(-) diff --git a/CLI.md b/CLI.md index a25c4a04f..335b7f387 100644 --- a/CLI.md +++ b/CLI.md @@ -1257,14 +1257,39 @@ are asked. `needsProducerReview` is cleared once every question has an answer. If no clarifying-questions section is found (or it contains no parseable questions) but the item is still flagged for producer review, the command -explains what the producer must do to clear the flag. The explanation is -produced by the local LLM proxy's `compact` model, prompted with the -instruction *"Explain what the producer needs to do in order to remove the -needsProducerReview flag"* plus item context (the description and the two most -recent comments). When the LLM is disabled or unreachable the command falls -back silently to the two most recent comments (`<author>: <first line>`). +explains **why** the item is flagged — naming the triggering evidence — and +only then what the producer must do to clear the flag. -The explanation is advisory only — it never mutates the description. +The explanation is produced by the local LLM proxy's `compact` model, prompted +with an instruction to first state the reason (citing the triggering evidence) +and then the action, plus item context: + +- the **latest audit result** (verdict, provenance, summary and a bounded + raw-output excerpt) — the strongest signal for review-gated items; +- the description; and +- the two most recent comments. + +The audit block is placed ahead of the description so it survives the overall +context bound (`MAX_PROMPT_CONTEXT_BYTES`, 8 KiB) when the description is very +large. The summary and raw-output excerpt are individually bounded so a single +large audit cannot dominate the prompt. + +When the LLM is disabled (`--no-llm`) or unreachable the command falls back +silently to the **structured evidence**, in priority order: + +1. the latest audit result (verdict and summary); +2. a durable audit-gap waiver (`auditWaiver`); +3. the two most recent comments (`<author>: <first line>`); +4. a generic actionable line (`wl reviewed <id> false`) as a last resort. + +The rendered explanation preserves intentional line breaks and short Markdown +structure — it is no longer collapsed into a single line — but collapses runs +of spaces and repeated blank lines, and is bounded by a documented maximum +(`MAX_EXPLANATION_LENGTH`, 1000 characters, and `MAX_EXPLANATION_LINES`, 24 +lines). Truncation is marked with a trailing ellipsis. + +The explanation is advisory only — it never mutates the description or clears +the flag. After printing the explanation the command asks `Clear the needsProducerReview flag? (y/N)` (default **No**); answering `y` @@ -1279,11 +1304,11 @@ Options: - `--prefix <prefix>` — Operate on a specific prefix (optional). - `--json` — Non-interactive mode: prints JSON with `needsProducerReview` (boolean), `producerReviewExplanation` (string or - `null`), `noSection`, `noQuestions`, `total`, `outstanding` and - `allAnswered`. Performs no prompts, no mutation and (with `--no-llm`) no - LLM call. -- `--no-llm` — Disable the LLM explanation and use the comment fallback - (default: LLM on). + `null`; multi-line explanations are preserved), `noSection`, + `noQuestions`, `total`, `outstanding` and `allAnswered`. Performs no + prompts, no mutation and (with `--no-llm`) no LLM call. +- `--no-llm` — Disable the LLM explanation and use the structured fallback + (audit result, waiver, comments) instead (default: LLM on). - `--model <model>` — Override the chat model used for the explanation. > Without `--json`, the command requires interactive (TTY) input. diff --git a/LOCAL_LLM.md b/LOCAL_LLM.md index 29e862c44..07d4a1e6d 100644 --- a/LOCAL_LLM.md +++ b/LOCAL_LLM.md @@ -230,7 +230,9 @@ Defaults: `baseUrl` `http://192.168.0.199:8000/v1`, `model` `compact`, `LLM_API_KEY` and `LLM_TIMEOUT_MS` are used as fallbacks; config values take precedence over environment variables (matching the `embedding.*` precedence). Every call is bounded by the timeout and degrades silently to a non-LLM -fallback on error. See [CLI.md](CLI.md#interview-options-id) for the +fallback on error; the fallback reports the flag reason from the structured +evidence first (the persisted audit verdict and summary, then an audit waiver, +then the review comments). See [CLI.md](CLI.md#interview-options-id) for the `wl interview --no-llm` opt-out. --- diff --git a/src/commands/interview.ts b/src/commands/interview.ts index dd7ae6e24..21b279c38 100644 --- a/src/commands/interview.ts +++ b/src/commands/interview.ts @@ -23,6 +23,14 @@ * Answers are written back in place so surrounding markup (Source lines, * attribution, quotes) is preserved losslessly. * + * When the item is flagged for producer review but there is nothing to + * interview (no section, or no parseable questions), the command explains + * **why** it is flagged — drawing on the persisted audit result, the review + * comments and the description — and then offers to clear the flag. The + * explanation is produced by the local LLM when available and silently falls + * back to the structured evidence otherwise; it is advisory and never mutates + * the item (WL-0MUUAMP7M008Z4GK). + * * WL-0MU55UDBJ008DJ67 */ @@ -530,21 +538,57 @@ export function rebuildDescription( // ── Producer-review explanation ───────────────────────────────────────── /** - * Instruction sent to the LLM when explaining how to clear the - * `needsProducerReview` flag (parent AC2). + * Instruction sent to the LLM when explaining the `needsProducerReview` + * flag. The model must first state **why** the item is flagged (citing the + * triggering evidence) and only then what the producer must do to clear it + * (WL-0MUUAMP7M008Z4GK AC1). */ export const PRODUCER_REVIEW_INSTRUCTION = - 'Explain what the producer needs to do in order to remove the needsProducerReview flag'; + 'Explain why the needsProducerReview flag is set, citing the triggering ' + + 'evidence (the latest audit verdict and summary, review or audit comments, ' + + 'or the item description), and then explain what the producer needs to do ' + + 'in order to remove the needsProducerReview flag'; /** Maximum size (bytes) of the item context embedded in the prompt. */ export const MAX_PROMPT_CONTEXT_BYTES = 8192; -/** Maximum length (characters) of the normalised explanation line. */ -export const MAX_EXPLANATION_LENGTH = 500; +/** Maximum bytes of the audit summary embedded in the explanation prompt. */ +export const MAX_AUDIT_SUMMARY_BYTES = 2048; + +/** Maximum bytes of the audit raw-output excerpt embedded in the prompt. */ +export const MAX_AUDIT_RAW_OUTPUT_BYTES = 2048; + +/** Maximum length (characters) of the rendered explanation. */ +export const MAX_EXPLANATION_LENGTH = 1000; + +/** + * Maximum number of lines in the rendered explanation. The render keeps + * intentional line breaks (AC3) but caps how many are surfaced so a runaway + * LLM response cannot flood the console. + */ +export const MAX_EXPLANATION_LINES = 24; /** Default timeout (ms) for the explanation LLM call. */ export const EXPLANATION_TIMEOUT_MS = 15000; +/** + * The subset of a persisted audit result consumed by the explanation. This is + * structurally compatible with `PersistentStore#getAuditResult`, so callers + * pass the row returned by the store directly (WL-0MUUAMP7M008Z4GK AC2/AC4). + */ +export interface ProducerReviewAuditContext { + /** Whether the latest audit judged the item ready to close. */ + readyToClose: boolean; + /** ISO 8601 timestamp of the audit, when known. */ + auditedAt?: string | null; + /** Human-readable audit summary, when present. */ + summary?: string | null; + /** Machine-readable audit output, when present. */ + rawOutput?: string | null; + /** Audit author/provenance, when known. */ + author?: string | null; +} + /** * Dependencies for {@link buildProducerReviewExplanation}. The chat client is * injected so callers (and tests) never make network calls implicitly. @@ -552,25 +596,58 @@ export const EXPLANATION_TIMEOUT_MS = 15000; export interface ProducerReviewExplanationDeps { /** Newest-first comments for the item (`getCommentsForWorkItem` order). */ comments: Comment[]; + /** + * Latest persisted audit result, when one exists. Included as first-class + * prompt context and as the primary structured fallback signal. + */ + auditResult?: ProducerReviewAuditContext | null; /** Chat client to use; `null`/`undefined` disables the LLM path. */ chatClient?: ChatClient | null; - /** When true, skip the LLM entirely and use the comment-based fallback. */ + /** When true, skip the LLM entirely and use the structured fallback. */ noLlm?: boolean; /** Per-call timeout override in milliseconds. */ timeoutMs?: number; } /** - * Collapse whitespace so an explanation renders as a single bounded line. - * Newlines (from either path) are collapsed; over-long text is truncated. + * Render an explanation readably for the console: keep intentional line + * breaks and short Markdown structure, but collapse runs of spaces/tabs and + * repeated blank lines, then bound the result by a documented character + * length and line count (WL-0MUUAMP7M008Z4GK AC3). Truncation is marked with + * a trailing ellipsis line. */ -export function normaliseExplanation( +export function renderExplanation( text: string, maxLength: number = MAX_EXPLANATION_LENGTH, + maxLines: number = MAX_EXPLANATION_LINES, ): string { - const collapsed = text.replace(/\s+/g, ' ').trim(); - if (collapsed.length <= maxLength) return collapsed; - return collapsed.slice(0, maxLength - 1).trimEnd() + '…'; + const source = (text ?? '').replace(/\r\n?/g, '\n'); + const collapsed: string[] = []; + for (const raw of source.split('\n')) { + const line = raw.replace(/[ \t]+/g, ' ').replace(/\s+$/, ''); + // Collapse runs of blank lines to a single blank line. + if (line === '' && collapsed[collapsed.length - 1] === '') continue; + collapsed.push(line); + } + while (collapsed.length > 0 && collapsed[0] === '') collapsed.shift(); + while (collapsed.length > 0 && collapsed[collapsed.length - 1] === '') collapsed.pop(); + + const joined = collapsed.join('\n'); + const overLines = collapsed.length > maxLines; + const overLength = joined.length > maxLength; + const truncated = overLines || overLength; + + const kept = overLines + ? collapsed.slice(0, Math.max(1, maxLines - 1)) + : collapsed; + let result = kept.join('\n'); + // Reserve two characters for the newline + ellipsis appended below. + const budget = truncated ? Math.max(1, maxLength - 2) : maxLength; + if (result.length > budget) result = result.slice(0, budget); + result = result.replace(/\s+$/, ''); + + if (!truncated) return result; + return result.length > 0 ? `${result}\n…` : '…'; } /** First non-empty line of a comment body, trimmed. */ @@ -589,52 +666,125 @@ function truncateBytes(text: string, maxBytes: number): string { } /** - * Build the LLM prompt: the fixed instruction plus item context - * (description and the two most recent comments), bounded to + * Format an audit result for the explanation prompt: verdict, provenance, + * summary and a bounded raw-output excerpt. The summary/raw output are + * individually bounded so a single large audit cannot dominate the prompt. + */ +function formatAuditPromptContext(audit: ProducerReviewAuditContext): string { + const lines = [ + `Verdict: ${audit.readyToClose ? 'ready to close' : 'not ready to close'}`, + `Audited at: ${audit.auditedAt ?? 'unknown'}`, + `Audit author: ${audit.author ?? 'unknown'}`, + ]; + const summary = audit.summary?.trim(); + if (summary) { + lines.push('Summary:', truncateBytes(summary, MAX_AUDIT_SUMMARY_BYTES)); + } + const raw = audit.rawOutput?.trim(); + if (raw) { + lines.push( + 'Raw output (excerpt):', + truncateBytes(raw, MAX_AUDIT_RAW_OUTPUT_BYTES), + ); + } + return lines.join('\n'); +} + +/** + * Build the LLM prompt: the fixed instruction plus item context — the latest + * audit result, the description and the two most recent comments — bounded to * {@link MAX_PROMPT_CONTEXT_BYTES}. + * + * The audit block is placed before the description so it survives the overall + * byte-bounded truncation even when the description is very large: the audit + * verdict/summary is the single most informative signal for flagging + * (WL-0MUUAMP7M008Z4GK AC2). */ -export function buildExplanationPrompt(item: WorkItem, comments: Comment[]): string { +export function buildExplanationPrompt( + item: WorkItem, + comments: Comment[], + auditResult?: ProducerReviewAuditContext | null, +): string { const recent = comments .slice(0, 2) .map(c => `${c.author}: ${firstLine(c.comment)}`) .join('\n'); - const context = [ - `Work item: ${item.id} — ${item.title}`, + const sections: string[] = [`Work item: ${item.id} — ${item.title}`]; + if (auditResult) { + sections.push('', 'Latest audit result:', formatAuditPromptContext(auditResult)); + } + sections.push( '', 'Description:', item.description, '', 'Most recent comments:', recent || '(none)', - ].join('\n'); + ); + const context = sections.join('\n'); return `${PRODUCER_REVIEW_INSTRUCTION}.\n\n${truncateBytes(context, MAX_PROMPT_CONTEXT_BYTES)}`; } +/** The generic, last-resort actionable line. */ +function genericFallback(item: WorkItem): string { + return ( + `No structured evidence found in the description, comments or audit result; ` + + `review the item and clear the flag with \`wl reviewed ${item.id} false\` ` + + `once the blocker is resolved.` + ); +} + /** - * Comment-based fallback: `<author>: <first line>` for the two most recent - * comments, joined on one line. When there are no usable comments a generic - * actionable line is returned so the producer still has a clear next step. + * Structured fallback used when the LLM is unavailable/disabled. + * + * Reports the flag reason from structured signals first — the latest audit + * verdict and summary, then a durable audit-gap waiver, then the two most + * recent comments — and only falls back to the generic actionable line as a + * last resort (WL-0MUUAMP7M008Z4GK AC4). Line breaks are preserved so the + * caller can render multiple signals readably. */ -export function buildCommentFallback(item: WorkItem, comments: Comment[]): string { +export function buildFallbackExplanation( + item: WorkItem, + comments: Comment[], + auditResult?: ProducerReviewAuditContext | null, +): string { + if (auditResult) { + const verdict = auditResult.readyToClose + ? 'ready to close' + : 'not ready to close'; + const attribution = auditResult.author ? ` (audited by ${auditResult.author})` : ''; + const lines = [`Latest audit verdict: ${verdict}${attribution}.`]; + if (auditResult.auditedAt) lines.push(`Audited at: ${auditResult.auditedAt}`); + const summary = auditResult.summary?.trim(); + if (summary) lines.push(summary); + return lines.join('\n'); + } + + const waiver = item.auditWaiver; + const waiverReason = waiver?.reason?.trim(); + if (waiver && waiverReason) { + const author = waiver.author ? ` by ${waiver.author}` : ''; + return `Audit gap waived${author}: ${waiverReason}`; + } + const recent = comments .slice(0, 2) .map(c => `${c.author}: ${firstLine(c.comment)}`) .filter(entry => entry.replace(/^[^:]*:\s*/, '').length > 0); - if (recent.length > 0) return recent.join('; '); - return ( - `No recent comments; review the item and clear the flag with ` + - `\`wl reviewed ${item.id} false\` once the blocker is resolved.` - ); + if (recent.length > 0) return recent.join('\n'); + + return genericFallback(item); } /** - * Produce a one-line explanation of how the producer clears - * `needsProducerReview`. + * Produce a bounded, readable explanation of **why** the item requires + * producer review and what clears the flag. * * Returns `null` when the item is not flagged. Otherwise it calls the injected * chat client (unless `noLlm` is set or the client is unavailable) and falls - * back silently to the two most recent comments on any failure — network - * error, non-2xx, timeout or empty response (parent AC2/AC3). + * back silently to the structured evidence — audit verdict/summary, waiver, + * comments — on any failure (network error, non-2xx, timeout or empty + * response). The explanation is advisory: it never mutates the item. */ export async function buildProducerReviewExplanation( item: WorkItem, @@ -645,17 +795,19 @@ export async function buildProducerReviewExplanation( if (!deps.noLlm && deps.chatClient?.available) { try { const response = await deps.chatClient.complete( - buildExplanationPrompt(item, deps.comments), + buildExplanationPrompt(item, deps.comments, deps.auditResult), { timeoutMs: deps.timeoutMs ?? EXPLANATION_TIMEOUT_MS }, ); - const normalised = normaliseExplanation(response); - if (normalised.length > 0) return normalised; + const rendered = renderExplanation(response); + if (rendered.length > 0) return rendered; } catch { // Silent fallback — the explanation is advisory; never surface an error. } } - return normaliseExplanation(buildCommentFallback(item, deps.comments)); + return renderExplanation( + buildFallbackExplanation(item, deps.comments, deps.auditResult), + ); } // ── Core interview loop ────────────────────────────────────────────────── @@ -896,6 +1048,7 @@ export default function register( if (!item.needsProducerReview || !(noSection || noQuestions)) return null; return buildProducerReviewExplanation(item, { comments: db.getCommentsForWorkItem(item.id), + auditResult: db.getAuditResult(item.id), chatClient: noLlm ? null : chatClientFactory({ model: options.model }), noLlm, }); @@ -950,6 +1103,7 @@ export default function register( return; } console.log(''); + console.log('Why this item needs producer review:'); console.log(explanation); console.log(''); const shouldClear = await clearPrompt('Clear the needsProducerReview flag? (y/N)'); diff --git a/tests/cli/interview.test.ts b/tests/cli/interview.test.ts index 12d047e1f..953dd1043 100644 --- a/tests/cli/interview.test.ts +++ b/tests/cli/interview.test.ts @@ -23,9 +23,11 @@ import registerInterview, { runInterview, buildProducerReviewExplanation, buildExplanationPrompt, - buildCommentFallback, - normaliseExplanation, + buildFallbackExplanation, + renderExplanation, PRODUCER_REVIEW_INSTRUCTION, + MAX_PROMPT_CONTEXT_BYTES, + MAX_AUDIT_RAW_OUTPUT_BYTES, } from '../../src/commands/interview.js'; import { createTestContext } from '../test-utils.js'; @@ -761,13 +763,14 @@ describe('buildProducerReviewExplanation', () => { expect(result).toBeNull(); }); - it('returns the normalised LLM explanation when the call succeeds', async () => { - const client = fakeChatClient('Ask the\noperator to\nclear the flag.'); + it('preserves intentional line breaks in the LLM explanation (AC3)', async () => { + const client = fakeChatClient('Reason: AC3 unmet\nAction: fix rendering'); const result = await buildProducerReviewExplanation(flaggedItem('desc'), { comments, chatClient: client, }); - expect(result).toBe('Ask the operator to clear the flag.'); + expect(result).toBe('Reason: AC3 unmet\nAction: fix rendering'); + expect(result!.split('\n').length).toBeGreaterThan(1); expect(client.complete).toHaveBeenCalledTimes(1); }); @@ -825,15 +828,160 @@ describe('buildProducerReviewExplanation', () => { expect(prompt).not.toContain('Ancient history'); }); - it('normalises multi-line text to a single bounded line', () => { - expect(normaliseExplanation('a\n\nb c')).toBe('a b c'); - const bounded = normaliseExplanation('x'.repeat(2000), 50); - expect(bounded.length).toBeLessThanOrEqual(50); + it('prompt instruction asks for the reason ("why") as well as the action (AC1)', () => { + const instruction = PRODUCER_REVIEW_INSTRUCTION.toLowerCase(); + expect(instruction).toContain('why'); + expect(instruction).toContain('remove the needsproducerreview flag'); }); - it('buildCommentFallback renders <author>: <first line> per comment', () => { - const fallback = buildCommentFallback(flaggedItem('desc'), comments); - expect(fallback).toBe('bob: Second comment; alice: First comment'); + it('names the audit reason when the LLM path is disabled (AC1)', async () => { + const auditResult = { + readyToClose: false, + auditedAt: '2026-10-04T21:00:00.000Z', + author: 'audit-runner', + summary: 'AC1 unmet: explanation does not state why', + }; + const result = await buildProducerReviewExplanation(flaggedItem('desc'), { + comments: [], + auditResult, + noLlm: true, + }); + expect(result).toContain('not ready to close'); + expect(result).toContain('AC1 unmet: explanation does not state why'); + }); + + it('surfaces audit evidence in the prompt and preserves multi-line rendering', async () => { + const auditResult = { + readyToClose: false, + auditedAt: '2026-10-04T21:00:00.000Z', + author: 'audit-runner', + summary: 'AC3 unmet: rendering still truncates', + rawOutput: 'VERDICT: not ready\nAC3: unmet', + }; + const client = fakeChatClient('Because AC3 failed\nFix the renderer'); + const result = await buildProducerReviewExplanation(flaggedItem('desc'), { + comments, + auditResult, + chatClient: client, + }); + const prompt = client.complete.mock.calls[0][0] as string; + expect(prompt).toContain(auditResult.summary); + expect(result).toBe('Because AC3 failed\nFix the renderer'); + }); + + describe('renderExplanation (AC3)', () => { + it('preserves line breaks but collapses runs of spaces', () => { + const rendered = renderExplanation('Line one\n\nLine two with spaces'); + expect(rendered).toBe('Line one\n\nLine two with spaces'); + expect(rendered.split('\n').length).toBeGreaterThan(1); + }); + + it('bounds the explanation by length', () => { + const bounded = renderExplanation('x'.repeat(5000), 100, 50); + expect(bounded.length).toBeLessThanOrEqual(100); + }); + + it('bounds the explanation by line count', () => { + const many = Array.from({ length: 100 }, (_, i) => `line ${i}`).join('\n'); + const bounded = renderExplanation(many, 5000, 10); + expect(bounded.split('\n').length).toBeLessThanOrEqual(10); + expect(bounded.length).toBeLessThanOrEqual(5000); + }); + + it('marks truncation with an ellipsis', () => { + const bounded = renderExplanation('x'.repeat(5000), 50, 50); + expect(bounded.endsWith('…')).toBe(true); + }); + }); + + describe('buildFallbackExplanation structured signals (AC4)', () => { + const auditResult = { + readyToClose: false, + auditedAt: '2026-10-04T21:00:00.000Z', + author: 'audit-runner', + summary: 'AC3 unmet: the explanation is still collapsed to one line', + rawOutput: 'VERDICT: not ready\nAC3: unmet', + }; + + it('reports the audit verdict and summary when an audit result exists (a)', () => { + const fallback = buildFallbackExplanation(flaggedItem('desc'), comments, auditResult); + expect(fallback).toContain('not ready to close'); + expect(fallback).toContain(auditResult.summary); + expect(fallback).not.toContain('bob: Second comment'); + }); + + it('reports a durable audit waiver when there is no audit result', () => { + const item = flaggedItem('desc') as any; + item.auditWaiver = { + reason: 'Audit gap deliberately accepted for a docs-only change', + author: 'producer', + waivedAt: '2026-10-04T21:00:00.000Z', + }; + const fallback = buildFallbackExplanation(item, comments, null); + expect(fallback).toContain('Audit gap waived by producer'); + expect(fallback).toContain('docs-only change'); + }); + + it('falls back to the comments when there is no audit evidence (b)', () => { + const fallback = buildFallbackExplanation(flaggedItem('desc'), comments, null); + expect(fallback).toBe('bob: Second comment\nalice: First comment'); + }); + + it('uses a generic actionable line as a last resort (c)', () => { + const fallback = buildFallbackExplanation(flaggedItem('desc'), [], null); + expect(fallback).toContain('WL-TEST-1'); + expect(fallback).toContain('No structured evidence'); + }); + }); + + describe('buildExplanationPrompt audit context (AC2)', () => { + const auditResult = { + readyToClose: false, + auditedAt: '2026-10-04T21:00:00.000Z', + author: 'audit-runner', + summary: 'AC3 unmet: rendering still truncates', + rawOutput: 'VERDICT: not ready\nAC3: unmet', + }; + + it('includes the audit summary as first-class prompt context', () => { + const prompt = buildExplanationPrompt(flaggedItem('desc'), comments, auditResult); + expect(prompt).toContain(auditResult.summary); + expect(prompt).toContain('Latest audit result:'); + expect(prompt).toContain('not ready to close'); + }); + + it('keeps the audit context ahead of a large description', () => { + const big = flaggedItem('z'.repeat(50000)); + const prompt = buildExplanationPrompt(big, [], auditResult); + expect(prompt).toContain(auditResult.summary); + }); + + it('bounds the total prompt context by MAX_PROMPT_CONTEXT_BYTES', () => { + const prefix = `${PRODUCER_REVIEW_INSTRUCTION}.\n\n`; + const prompt = buildExplanationPrompt( + flaggedItem('y'.repeat(50000)), + [comment('a', 'z'.repeat(50000))], + auditResult, + ); + expect(prompt.startsWith(prefix)).toBe(true); + const context = prompt.slice(prefix.length); + expect(Buffer.byteLength(context, 'utf8')).toBeLessThanOrEqual( + MAX_PROMPT_CONTEXT_BYTES, + ); + }); + + it('bounds the raw-output excerpt embedded in the prompt', () => { + const hugeRaw = { ...auditResult, rawOutput: 'r'.repeat(50000) }; + const prompt = buildExplanationPrompt(flaggedItem('desc'), [], hugeRaw); + const marker = 'Raw output (excerpt):'; + const rawIdx = prompt.indexOf(marker); + expect(rawIdx).toBeGreaterThan(-1); + const excerpt = prompt.slice(rawIdx + marker.length); + // The bound plus the small trailing description/comments sections. + expect(Buffer.byteLength(excerpt, 'utf8')).toBeLessThan( + MAX_AUDIT_RAW_OUTPUT_BYTES + 200, + ); + }); }); }); @@ -842,6 +990,7 @@ describe('interview --json (non-interactive)', () => { function setup(options: { item?: { description?: string; needsProducerReview?: boolean }; comments?: any[]; + auditResult?: any; chatClient?: any; } = {}) { const ctx = createTestContext(); @@ -861,6 +1010,7 @@ describe('interview --json (non-interactive)', () => { ctx.utils.getDatabase = (prefix?: string) => ({ ...baseGetDatabase(prefix), getCommentsForWorkItem: () => options.comments ?? [], + getAuditResult: () => options.auditResult ?? null, }); registerInterview(ctx as any, { chatClientFactory: () => options.chatClient ?? null, @@ -907,6 +1057,52 @@ describe('interview --json (non-interactive)', () => { expect(jsonOutput[0].producerReviewExplanation).toBe('producer: Please clarify scope.'); }); + it('preserves a multi-line explanation and does not mutate the item (AC5)', async () => { + const client = fakeChatClient('Reason: AC3 unmet\nAction: fix rendering'); + const { ctx, id, jsonOutput } = setup({ + item: { description: '# Task\n\nNo section.', needsProducerReview: true }, + comments: [], + chatClient: client, + }); + + await ctx.runCli(['interview', id, '--json']); + + expect(jsonOutput).toHaveLength(1); + expect(jsonOutput[0].producerReviewExplanation).toBe( + 'Reason: AC3 unmet\nAction: fix rendering', + ); + // Round-trips as valid JSON with the newline preserved. + expect(JSON.parse(JSON.stringify(jsonOutput[0])).producerReviewExplanation).toBe( + 'Reason: AC3 unmet\nAction: fix rendering', + ); + const stored = ctx.utils.db.get(id); + expect(stored.needsProducerReview).toBe(true); + expect(stored.description).toContain('No section.'); + }); + + it('surfaces the audit verdict and summary in --json when the LLM is disabled (AC4)', async () => { + const { ctx, id, jsonOutput } = setup({ + item: { description: '# Task\n\nNo section.', needsProducerReview: true }, + comments: [], + auditResult: { + readyToClose: false, + auditedAt: '2026-10-04T21:00:00.000Z', + author: 'audit-runner', + summary: 'AC3 unmet: rendering still truncates', + }, + chatClient: fakeChatClient('unused'), + }); + + await ctx.runCli(['interview', id, '--json', '--no-llm']); + + expect(jsonOutput[0].producerReviewExplanation).toContain('not ready to close'); + expect(jsonOutput[0].producerReviewExplanation).toContain( + 'AC3 unmet: rendering still truncates', + ); + const stored = ctx.utils.db.get(id); + expect(stored.needsProducerReview).toBe(true); + }); + it('returns a null explanation for a non-flagged item', async () => { const { ctx, id, jsonOutput } = setup({ item: { description: '# Task\n\nNo section.', needsProducerReview: false }, From 03d0a50d1fe704cc990931dfb3f999d6f5354156 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Mon, 5 Oct 2026 00:38:50 +0100 Subject: [PATCH 09/27] WL-0MUH7ACKJ0024VGF: Implementation complete --- CLI.md | 60 ++++- packages/shared/src/types.ts | 26 ++ src/cli-types.ts | 6 +- src/commands/interview.ts | 402 ++++++++++++++++++++++++---- src/config.ts | 49 ++++ src/types.ts | 1 + tests/cli/interview.test.ts | 351 ++++++++++++++++++++++++ tests/unit/config-interview.test.ts | 153 +++++++++++ 8 files changed, 997 insertions(+), 51 deletions(-) create mode 100644 tests/unit/config-interview.test.ts diff --git a/CLI.md b/CLI.md index 335b7f387..780527866 100644 --- a/CLI.md +++ b/CLI.md @@ -1253,6 +1253,41 @@ Answers are persisted after each response, so an interrupted session can be resumed by re-running the command: only the questions that remain outstanding are asked. `needsProducerReview` is cleared once every question has an answer. +#### LLM-assisted question extraction + +The deterministic parser needs a recognised appendix heading and readable +Q/A markup. When it finds nothing — the item was written in free prose, for +example — the command can fall back to the local LLM to extract the +outstanding questions: + +```sh +wl interview WL-ABC123 --llm +``` + +The opt-in can also be set in the configuration file: + +```yaml +interview: + intelligent: true +``` + +`--llm` and `interview.intelligent: true` are equivalent; `--llm` wins when +both are present. `--no-llm` disables all LLM use, including extraction. + +When enabled — and only when the deterministic parser found no questions — +the command sends the description (truncated to 8 KiB) to the configured chat +provider with a 15 s timeout and asks for a JSON array of +`[{"question": "..."}]`. Each returned question is shown in the same prompt +loop as a parsed question, and **only questions the operator answers +(confirms)** are written back — through the deterministic re-serialiser, never +from raw model output. When the description has no clarifying section the +canonical `## Appendix: Clarifying questions` heading is inserted; when one +exists the confirmed questions are merged into it, keeping re-runs idempotent. + +If no provider is configured, or the request fails, times out or returns +malformed JSON, the command degrades silently to the deterministic-only +behaviour described below — no error is surfaced. + #### When there are no questions to answer If no clarifying-questions section is found (or it contains no parseable @@ -1307,9 +1342,15 @@ Options: `null`; multi-line explanations are preserved), `noSection`, `noQuestions`, `total`, `outstanding` and `allAnswered`. Performs no prompts, no mutation and (with `--no-llm`) no LLM call. -- `--no-llm` — Disable the LLM explanation and use the structured fallback - (audit result, waiver, comments) instead (default: LLM on). -- `--model <model>` — Override the chat model used for the explanation. +- `--no-llm` — Disable all LLM use (the producer-review explanation and the + LLM-assisted question extraction) and use the structured fallback instead + (default: explanation on, extraction off unless enabled). +- `--llm` — Enable LLM-assisted clarifying-question extraction when the + deterministic parser finds no questions. Equivalent to + `interview.intelligent: true`; the flag wins when both are set (default: + off). +- `--model <model>` — Override the chat model used for the explanation and + extraction. > Without `--json`, the command requires interactive (TTY) input. @@ -1339,6 +1380,19 @@ Defaults: `baseUrl` `http://192.168.0.199:8000/v1`, `model` `compact`, `LLM_API_KEY` and `LLM_TIMEOUT_MS` are used as fallbacks; config values take precedence over environment variables. +#### `interview` configuration + +The `interview` section opts in to LLM-assisted question extraction without +requiring the `--llm` flag: + +```yaml +interview: + intelligent: true +``` + +An absent section (or `intelligent: false`) leaves extraction off. The +provider itself is configured under `llm` (see above). + ### `help` [command] Show help for a specific command. diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 96ddfb75a..cfe078f2b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -240,6 +240,27 @@ export interface LlmConfig { timeoutMs?: number; } +/** + * Configuration for the `wl interview` command. + * + * `intelligent: true` opts in to LLM-assisted clarifying-question extraction: + * when the deterministic parser finds no questions, the command asks the local + * OpenAI-compatible provider (configured under `llm`) to extract questions + * from the description as a fallback. This is the config-file equivalent of + * the `wl interview --llm` flag; the CLI flag takes precedence. Provider + * settings (base URL, model, API key, timeout) always come from `llm`. + * + * Example: + * ```yaml + * interview: + * intelligent: true + * ``` + */ +export interface InterviewConfig { + /** Enable LLM-assisted question extraction when no questions are parsed. */ + intelligent?: boolean; +} + /** * Configuration for a worklog project */ @@ -328,6 +349,11 @@ export interface WorklogConfig { * ``` */ llm?: LlmConfig; + /** + * Interview command configuration (opt-in LLM-assisted question + * extraction). See {@link InterviewConfig}. + */ + interview?: InterviewConfig; } /** diff --git a/src/cli-types.ts b/src/cli-types.ts index b7be0139f..be2ae521e 100644 --- a/src/cli-types.ts +++ b/src/cli-types.ts @@ -200,7 +200,11 @@ export interface InterviewOptions { prefix?: string; /** Non-interactive JSON output mode (no prompts, no mutation). */ json?: boolean; - /** Commander maps the `--no-llm` flag to `llm: false`. */ + /** + * LLM control flag. Commander maps `--llm` to `llm: true` (explicitly + * enable LLM-assisted question extraction) and `--no-llm` to `llm: false` + * (disable all LLM use). The test harness reports `--no-llm` as `noLlm`. + */ llm?: boolean; /** Test-harness spelling of `--no-llm`. */ noLlm?: boolean; diff --git a/src/commands/interview.ts b/src/commands/interview.ts index 21b279c38..6627c3c93 100644 --- a/src/commands/interview.ts +++ b/src/commands/interview.ts @@ -31,6 +31,15 @@ * back to the structured evidence otherwise; it is advisory and never mutates * the item (WL-0MUUAMP7M008Z4GK). * + * When the item is **not** flagged (or before explaining) and the operator + * opts in with `--llm` or `interview.intelligent: true`, the command falls + * back to the local LLM to extract questions from natural prose when the + * deterministic parser finds none. Extracted questions are confirmed one by + * one through the same prompt loop and only confirmed answers are written + * back through the deterministic re-serialiser; an unavailable/failing LLM + * degrades silently to the deterministic-only behaviour + * (WL-0MUH7ACKJ0024VGF). + * * WL-0MU55UDBJ008DJ67 */ @@ -38,7 +47,10 @@ import type { PluginContext } from '../plugin-types.js'; import type { InterviewOptions } from '../cli-types.js'; import type { WorkItem, Comment } from '../types.js'; import { OpenAIChatClient, type ChatClient } from '../lib/llm.js'; -import { resolveLlmConfig } from '../config.js'; +import { + resolveLlmConfig, + isIntelligentInterviewEnabled, +} from '../config.js'; import * as readline from 'readline'; // ── Section markers ────────────────────────────────────────────────────── @@ -810,6 +822,152 @@ export async function buildProducerReviewExplanation( ); } +// ── LLM-assisted question extraction ───────────────────────────────────── + +/** + * Instruction sent to the LLM when the deterministic parser finds no + * clarifying questions. The model must return **only** a JSON array of + * `{ question }` objects (or `[]`) so the response can be parsed defensively + * and every entry confirmed by the operator before anything is written + * (WL-0MUH7ACKJ0024VGF AC2/AC3/AC4). + */ +export const EXTRACTION_INSTRUCTION = + 'Extract every unanswered clarifying question from the work item ' + + 'description below. Respond with ONLY a JSON array of objects of the ' + + 'form [{"question": "..."}] and nothing else. If there are no clarifying ' + + 'questions, respond with []. Do not include answered questions, ' + + 'commentary, or markdown code fences.'; + +/** Maximum size (bytes) of the description sent to the extraction LLM. */ +export const MAX_EXTRACTION_DESCRIPTION_BYTES = 8192; + +/** Default timeout (ms) for the extraction LLM call. */ +export const EXTRACTION_TIMEOUT_MS = 15000; + +/** + * Build the bounded extraction prompt: the fixed instruction plus the + * description source truncated to {@link MAX_EXTRACTION_DESCRIPTION_BYTES}. + * Callers pass the clarifying-section body when one is present (and + * non-empty), otherwise the whole description. + */ +export function buildExtractionPrompt(description: string): string { + return ( + `${EXTRACTION_INSTRUCTION}\n\n` + + `Description:\n${truncateBytes(description, MAX_EXTRACTION_DESCRIPTION_BYTES)}` + ); +} + +/** + * Defensively parse an LLM extraction response into a de-duplicated list of + * question strings. + * + * Non-JSON text, a non-array payload, malformed entries and blank questions + * are all treated as "no questions" and never throw (WL-0MUH7ACKJ0024VGF + * AC: parse defensively). A leading/trailing markdown code fence or + * surrounding prose is tolerated. + */ +export function parseExtractedQuestions(raw: string): string[] { + if (typeof raw !== 'string') return []; + let text = raw.trim(); + if (text === '') return []; + + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fence && typeof fence[1] === 'string') text = fence[1].trim(); + + if (!text.startsWith('[')) { + const start = text.indexOf('['); + const end = text.lastIndexOf(']'); + if (start === -1 || end <= start) return []; + text = text.slice(start, end + 1); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + + const questions: string[] = []; + const seen = new Set<string>(); + for (const entry of parsed) { + if (!entry || typeof entry !== 'object') continue; + const question = (entry as { question?: unknown }).question; + if (typeof question !== 'string') continue; + const cleaned = question.replace(/\s+/g, ' ').trim(); + if (cleaned === '' || seen.has(cleaned)) continue; + seen.add(cleaned); + questions.push(cleaned); + } + return questions; +} + +/** + * Ask the chat client to extract clarifying questions from `description`. + * Returns `[]` (never throws) when the client is unavailable, the request + * fails, times out or the response is malformed — callers then fall back + * silently to the deterministic-only behaviour (WL-0MUH7ACKJ0024VGF AC5). + */ +export async function extractQuestionsWithLlm( + chatClient: ChatClient | null | undefined, + description: string, + timeoutMs: number = EXTRACTION_TIMEOUT_MS, +): Promise<string[]> { + if (!chatClient?.available) return []; + try { + const raw = await chatClient.complete(buildExtractionPrompt(description), { + timeoutMs, + }); + return parseExtractedQuestions(raw); + } catch { + return []; + } +} + +/** + * Build lossless clarifying-Q/A pairs for LLM-extracted questions so that + * confirmed answers are written back through the deterministic re-serialiser + * ({@link rebuildQAPairs} / {@link rebuildDescription}) rather than from raw + * model output (WL-0MUH7ACKJ0024VGF AC4). + */ +export function buildExtractedPairs(questions: string[]): ClarifyingQAPair[] { + return questions.map((question, index) => ({ + number: index + 1, + question, + answer: '', + unanswered: true, + startLine: 0, + endLine: 0, + before: `- Q: ${question} — Answer (producer): `, + after: '\n', + needsScaffold: false, + })); +} + +/** Canonical heading used when the description has no clarifying section. */ +export const EXTRACTED_SECTION_HEADER = '## Appendix: Clarifying questions'; + +/** Append a clarifying section to a description that has none. */ +export function insertClarifyingSection( + description: string, + content: string, +): string { + const base = description.replace(/\s+$/, ''); + const body = content.replace(/\s+$/, ''); + if (base.length === 0) return `${EXTRACTED_SECTION_HEADER}\n\n${body}`; + return `${base}\n\n${EXTRACTED_SECTION_HEADER}\n\n${body}`; +} + +/** Merge new extracted pairs into an existing (question-free) section body. */ +function mergeExtractedIntoSection(existing: string, content: string): string { + const base = existing.replace(/\s+$/, ''); + const body = content.replace(/\s+$/, ''); + if (base.length === 0) return body; + if (body.length === 0) return base; + return `${base}\n${body}`; +} + // ── Core interview loop ────────────────────────────────────────────────── /** Minimal database dependency required by {@link runInterview}. */ @@ -830,56 +988,67 @@ export interface InterviewOutcome { total: number; outstanding: number; allAnswered: boolean; + /** Number of questions extracted by the LLM fallback (0 when unused). */ + extracted: number; } -/** - * Walk the outstanding questions on `item`, prompting via `io` and writing - * each answer back to the description as it is given. - * - * Answers are persisted after each response so an interrupted session can be - * resumed; the producer review flag is only cleared once every question has - * a non-empty answer (the `allAnswered` auto-clear path, unchanged). - * - * This function only handles the question walkthrough. The "no questions - * detected" edge case (noSection / noQuestions) is handled by the command - * registration below, which explains how the producer clears an outstanding - * `needsProducerReview` flag and offers to clear it: see - * {@link buildProducerReviewExplanation} and `.option('--no-llm')`. - */ -export async function runInterview( - item: WorkItem, - store: InterviewStore, - io: InterviewIO, -): Promise<InterviewOutcome> { - const empty: InterviewOutcome = { +/** Options controlling the LLM-assisted question-extraction fallback. */ +export interface InterviewRunOptions { + /** Enable LLM extraction when the deterministic parser finds no questions. */ + llmFallback?: boolean; + /** Chat client for extraction; `null`/`undefined` disables the path. */ + chatClient?: ChatClient | null; + /** Per-call timeout override for the extraction request (ms). */ + timeoutMs?: number; +} + +/** A zeroed outcome; `extracted` is always present. */ +function emptyOutcome(): InterviewOutcome { + return { noSection: false, noQuestions: false, recorded: 0, total: 0, outstanding: 0, allAnswered: false, + extracted: 0, }; +} - const section = extractClarifyingSection(item.description); - if (!section) return { ...empty, noSection: true }; - - const pairs = parseQAPairs(section.content); - if (pairs.length === 0) return { ...empty, noQuestions: true }; +/** + * Prompt for and persist answers to `pairs`. + * + * On the deterministic path (`fromExtraction === false`) the whole section is + * rebuilt losslessly on every answer, exactly as before. On the LLM-extracted + * path only questions the operator actually answers (confirms) are written + * back — skipped questions are dropped — and the write-back always goes + * through {@link rebuildQAPairs} / {@link rebuildDescription}, inserting the + * canonical section header when the description had none. + */ +async function walkQuestions( + item: WorkItem, + store: InterviewStore, + io: InterviewIO, + section: ClarifyingSection | null, + pairs: ClarifyingQAPair[], + fromExtraction: boolean, +): Promise<InterviewOutcome> { + const outcome = emptyOutcome(); + outcome.total = pairs.length; + outcome.extracted = fromExtraction ? pairs.length : 0; const unanswered = pairs.filter(p => p.unanswered); if (unanswered.length === 0) { if (item.needsProducerReview) { store.update(item.id, { needsProducerReview: false }); } - return { - ...empty, - total: pairs.length, - allAnswered: true, - }; + outcome.allAnswered = true; + return outcome; } let recorded = 0; let interrupted = false; + const confirmed: ClarifyingQAPair[] = []; for (const pair of unanswered) { const answer = await io.prompt(`${pair.number}. ${pair.question}\n →`); @@ -894,8 +1063,21 @@ export async function runInterview( pair.unanswered = false; recorded += 1; - const newContent = rebuildQAPairs(pairs); - const newDescription = rebuildDescription(item.description, section, newContent); + let newDescription: string; + if (fromExtraction) { + confirmed.push(pair); + const newContent = rebuildQAPairs(confirmed); + newDescription = section + ? rebuildDescription( + item.description, + section, + mergeExtractedIntoSection(section.content, newContent), + ) + : insertClarifyingSection(item.description, newContent); + } else { + const newContent = rebuildQAPairs(pairs); + newDescription = rebuildDescription(item.description, section, newContent); + } store.update(item.id, { description: newDescription }); } @@ -904,13 +1086,69 @@ export async function runInterview( store.update(item.id, { needsProducerReview: false }); } + outcome.recorded = recorded; + outcome.outstanding = pairs.filter(p => p.unanswered).length; + outcome.allAnswered = allAnswered; + return outcome; +} + +/** + * Walk the outstanding questions on `item`, prompting via `io` and writing + * each answer back to the description as it is given. + * + * Answers are persisted after each response so an interrupted session can be + * resumed; the producer review flag is only cleared once every question has + * a non-empty answer (the `allAnswered` auto-clear path, unchanged). + * + * When the deterministic parser finds no questions and the caller enables the + * LLM fallback (`options.llmFallback` + an available `chatClient`), + * {@link extractQuestionsWithLlm} is asked for a bounded JSON array of + * questions; each is then presented through the same prompt loop for + * confirmation and answer. Extraction failures degrade silently to the + * deterministic-only outcome (WL-0MUH7ACKJ0024VGF AC1–AC6). + * + * The "no questions detected" edge case (noSection / noQuestions) is handled + * by the command registration below, which explains how the producer clears + * an outstanding `needsProducerReview` flag and offers to clear it: see + * {@link buildProducerReviewExplanation} and `.option('--no-llm')`. + */ +export async function runInterview( + item: WorkItem, + store: InterviewStore, + io: InterviewIO, + options: InterviewRunOptions = {}, +): Promise<InterviewOutcome> { + const section = extractClarifyingSection(item.description); + const pairs = section ? parseQAPairs(section.content) : []; + + // Deterministic questions always win — the LLM is never called then. + if (pairs.length > 0) { + return walkQuestions(item, store, io, section, pairs, false); + } + + // No parseable questions → optional LLM-assisted extraction fallback. + if (options.llmFallback && options.chatClient?.available) { + // Prefer the clarifying-section body when one is present and non-empty; + // otherwise scan the whole description (WL-0MUQ0VQ8A002HHFA). + const source = + section && section.content.trim() !== '' + ? section.content + : item.description; + const questions = await extractQuestionsWithLlm( + options.chatClient, + source, + options.timeoutMs, + ); + if (questions.length > 0) { + const extractedPairs = buildExtractedPairs(questions); + return walkQuestions(item, store, io, section, extractedPairs, true); + } + } + return { - noSection: false, - noQuestions: false, - recorded, - total: pairs.length, - outstanding: pairs.filter(p => p.unanswered).length, - allAnswered, + ...emptyOutcome(), + noSection: section === null, + noQuestions: section !== null, }; } @@ -956,8 +1194,9 @@ export interface InterviewPromptLoop { /** Injectable dependencies for the interview command (used by tests). */ export interface InterviewCommandDeps { /** - * Build the chat client for the explanation. Return `null` to disable the - * LLM path. Defaults to an `OpenAIChatClient` over the resolved LLM config. + * Build the chat client used for the producer-review explanation and the + * LLM-assisted question extraction. Return `null` to disable the LLM path. + * Defaults to an `OpenAIChatClient` over the resolved LLM config. */ chatClientFactory?: (options: { model?: string }) => ChatClient | null; /** @@ -988,6 +1227,28 @@ export async function defaultClearPrompt(message: string): Promise<boolean> { } } +/** + * Whether the operator explicitly passed `--llm`. + * + * Commander stores `--llm` and `--no-llm` on the same `llm` key whose default + * is `true`, so the only reliable signal is the option source. When running + * under Commander, `getOptionValueSource('llm') === 'cli'` means the operator + * set it (and `options.llm !== false` distinguishes `--llm` from `--no-llm`). + * The in-process test harness invokes the action without a Commander command + * and reports only an explicit `--llm` as `options.llm === true`. + */ +function isExplicitLlmRequested( + options: InterviewOptions, + command?: { getOptionValueSource?: (name: string) => string }, +): boolean { + if (command && typeof command.getOptionValueSource === 'function') { + return ( + command.getOptionValueSource('llm') === 'cli' && options.llm !== false + ); + } + return options.llm === true; +} + export default function register( ctx: PluginContext, deps: InterviewCommandDeps = {}, @@ -1015,9 +1276,17 @@ export default function register( ) .option('--prefix <prefix>', 'Override the default prefix') .option('--json', 'Non-interactive JSON output (no prompts, no mutation)') - .option('--no-llm', 'Disable the LLM explanation and use the comment fallback') + .option('--no-llm', 'Disable all LLM use (explanation and question extraction)') + .option( + '--llm', + 'Enable LLM-assisted clarifying-question extraction when the deterministic parser finds none', + ) .option('--model <model>', 'Override the chat model for the explanation') - .action(async (id: string, options: InterviewOptions) => { + .action(async ( + id: string, + options: InterviewOptions, + command?: { getOptionValueSource?: (name: string) => string }, + ) => { utils.requireInitialized(); const db = utils.getDatabase(options.prefix); @@ -1036,6 +1305,17 @@ export default function register( // passes the kebab-cased `noLlm`. Accept either spelling. const noLlm = options.noLlm === true || options.llm === false; + // Enablement for LLM question extraction: explicit `--llm` (highest + // priority) or the `interview.intelligent: true` config opt-in; + // `--no-llm` disables all LLM use. Provider settings come from `llm.*`. + const config = utils.getConfig(); + const intelligent = isIntelligentInterviewEnabled(config); + const llmFallbackEnabled = + !noLlm && (isExplicitLlmRequested(options, command) || intelligent); + const chatClient = noLlm + ? null + : chatClientFactory({ model: options.model }); + const section = extractClarifyingSection(item.description); const pairs = section ? parseQAPairs(section.content) : []; const noSection = section === null; @@ -1049,7 +1329,7 @@ export default function register( return buildProducerReviewExplanation(item, { comments: db.getCommentsForWorkItem(item.id), auditResult: db.getAuditResult(item.id), - chatClient: noLlm ? null : chatClientFactory({ model: options.model }), + chatClient, noLlm, }); }; @@ -1084,9 +1364,16 @@ export default function register( const prompts = promptLoopFactory(); let outcome: InterviewOutcome; try { - outcome = await runInterview(item, db, { - prompt: (message: string) => prompts.next(message), - }); + outcome = await runInterview( + item, + db, + { prompt: (message: string) => prompts.next(message) }, + { + llmFallback: llmFallbackEnabled, + chatClient, + timeoutMs: EXTRACTION_TIMEOUT_MS, + }, + ); } finally { prompts.close(); } @@ -1116,6 +1403,27 @@ export default function register( }; // ── Step 3: Report outcome ─────────────────────────────────────── + if (outcome.extracted > 0) { + console.log(''); + if (outcome.allAnswered) { + console.log( + `✅ ${outcome.recorded} of ${outcome.extracted} LLM-extracted question(s) confirmed for ${normalizedId}.`, + ); + console.log(' needsProducerReview cleared.'); + } else if (outcome.recorded > 0) { + console.log( + `📝 ${outcome.recorded} of ${outcome.extracted} LLM-extracted question(s) confirmed for ${normalizedId}.`, + ); + console.log( + ` ${outcome.outstanding} question(s) skipped — needsProducerReview remains flagged.`, + ); + } else { + console.log( + `The LLM suggested ${outcome.extracted} question(s); none were confirmed.`, + ); + } + return; + } if (outcome.noSection) { console.log('No clarifying-questions section found.'); await explainAndOfferClear(); diff --git a/src/config.ts b/src/config.ts index 4b1dd4339..fadd81203 100644 --- a/src/config.ts +++ b/src/config.ts @@ -197,6 +197,14 @@ export function loadConfig(): WorklogConfig | null { return null; } + // Validate the optional interview config section (opt-in LLM question + // extraction). Absent section → LLM fallback off (no behaviour change). + const interviewError = validateInterviewConfig(config); + if (interviewError) { + console.error(interviewError); + return null; + } + // Resolve LLM config section (config → env vars → defaults). config.llm = resolveLlmConfig(config); @@ -327,6 +335,47 @@ function validateLlmConfig(config: WorklogConfig): string | null { return null; } +/** + * Validate the optional `interview` config section. + * + * Returns an error message string when the section is present but malformed, + * or `null` when it is absent or valid. A non-object `interview` value or a + * non-boolean `intelligent` value is rejected; an absent section is valid and + * leaves LLM-assisted question extraction disabled. + */ +export function validateInterviewConfig(config: WorklogConfig): string | null { + const interview = (config as { interview?: unknown }).interview; + if (interview === undefined || interview === null) return null; + if (typeof interview !== 'object' || Array.isArray(interview)) { + return 'Invalid config: interview must be an object mapping to intelligent'; + } + + const section = interview as Record<string, unknown>; + if ( + section.intelligent !== undefined && + typeof section.intelligent !== 'boolean' + ) { + return 'Invalid config: interview.intelligent must be a boolean'; + } + + return null; +} + +/** + * Whether LLM-assisted question extraction is enabled via the + * `interview.intelligent: true` config opt-in. + * + * This is the config-file equivalent of the `wl interview --llm` flag; the + * CLI flag takes precedence at the command boundary. Provider settings always + * come from `llm.*` / {@link resolveLlmConfig}. An absent section (or a + * non-true value) returns `false`, so existing users see no behaviour change. + */ +export function isIntelligentInterviewEnabled( + config: WorklogConfig | null | undefined, +): boolean { + return config?.interview?.intelligent === true; +} + /** Parse a positive finite numeric env var, returning undefined otherwise. */ function parsePositiveNumber(value: string | undefined): number | undefined { if (value === undefined || value === '') return undefined; diff --git a/src/types.ts b/src/types.ts index ab5c54421..ed0c5a1c9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export { type WorkItemQuery, type EmbeddingConfig, type LlmConfig, + type InterviewConfig, type WorklogConfig, type Comment, type DependencyEdge, diff --git a/tests/cli/interview.test.ts b/tests/cli/interview.test.ts index 953dd1043..f78957e02 100644 --- a/tests/cli/interview.test.ts +++ b/tests/cli/interview.test.ts @@ -28,6 +28,10 @@ import registerInterview, { PRODUCER_REVIEW_INSTRUCTION, MAX_PROMPT_CONTEXT_BYTES, MAX_AUDIT_RAW_OUTPUT_BYTES, + parseExtractedQuestions, + buildExtractionPrompt, + EXTRACTION_TIMEOUT_MS, + MAX_EXTRACTION_DESCRIPTION_BYTES, } from '../../src/commands/interview.js'; import { createTestContext } from '../test-utils.js'; @@ -1297,3 +1301,350 @@ describe('interview clear-the-flag prompt', () => { } }); }); + +// ── LLM-assisted question extraction (WL-0MUH7ACKJ0024VGF) ─────────────── + +describe('parseExtractedQuestions', () => { + it('parses a JSON array of question objects', () => { + expect(parseExtractedQuestions('[{"question":"A?"},{"question":"B?"}]')).toEqual([ + 'A?', + 'B?', + ]); + }); + + it('returns an empty array for [] and blank input', () => { + expect(parseExtractedQuestions('[]')).toEqual([]); + expect(parseExtractedQuestions(' ')).toEqual([]); + }); + + it('tolerates markdown fences and surrounding prose', () => { + expect( + parseExtractedQuestions('Here you go:\n```json\n[{"question":"Fenced?"}]\n```'), + ).toEqual(['Fenced?']); + }); + + it('degrades to an empty array for malformed or non-array payloads', () => { + expect(parseExtractedQuestions('not json')).toEqual([]); + expect(parseExtractedQuestions('{"question":"object?"}')).toEqual([]); + expect(parseExtractedQuestions('[{"nope":1},"bad",{"question":" "}]')).toEqual([]); + }); + + it('de-duplicates repeated questions', () => { + expect( + parseExtractedQuestions('[{"question":"Same?"},{"question":"Same?"}]'), + ).toEqual(['Same?']); + }); +}); + +describe('buildExtractionPrompt', () => { + it('bounds the description to the 8 KB extraction limit', () => { + const prompt = buildExtractionPrompt('x'.repeat(20_000)); + expect(prompt).toContain('Extract every unanswered clarifying question'); + expect(Buffer.byteLength(prompt, 'utf8')).toBeLessThan( + MAX_EXTRACTION_DESCRIPTION_BYTES + 1000, + ); + }); +}); + +describe('runInterview LLM extraction fallback', () => { + const noSectionDesc = '# Task\n\nNo clarifying section here.'; + const noQuestionsDesc = `# Task + +## Appendix: Clarifying questions + +No questions here — just context prose.`; + + function extractionClient(response: string | Error, available = true): any { + return { + available, + complete: vi.fn(async () => { + if (response instanceof Error) throw response; + return response; + }), + }; + } + + it('extracts questions, confirms each, and writes answers deterministically', async () => { + const store = makeStore(makeItem(noSectionDesc)); + const chat = extractionClient( + '[{"question":"Who is the user?"},{"question":"What format?"}]', + ); + const prompts: string[] = []; + const answers = ['Support engineers', 'JSON']; + const outcome = await runInterview( + store.current(), + store, + { + prompt: async (message: string) => { + prompts.push(message); + return answers.shift() ?? ''; + }, + }, + { llmFallback: true, chatClient: chat, timeoutMs: EXTRACTION_TIMEOUT_MS }, + ); + + expect(chat.complete).toHaveBeenCalledTimes(1); + expect(chat.complete.mock.calls[0][1]).toEqual({ timeoutMs: 15_000 }); + expect(prompts).toHaveLength(2); + expect(prompts[0]).toContain('Who is the user?'); + expect(outcome.extracted).toBe(2); + expect(outcome.recorded).toBe(2); + expect(outcome.allAnswered).toBe(true); + + const desc = store.current().description; + expect(desc).toContain('## Appendix: Clarifying questions'); + expect(desc).toContain('- Q: Who is the user? — Answer (producer): Support engineers'); + expect(desc).toContain('- Q: What format? — Answer (producer): JSON'); + expect(store.current().needsProducerReview).toBe(false); + }); + + it('returns the deterministic no-question fallback when the LLM returns []', async () => { + const store = makeStore(makeItem(noSectionDesc)); + const chat = extractionClient('[]'); + const outcome = await runInterview( + store.current(), + store, + { prompt: async () => 'unused' }, + { llmFallback: true, chatClient: chat }, + ); + expect(outcome.extracted).toBe(0); + expect(outcome.noSection).toBe(true); + expect(store.current().description).toBe(noSectionDesc); + }); + + it('degrades silently when the LLM throws (network/timeout)', async () => { + const store = makeStore(makeItem(noSectionDesc)); + const chat = extractionClient(new Error('Chat API request timed out after 15000 ms')); + const outcome = await runInterview( + store.current(), + store, + { prompt: async () => 'unused' }, + { llmFallback: true, chatClient: chat }, + ); + expect(outcome.extracted).toBe(0); + expect(outcome.noSection).toBe(true); + expect(store.current().description).toBe(noSectionDesc); + }); + + it('does not call an unavailable client', async () => { + const store = makeStore(makeItem(noSectionDesc)); + const chat = extractionClient('ignored', false); + const outcome = await runInterview( + store.current(), + store, + { prompt: async () => 'unused' }, + { llmFallback: true, chatClient: chat }, + ); + expect(chat.complete).not.toHaveBeenCalled(); + expect(outcome.extracted).toBe(0); + }); + + it('never calls the LLM when the deterministic parser finds questions', async () => { + const desc = `# Task + +## Appendix: Clarifying questions + +- Q: "Existing?" — Answer (user): "Yes". Source: reply.`; + const store = makeStore(makeItem(desc)); + const chat = extractionClient('[{"question":"Hallucinated?"}]'); + const outcome = await runInterview( + store.current(), + store, + { prompt: async () => 'unused' }, + { llmFallback: true, chatClient: chat }, + ); + expect(chat.complete).not.toHaveBeenCalled(); + expect(outcome.extracted).toBe(0); + expect(outcome.allAnswered).toBe(true); + }); + + it('writes only operator-confirmed extracted questions', async () => { + const store = makeStore(makeItem(noSectionDesc)); + const chat = extractionClient('[{"question":"Keep me?"},{"question":"Skip me?"}]'); + const answers = ['Confirmed', '']; + const outcome = await runInterview( + store.current(), + store, + { prompt: async () => answers.shift() ?? '' }, + { llmFallback: true, chatClient: chat }, + ); + expect(outcome.recorded).toBe(1); + expect(outcome.outstanding).toBe(1); + expect(outcome.allAnswered).toBe(false); + const desc = store.current().description; + expect(desc).toContain('Keep me?'); + expect(desc).not.toContain('Skip me?'); + expect(store.current().needsProducerReview).toBe(true); + }); + + it('feeds the clarifying-section body (not the whole description) to the LLM', async () => { + const desc = `# Task + +UNRELATED_PREAMBLE_SENTINEL + +## Appendix: Clarifying questions + +SECTION_BODY_SENTINEL + +## Risks + +MORE`; + const store = makeStore(makeItem(desc)); + const chat = extractionClient('[]'); + await runInterview( + store.current(), + store, + { prompt: async () => 'unused' }, + { llmFallback: true, chatClient: chat }, + ); + const prompt = chat.complete.mock.calls[0][0] as string; + expect(prompt).toContain('SECTION_BODY_SENTINEL'); + expect(prompt).not.toContain('UNRELATED_PREAMBLE_SENTINEL'); + }); + + it('merges extracted questions into an existing question-free section', async () => { + const store = makeStore(makeItem(noQuestionsDesc)); + const chat = extractionClient('[{"question":"Merged?"}]'); + const outcome = await runInterview( + store.current(), + store, + { prompt: async () => 'Yes' }, + { llmFallback: true, chatClient: chat }, + ); + expect(outcome.extracted).toBe(1); + const desc = store.current().description; + expect(desc.match(/## Appendix: Clarifying questions/g)).toHaveLength(1); + expect(desc).toContain('just context prose'); + expect(desc).toContain('- Q: Merged? — Answer (producer): Yes'); + }); +}); + +describe('interview --llm command', () => { + function setup(options: { + description?: string; + config?: any; + chatClient?: any; + answers?: string[]; + needsProducerReview?: boolean; + jsonOutput?: any[]; + } = {}) { + const ctx = createTestContext(); + const messages: string[] = []; + const answers = [...(options.answers ?? [])]; + const originalLog = console.log; + console.log = (...args: any[]) => { messages.push(args.join(' ')); }; + if (options.jsonOutput) { + ctx.output = { + json: (data: any) => { options.jsonOutput!.push(data); }, + success: () => {}, + error: () => {}, + }; + } + ctx.utils.getConfig = () => options.config ?? {}; + const id = ctx.utils.createSampleItem({}); + ctx.utils.db.update(id, { + description: options.description ?? '# Task\n\nNo clarifying section here.', + title: 'LLM task', + needsProducerReview: options.needsProducerReview ?? true, + }); + const baseGetDatabase = ctx.utils.getDatabase; + ctx.utils.getDatabase = (prefix?: string) => ({ + ...baseGetDatabase(prefix), + getCommentsForWorkItem: () => [], + getAuditResult: () => null, + }); + registerInterview(ctx as any, { + chatClientFactory: () => options.chatClient ?? null, + promptLoopFactory: () => ({ + next: async (message: string) => { + messages.push(message); + return answers.shift() ?? ''; + }, + close: () => {}, + }), + clearPrompt: async () => false, + }); + return { ctx, id, messages, restore: () => { console.log = originalLog; } }; + } + + it('extracts, confirms and writes questions when --llm is passed', async () => { + const chat = fakeChatClient('[{"question":"Who is the user?"}]'); + const t = setup({ chatClient: chat, answers: ['Support engineers'] }); + try { + await t.ctx.runCli(['interview', t.id, '--llm']); + expect(chat.complete).toHaveBeenCalledTimes(1); + const stored = t.ctx.utils.db.get(t.id); + expect(stored.description).toContain('Who is the user?'); + expect(stored.description).toContain('Support engineers'); + expect(stored.needsProducerReview).toBe(false); + } finally { + t.restore(); + } + }); + + it('does not call the extraction LLM without --llm or config opt-in', async () => { + const chat = fakeChatClient('[{"question":"Who is the user?"}]'); + const t = setup({ + chatClient: chat, + answers: [''], + needsProducerReview: false, + }); + try { + await t.ctx.runCli(['interview', t.id]); + expect(chat.complete).not.toHaveBeenCalled(); + } finally { + t.restore(); + } + }); + + it('enables extraction from interview.intelligent: true', async () => { + const chat = fakeChatClient('[{"question":"Who is the user?"}]'); + const t = setup({ + chatClient: chat, + config: { interview: { intelligent: true } }, + answers: ['Support engineers'], + }); + try { + await t.ctx.runCli(['interview', t.id]); + expect(chat.complete).toHaveBeenCalledTimes(1); + expect(t.ctx.utils.db.get(t.id).description).toContain('Support engineers'); + } finally { + t.restore(); + } + }); + + it('lets --no-llm override interview.intelligent: true', async () => { + const chat = fakeChatClient('[{"question":"Who is the user?"}]'); + const t = setup({ + chatClient: chat, + config: { interview: { intelligent: true } }, + answers: [''], + needsProducerReview: false, + }); + try { + await t.ctx.runCli(['interview', t.id, '--no-llm']); + expect(chat.complete).not.toHaveBeenCalled(); + } finally { + t.restore(); + } + }); + + it('never runs extraction in --json mode (no prompts, no mutation)', async () => { + const chat = fakeChatClient('[{"question":"Who is the user?"}]'); + const jsonOutput: any[] = []; + const t = setup({ + chatClient: chat, + answers: [''], + needsProducerReview: false, + jsonOutput, + }); + try { + await t.ctx.runCli(['interview', t.id, '--json', '--llm']); + expect(chat.complete).not.toHaveBeenCalled(); + expect(jsonOutput).toHaveLength(1); + expect(t.ctx.utils.db.get(t.id).description).not.toContain('Who is the user?'); + } finally { + t.restore(); + } + }); +}); diff --git a/tests/unit/config-interview.test.ts b/tests/unit/config-interview.test.ts new file mode 100644 index 000000000..47014c485 --- /dev/null +++ b/tests/unit/config-interview.test.ts @@ -0,0 +1,153 @@ +/** + * Tests for the optional `interview` configuration section and + * `isIntelligentInterviewEnabled`. + * + * The `interview.intelligent` flag is the config-file equivalent of the + * `wl interview --llm` flag: it opts in to LLM-assisted clarifying-question + * extraction when the deterministic parser finds no questions. An absent + * section (or a non-true value) leaves the feature off, so existing users see + * no behaviour change. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import { + getConfigDir, + getConfigPath, + loadConfig, + isIntelligentInterviewEnabled, + validateInterviewConfig, +} from '../../src/config.js'; +import type { WorklogConfig } from '../../src/types.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; + +/** Minimal valid WorklogConfig used for direct helper tests. */ +function baseConfig(overrides: Partial<WorklogConfig> = {}): WorklogConfig { + return { + projectName: 'Test', + prefix: 'TST', + ...overrides, + }; +} + +describe('interview configuration', () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(() => { + tempDir = createTempDir(); + originalCwd = process.cwd(); + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + cleanupTempDir(tempDir); + }); + + /** Write a config.yaml with the given body lines. */ + function writeConfig(body: string[]): void { + const configDir = getConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(getConfigPath(), body.join('\n'), 'utf-8'); + } + + describe('isIntelligentInterviewEnabled', () => { + it('is off when the section is absent', () => { + expect(isIntelligentInterviewEnabled(baseConfig())).toBe(false); + expect(isIntelligentInterviewEnabled(null)).toBe(false); + expect(isIntelligentInterviewEnabled(undefined)).toBe(false); + }); + + it('is off when intelligent is false or missing', () => { + expect(isIntelligentInterviewEnabled(baseConfig({ interview: {} }))).toBe(false); + expect( + isIntelligentInterviewEnabled(baseConfig({ interview: { intelligent: false } })), + ).toBe(false); + }); + + it('is on when intelligent is true', () => { + expect( + isIntelligentInterviewEnabled(baseConfig({ interview: { intelligent: true } })), + ).toBe(true); + }); + }); + + describe('validateInterviewConfig', () => { + it('accepts an absent or valid section', () => { + expect(validateInterviewConfig(baseConfig())).toBeNull(); + expect( + validateInterviewConfig(baseConfig({ interview: { intelligent: true } })), + ).toBeNull(); + expect( + validateInterviewConfig(baseConfig({ interview: { intelligent: false } })), + ).toBeNull(); + }); + + it('rejects a non-object section', () => { + expect( + validateInterviewConfig(baseConfig({ interview: 'yes' as unknown as object })), + ).toMatch(/interview must be an object/); + }); + + it('rejects an array section', () => { + expect( + validateInterviewConfig(baseConfig({ interview: [] as unknown as object })), + ).toMatch(/interview must be an object/); + }); + + it('rejects a non-boolean intelligent value', () => { + expect( + validateInterviewConfig( + baseConfig({ interview: { intelligent: 'yes' as unknown as boolean } }), + ), + ).toMatch(/interview\.intelligent must be a boolean/); + }); + }); + + describe('loadConfig with an interview section', () => { + it('preserves an intelligent: true section', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'interview:', + ' intelligent: true', + ]); + + const config = loadConfig(); + expect(config).not.toBeNull(); + expect(config?.interview).toEqual({ intelligent: true }); + expect(isIntelligentInterviewEnabled(config)).toBe(true); + }); + + it('leaves interview undefined when the section is absent', () => { + writeConfig(['projectName: Test Project', 'prefix: TEST']); + + const config = loadConfig(); + expect(config).not.toBeNull(); + expect(config?.interview).toBeUndefined(); + expect(isIntelligentInterviewEnabled(config)).toBe(false); + }); + + it('rejects a non-object interview section', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'interview: not-an-object', + ]); + + expect(loadConfig()).toBeNull(); + }); + + it('rejects a non-boolean interview.intelligent value', () => { + writeConfig([ + 'projectName: Test Project', + 'prefix: TEST', + 'interview:', + ' intelligent: maybe', + ]); + + expect(loadConfig()).toBeNull(); + }); + }); +}); From eaa4d682b796396efa939353ade22c23ab3b8954 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Mon, 5 Oct 2026 10:08:24 +0100 Subject: [PATCH 10/27] WL-0MUV0VEFW007ZBB7: Implementation complete --- docs/dev/downtime-dispatcher.md | 13 ++++++++----- packages/herdr/src/downtime-worker.ts | 10 ++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index 826b7b20e..f093804ae 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -1155,13 +1155,13 @@ as fallback — they are orphaned and ignored. Guarantees: The original spec (parent AC8) said "30s proxy poll + 4 min continuous idle threshold". **Accepted variance (2026-08-24): the code defaults are -canonical — 10s dispatch poll, 60s continuous idle threshold, with the proxy +canonical — 10s dispatch poll, 75s continuous idle threshold, with the proxy status refresh unchanged at 30s.** | Setting | Default | Source | |---|---|---| | Dispatch poll interval | **10 s** (`downtimePollIntervalMs`) | `DEFAULT_DOWNTIME_POLL_INTERVAL_MS`, floor 10 s (`downtime-worker.ts`) | -| LLM continuous idle threshold | **60 s** (`downtimeIdleThresholdMs`) | `DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS`, floor 1 s (`downtime-worker.ts`) | +| LLM continuous idle threshold | **75 s** (`downtimeIdleThresholdMs`) | `DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS`, floor 1 s (`downtime-worker.ts`) | | Proxy status refresh | 30 s (`refreshIntervalMs`) | `settings.ts` (unchanged, pre-refactor cadence) | | Leader lease TTL | 5 min (`DEFAULT_LEASE_TTL_SECONDS = 300`) | `leader-election.ts` | | Leader check-in | 4 min (`DEFAULT_LEADER_CHECK_IN_MS`) — leader re-offer + lease renew inside 5-min TTL | `downtime-worker.ts`, `leader-election.ts` | @@ -1178,8 +1178,11 @@ Both dispatch-poll and idle-threshold are configurable in the herdr plugin settings file (`~/.config/herdr/worklog-plugin.json`, `downtimePollIntervalMs` / `downtimeIdleThresholdMs`) and are clamped on load (see `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` in -`downtime-worker.ts`). The success-marker staleness window -(`downtimeMarkerStaleWindowMs`) is likewise configurable and clamped on load +`downtime-worker.ts`). The 75 s idle threshold exceeds the 1-minute +slot-exhaustion cooldown so that existing sessions have a chance to reclaim +the slot before a new work item is dispatched. The success-marker staleness +window (`downtimeMarkerStaleWindowMs`) is likewise configurable and clamped +on load (`clampDowntimeMarkerStaleWindowMs`). The non-terminal pane-close cooldown (`downtimeNonTerminalCooldownMs`) is likewise configurable and clamped on load (`clampDowntimeNonTerminalCooldownMs`). The per-item/per-kind attempt cap @@ -1221,7 +1224,7 @@ load (see `clampDowntimePollInterval` / `clampDowntimeIdleThresholdMs` in - **No `Downtime triggered …` pane but `anchor-unavailable` in logs:** neither the project workspace/item tab nor the Dispatcher fallback anchor could be provisioned (fail-closed, never the leader's pane). For the project path check the `pane list` / `process-info` / `/proc/<pid>/environ` reads (no `Work Items` pane for the root, missing `HERDR_RESOLVED_CWD`, or unreadable `/proc` on non-Linux); for the fallback check `~/.herdr/downtime/` writability, coordination lock contention (`downtime-coordination.lock`), and the `herdr workspace create --label Dispatcher` / `herdr tab create --workspace <id> --label <itemId> --no-focus` JSON parse (shape drift across herdr versions). The worker degrades to “no dispatch this cycle” and retries next idle tick; an empty/missing anchor file or unreadable machine dir is treated as missing (never a crash). - **Pane landed in the wrong project's workspace:** resolution is fail-closed — it never selects another root's workspace. Check (a) the item's `cwd` (worklog root) matches exactly the plugin pane's `HERDR_RESOLVED_CWD` (trailing-slash tolerant); (b) when ≥2 `Work Items` panes resolve to the same root the focused pane wins, else the lowest pane id — close the surplus plugin pane to remove the ambiguity; (c) stale `dist/` in the running plugin (rebuild with `npm run build` in `packages/herdr`). If no plugin pane resolves, panes intentionally fall back to the `Dispatcher` workspace — that is AC4, not a misroute. - **No dispatches happening:** confirm a leader is elected (lease file - present + recent `lastUpdated` refresh), the proxy reports idle for ≥ 60 s + present + recent `lastUpdated` refresh), the proxy reports idle for ≥ 75 s continuously, and the coordination list has offers. Check `downtime-coordination.log` for check-ins and the dispatches log for the last dispatch. diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 36fe74ddc..72084f91f 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -200,7 +200,13 @@ export const DOWNTIME_POLL_INTERVAL_FLOOR_MS = 10_000; export const DOWNTIME_IDLE_THRESHOLD_FLOOR_MS = 1_000; export const DEFAULT_DOWNTIME_POLL_INTERVAL_MS = 10_000; -export const DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS = 60_000; +/** + * Default LLM idle threshold: 75 seconds. Chosen so that the slot-exhaustion + * cooldown (1 min) has elapsed before the Dispatcher considers a slot fresh + * enough for a new work item — this gives currently-active sessions the + * opportunity to reclaim the slot first. + */ +export const DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS = 75_000; /** * Default required-free-slots count: 2 of 3 slots (spare-capacity dispatch, @@ -7366,7 +7372,7 @@ export function clampDowntimePollInterval(value: number): number { /** * Clamp the idle threshold: reject negative/non-finite (fall back to the - * 60s default, DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS) and floor at 1s to + * 75s default, DEFAULT_DOWNTIME_IDLE_THRESHOLD_MS) and floor at 1s to * prevent immediate dispatch. */ export function clampDowntimeIdleThresholdMs(value: number): number { From f57b90d5bdd21a759dfc01fab112848ae84ac852 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Mon, 5 Oct 2026 11:45:58 +0100 Subject: [PATCH 11/27] WL-0MUV4B3QZ007THLW: Implementation complete --- CLI.md | 2 +- src/commands/interview.ts | 4 ++-- tests/cli/interview.test.ts | 13 +++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CLI.md b/CLI.md index 780527866..54a370bef 100644 --- a/CLI.md +++ b/CLI.md @@ -1320,7 +1320,7 @@ silently to the **structured evidence**, in priority order: The rendered explanation preserves intentional line breaks and short Markdown structure — it is no longer collapsed into a single line — but collapses runs of spaces and repeated blank lines, and is bounded by a documented maximum -(`MAX_EXPLANATION_LENGTH`, 1000 characters, and `MAX_EXPLANATION_LINES`, 24 +(`MAX_EXPLANATION_LENGTH`, 8192 characters, and `MAX_EXPLANATION_LINES`, 512 lines). Truncation is marked with a trailing ellipsis. The explanation is advisory only — it never mutates the description or clears diff --git a/src/commands/interview.ts b/src/commands/interview.ts index 6627c3c93..3b02962bf 100644 --- a/src/commands/interview.ts +++ b/src/commands/interview.ts @@ -571,14 +571,14 @@ export const MAX_AUDIT_SUMMARY_BYTES = 2048; export const MAX_AUDIT_RAW_OUTPUT_BYTES = 2048; /** Maximum length (characters) of the rendered explanation. */ -export const MAX_EXPLANATION_LENGTH = 1000; +export const MAX_EXPLANATION_LENGTH = 8192; /** * Maximum number of lines in the rendered explanation. The render keeps * intentional line breaks (AC3) but caps how many are surfaced so a runaway * LLM response cannot flood the console. */ -export const MAX_EXPLANATION_LINES = 24; +export const MAX_EXPLANATION_LINES = 512; /** Default timeout (ms) for the explanation LLM call. */ export const EXPLANATION_TIMEOUT_MS = 15000; diff --git a/tests/cli/interview.test.ts b/tests/cli/interview.test.ts index f78957e02..b1e76979e 100644 --- a/tests/cli/interview.test.ts +++ b/tests/cli/interview.test.ts @@ -896,6 +896,19 @@ describe('buildProducerReviewExplanation', () => { const bounded = renderExplanation('x'.repeat(5000), 50, 50); expect(bounded.endsWith('…')).toBe(true); }); + + it('applies the raised default bounds (8192 chars / 512 lines)', () => { + // Single long line: the default character cap is 8192. + const long = renderExplanation('x'.repeat(10000)); + expect(long.length).toBeGreaterThan(8000); + expect(long.length).toBeLessThanOrEqual(8192); + + // Many short lines: the default line cap is 512, well above the old 24. + const manyLines = Array.from({ length: 600 }, (_, i) => `line ${i}`).join('\n'); + const lineBounded = renderExplanation(manyLines); + expect(lineBounded.split('\n').length).toBeGreaterThan(100); + expect(lineBounded.split('\n').length).toBeLessThanOrEqual(512); + }); }); describe('buildFallbackExplanation structured signals (AC4)', () => { From 386733c1f8900b50f58cfbde5c2de6cbc066b38d Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Mon, 5 Oct 2026 12:08:17 +0100 Subject: [PATCH 12/27] WL-0MUNMCZOX003VXTE: Implementation complete --- docs/design/mode-switch-worker.md | 2 +- packages/herdr/README.md | 2 +- packages/herdr/src/mode-switch-integration.test.ts | 2 +- packages/herdr/src/mode-switch-worker.test.ts | 10 +++++----- packages/herdr/src/mode-switch-worker.ts | 6 +++--- packages/herdr/src/settings.test.ts | 8 ++++---- packages/herdr/src/settings.ts | 2 +- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/design/mode-switch-worker.md b/docs/design/mode-switch-worker.md index e565eba56..0ea823f99 100644 --- a/docs/design/mode-switch-worker.md +++ b/docs/design/mode-switch-worker.md @@ -95,7 +95,7 @@ The mode-switch worker reuses the machine-wide downtime leader | Setting | Default | Clamp | |---|---|---| | `modeSwitchEnabled` | `true` | — (disabled ⇒ no scheduler task, no-op hook) | -| `modeSwitchIdleThresholdMs` | `3600000` (60 min) | floor `60000` | +| `modeSwitchIdleThresholdMs` | `900000` (15 min) | floor `60000` | | `modeSwitchPollIntervalMs` | `10000` | `[5000, 60000]` | The proxy URL is **not** a new setting — it reuses `downtimeProxyUrl`. diff --git a/packages/herdr/README.md b/packages/herdr/README.md index 2db04020b..ebb871485 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -908,7 +908,7 @@ New settings (all optional): when `false` the scheduler registers no mode-switch task and the agent-route hook is a no-op) - `modeSwitchIdleThresholdMs` — Operator-inactivity window before a cheap - switch is considered (default: `3600000` = 60 minutes, hard floor `60000`) + switch is considered (default: `900000` = 15 minutes, hard floor `60000`) - `modeSwitchPollIntervalMs` — Poll interval for the proxy idle check when evaluating the idle window (default: `10000`, clamped to `[5000, 60000]`) diff --git a/packages/herdr/src/mode-switch-integration.test.ts b/packages/herdr/src/mode-switch-integration.test.ts index c40665912..f9b58d6bc 100644 --- a/packages/herdr/src/mode-switch-integration.test.ts +++ b/packages/herdr/src/mode-switch-integration.test.ts @@ -116,7 +116,7 @@ describe('scheduler task configuration', () => { it('idle threshold floor is at least 60s', () => { expect(MODE_SWITCH_IDLE_THRESHOLD_FLOOR_MS).toBeGreaterThanOrEqual(60_000); - expect(DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS).toBe(3_600_000); // 60 minutes + expect(DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS).toBe(900_000); // 15 minutes }); it('run timeout watchdog exceeds the admin API timeout (hung ticks get abandoned)', () => { diff --git a/packages/herdr/src/mode-switch-worker.test.ts b/packages/herdr/src/mode-switch-worker.test.ts index 1794ad7e2..583a281b9 100644 --- a/packages/herdr/src/mode-switch-worker.test.ts +++ b/packages/herdr/src/mode-switch-worker.test.ts @@ -1532,11 +1532,11 @@ describe('graceful leader transitions (AC5)', () => { }); }); -describe('60-minute default (AC3)', () => { - it('defaults the idle threshold to 3_600_000 ms and clamps invalid input to it', () => { - expect(DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS).toBe(3_600_000); - expect(clampModeSwitchIdleThresholdMs(NaN)).toBe(3_600_000); - expect(clampModeSwitchIdleThresholdMs(-1)).toBe(3_600_000); +describe('15-minute default (AC3)', () => { + it('defaults the idle threshold to 900_000 ms and clamps invalid input to it', () => { + expect(DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS).toBe(900_000); + expect(clampModeSwitchIdleThresholdMs(NaN)).toBe(900_000); + expect(clampModeSwitchIdleThresholdMs(-1)).toBe(900_000); // The 60s floor is unchanged. expect(clampModeSwitchIdleThresholdMs(1_000)).toBe(MODE_SWITCH_IDLE_THRESHOLD_FLOOR_MS); }); diff --git a/packages/herdr/src/mode-switch-worker.ts b/packages/herdr/src/mode-switch-worker.ts index cdad7bdb8..ecd6c5e5c 100644 --- a/packages/herdr/src/mode-switch-worker.ts +++ b/packages/herdr/src/mode-switch-worker.ts @@ -103,8 +103,8 @@ export const ADMIN_MODE_PATH = '/admin/mode'; /** Proxy mode-switch endpoint path. */ export const ADMIN_SET_MODE_PATH = '/admin/set-mode'; -/** Default idle window before switching to cheap mode: 60 minutes. */ -export const DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS = 3_600_000; +/** Default idle window before switching to cheap mode: 15 minutes. */ +export const DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS = 900_000; /** * Free slots the proxy must report before the idle cheap switch completes. @@ -148,7 +148,7 @@ export const MODE_SWITCH_RUN_TIMEOUT_MS = 30_000; /** * Clamp the mode-switch idle threshold: reject negative/non-finite (fall - * back to the 60-minute default) and floor at 60s so the operator cannot + * back to the 15-minute default) and floor at 60s so the operator cannot * configure an immediate cheap-switch. */ export function clampModeSwitchIdleThresholdMs(value: number): number { diff --git a/packages/herdr/src/settings.test.ts b/packages/herdr/src/settings.test.ts index 30b5494a7..6a2ade87f 100644 --- a/packages/herdr/src/settings.test.ts +++ b/packages/herdr/src/settings.test.ts @@ -190,10 +190,10 @@ describe('modeSwitchEnabled', () => { }); describe('modeSwitchIdleThresholdMs', () => { - it('defaults to 3_600_000 ms (60 minutes)', () => { - expect(defaultSettings.modeSwitchIdleThresholdMs).toBe(3_600_000); + it('defaults to 900_000 ms (15 minutes)', () => { + expect(defaultSettings.modeSwitchIdleThresholdMs).toBe(900_000); const path = tempSettingsPath(); - expect(loadSettings(path).modeSwitchIdleThresholdMs).toBe(3_600_000); + expect(loadSettings(path).modeSwitchIdleThresholdMs).toBe(900_000); }); it('clamps a persisted value below the 60s floor up to the floor', () => { @@ -208,7 +208,7 @@ describe('modeSwitchIdleThresholdMs', () => { it('falls back to the default when the persisted value is not a number', () => { const path = tempSettingsPath(); writeFileSync(path, JSON.stringify({ ...defaultSettings, modeSwitchIdleThresholdMs: 'later' }), 'utf-8'); - expect(loadSettings(path).modeSwitchIdleThresholdMs).toBe(3_600_000); + expect(loadSettings(path).modeSwitchIdleThresholdMs).toBe(900_000); }); }); diff --git a/packages/herdr/src/settings.ts b/packages/herdr/src/settings.ts index 06a58c81b..6c39baddd 100644 --- a/packages/herdr/src/settings.ts +++ b/packages/herdr/src/settings.ts @@ -126,7 +126,7 @@ export interface PluginSettings { */ modeSwitchEnabled: boolean; /** - * Idle window before switching to cheap mode (ms). Default 3_600_000 (60 min). + * Idle window before switching to cheap mode (ms). Default 900_000 (15 min). * A new operator agent-route command resets this timer. */ modeSwitchIdleThresholdMs: number; From d9ea2100ef12856b54256257cc9edb8381971010 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Mon, 5 Oct 2026 15:34:20 +0100 Subject: [PATCH 13/27] WL-0MUNMD0XY006BTE7: Implementation complete --- docs/design/mode-switch-worker.md | 3 + .../src/drain-dispatch-integration.test.ts | 272 ++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 packages/herdr/src/drain-dispatch-integration.test.ts diff --git a/docs/design/mode-switch-worker.md b/docs/design/mode-switch-worker.md index 0ea823f99..2159a954c 100644 --- a/docs/design/mode-switch-worker.md +++ b/docs/design/mode-switch-worker.md @@ -117,6 +117,9 @@ time-based plan. - `packages/herdr/src/mode-switch-worker.test.ts` — worker core tests - `packages/herdr/src/mode-switch-integration.test.ts` — wiring tests (settings → worker, route classification, scheduler interval constants) +- `packages/herdr/src/drain-dispatch-integration.test.ts` — drain → dispatch + integration tests (the live `getIsDraining()` → `config().drainPaused` + wiring pauses, resumes and cancels dispatch) - `packages/herdr/src/settings.ts` — settings schema + validation/clamps - `packages/herdr/src/index.ts` — agent-route hook wiring - `packages/herdr/src/worklist.ts` — scheduler task registration \ No newline at end of file diff --git a/packages/herdr/src/drain-dispatch-integration.test.ts b/packages/herdr/src/drain-dispatch-integration.test.ts new file mode 100644 index 000000000..d34c04b69 --- /dev/null +++ b/packages/herdr/src/drain-dispatch-integration.test.ts @@ -0,0 +1,272 @@ +/** + * Integration tests for the drain-pause dispatch signal (F5, parent + * WL-0MUL0KO7Q003O7YJ). + * + * These are true integration tests: they wire the REAL mode-switch worker's + * `getIsDraining()` state into the REAL downtime worker's + * `config().drainPaused` — the exact production wiring in index.ts + * (`drainPaused: modeSwitchHolder.worker?.getIsDraining() ?? false`, + * F2 WL-0MUNMCZ2K003DZ50) — and assert the full loop: + * + * 1. while the mode-switch worker is draining, the downtime worker polls + * and tracks idle but does NOT dispatch a new work item; + * 2. once the drain completes (the proxy switches to cheap), the very next + * idle tick dispatches normally; + * 3. while NOT draining, dispatch works normally (fail-open baseline); + * 4. an operator command during the drain cancels it, so the downtime + * worker resumes dispatching on the next tick (the drain signal is live). + * + * The F2 unit tests pin the `drainPaused` config contract in isolation; the + * F4 tests pin the mode-switch drain state machine in isolation. These tests + * pin the WIRING between them, which neither unit suite covers. + * + * Run: npx vitest run packages/herdr/src/drain-dispatch-integration.test.ts + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + createDowntimeWorker, + createDowntimePoller, + type DowntimeWorkerDeps, + type LlamaStatus, +} from './downtime-worker.js'; +import { + createModeSwitchWorker, + type AdminApiFetcher, + type ProxyMode, +} from './mode-switch-worker.js'; +import { idleAllSlotsFree, jsonResponseFixture } from './downtime-worker.fixtures.js'; + +// ── Test helpers ────────────────────────────────────────────────────── + +let clock: number; + +function now(): number { + return clock; +} + +function advance(ms: number): void { + clock += ms; +} + +/** Build a per-slot `/llama/local/status` payload (LP-0MSG5TA7Y002GN39). */ +function perSlotStatus(overrides: Partial<LlamaStatus> = {}): LlamaStatus { + return { + llama_server_running: true, + active_query: false, + local_active_query: false, + model_switch_in_progress: false, + local_lease_active: false, + available_slots: 3, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: false }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + ...overrides, + }; +} + +/** + * 1 of 3 slots free, with the operator's session holding the two busy slots: + * the per-slot idle-entry gate passes (≥1 free) but the cheap pool's drain + * budget (≥2 free) is unmet → the mode-switch worker enters drain. + */ +function perSlotDrainPending(): LlamaStatus { + return perSlotStatus({ + active_query: true, + local_active_query: true, + local_lease_active: true, + available_slots: 1, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: true }, + { slot_id: 'slot-3', is_processing: false }, + ], + }); +} + +/** 2 of 3 slots free — the drain-completion budget is met. */ +function perSlotDrainComplete(): LlamaStatus { + return perSlotStatus({ + active_query: true, + local_active_query: true, + local_lease_active: true, + available_slots: 2, + total_slots: 3, + slots: [ + { slot_id: 'slot-1', is_processing: true }, + { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-3', is_processing: false }, + ], + }); +} + +interface MockAdminApi { + fetcher: AdminApiFetcher; +} + +/** A minimal admin API: `GET /admin/mode` always `fast`, `POST` always 200. */ +function mockAdminApi(): MockAdminApi { + let mode: ProxyMode = 'fast'; + const fetcher: AdminApiFetcher = async (_url, init) => { + if ((init?.method ?? 'GET') === 'POST') { + mode = JSON.parse(init?.body ?? '{}').mode ?? mode; + return { ok: true, status: 200, json: async () => ({ ok: true }) }; + } + return { ok: true, status: 200, json: async () => ({ mode }) }; + }; + return { fetcher }; +} + +/** + * Dispatch-capable downtime-worker deps (mirrors the shared `makeDeps` in + * downtime-worker.test.ts): an empty Herdr list falls through to the legacy + * tier chain, where the intake tier resolves the stubbed candidate and the + * pane spawn succeeds. + */ +function makeDeps(): DowntimeWorkerDeps { + return { + getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [] }), + getNextItem: vi.fn().mockResolvedValue({ + ok: true, + candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, + }), + getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + getActiveAudit: vi.fn().mockResolvedValue({ ok: true, active: false }), + getNextImplementCandidate: vi.fn().mockResolvedValue(null), + getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), + claimItem: vi.fn().mockResolvedValue({ ok: true }), + spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), + recordDispatch: vi.fn().mockResolvedValue(true), + recordDispatchFailure: vi.fn().mockResolvedValue(undefined), + rollbackClaim: vi.fn().mockResolvedValue(true), + recordError: vi.fn().mockResolvedValue(undefined), + getDueScheduledPrompt: vi.fn().mockResolvedValue(null), + recordScheduledPromptTrigger: vi.fn().mockResolvedValue(true), + readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), + hasFreshAudit: vi.fn().mockResolvedValue(false), + getReviewQueueCount: vi.fn().mockResolvedValue(0), + getRunningDowntimePanes: vi + .fn() + .mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }), + }; +} + +/** + * Build a downtime worker whose `drainPaused` reads the live mode-switch drain + * state — the production index.ts wiring. + */ +function makeWiredDowntimeWorker(modeSwitch: ReturnType<typeof createModeSwitchWorker>) { + const deps = makeDeps(); + const poller = createDowntimePoller( + 'http://proxy:8000', + vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)), + ); + const worker = createDowntimeWorker({ + poller, + deps, + config: () => ({ + enabled: true, + // A zero threshold makes the idle run dispatch on its first idle tick, + // so the tests isolate the drain signal rather than idle timing. + thresholdMs: 0, + requiredFreeSlots: 0, + model: 'plan', + cwd: '/repo', + noCandidateCooldownMs: 3_600_000, + drainPaused: modeSwitch.getIsDraining(), + }), + }); + return { worker, deps }; +} + +// ── Drain-pause integration ─────────────────────────────────────────── + +describe('mode-switch drain → downtime dispatch integration', () => { + it('pauses new dispatches while draining, then resumes once the drain completes', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const modeSwitch = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + + // Idle window met + proxy idle but only 1 free slot → enter drain. + await modeSwitch.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotDrainPending(), + }); + expect(modeSwitch.getIsDraining()).toBe(true); + + const { worker, deps } = makeWiredDowntimeWorker(modeSwitch); + + // While draining: the worker polls (so the mode-switch worker keeps + // observing free slots) but never spawns a new pane. + const paused = await worker.tick(); + expect(paused).toEqual({ polled: true, dispatched: false, idle: true }); + expect(worker.blockReason).toBe('draining'); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + // The cheap budget frees → the drain completes and the proxy switches to + // cheap. + await modeSwitch.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotDrainComplete(), + }); + expect(modeSwitch.getIsDraining()).toBe(false); + expect(modeSwitch.getLastKnownMode()).toBe('cheap'); + + // The next idle tick dispatches normally (the drain signal is live). + const resumed = await worker.tick(); + expect(resumed.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('dispatches normally while NOT draining (fail-open baseline)', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + // Never ticked → not draining. + const modeSwitch = createModeSwitchWorker({ fetcher: api.fetcher, now }); + expect(modeSwitch.getIsDraining()).toBe(false); + + const { worker, deps } = makeWiredDowntimeWorker(modeSwitch); + + const result = await worker.tick(); + expect(result.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); + + it('an operator command during drain cancels it, so dispatch resumes on the next tick', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + const modeSwitch = createModeSwitchWorker({ fetcher: api.fetcher, now }); + advance(900_000); + await modeSwitch.tick({ + enabled: true, + idleThresholdMs: 900_000, + proxyUrl: 'http://proxy', + proxyStatus: perSlotDrainPending(), + }); + expect(modeSwitch.getIsDraining()).toBe(true); + + const { worker, deps } = makeWiredDowntimeWorker(modeSwitch); + const paused = await worker.tick(); + expect(paused.dispatched).toBe(false); + expect(deps.spawnAgentPane).not.toHaveBeenCalled(); + + // The operator returns: the drain is abandoned and the idle clock resets. + modeSwitch.onOperatorCommand('http://proxy'); + expect(modeSwitch.getIsDraining()).toBe(false); + + // Dispatch resumes on the next tick — the drain signal was live, not + // cached at worker construction. + const resumed = await worker.tick(); + expect(resumed.dispatched).toBe(true); + expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); + }); +}); From ed0acc60ac27856af81773b09ca0069d602fd31d Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Mon, 5 Oct 2026 22:55:54 +0100 Subject: [PATCH 14/27] CG-0MUUFGXFX004O2X7: Serialise mutating commands on store lock (WL-0MUV2U9QF002S9J1) --- DATA_SYNCING.md | 17 ++ docs/design/incremental-sync.md | 7 +- src/commands/audit-result.ts | 5 +- src/commands/comment.ts | 15 +- src/commands/update.ts | 5 +- src/mutation-lock.ts | 37 ++++ tests/cli/sync-vs-mutation.test.ts | 290 +++++++++++++++++++++++++++++ 7 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 src/mutation-lock.ts create mode 100644 tests/cli/sync-vs-mutation.test.ts diff --git a/DATA_SYNCING.md b/DATA_SYNCING.md index ca76e0472..b092f8f7e 100644 --- a/DATA_SYNCING.md +++ b/DATA_SYNCING.md @@ -62,6 +62,23 @@ To prevent this, auto-sync spawners pass `wl sync --if-idle`: Manual `wl sync` (without `--if-idle`) keeps the original behavior: it waits for the lock (with exponential backoff) and runs to completion. +### Concurrent Writes: Sync vs Mutating Commands + +`wl sync` holds the per-store advisory file lock (`.worklog/worklog-data.jsonl.lock`, via `withFileLock` / `getLockPathForJsonl`) for its entire fetch → merge → write → push operation. The single-command mutators `wl update`, `wl comment add`, and `wl audit-set` acquire the **same** lock around their own read-modify-write, so a mutation and a sync can never interleave: + +- If a mutation is in flight, `wl sync` waits for the lock instead of taking its snapshot while a write is landing. +- If a sync is in flight, a mutating command waits (bounded by the lock timeout, 30 s by default) instead of writing into the sync's read→write window. + +This closes a lost-update race (WL-0MUV2U9QF002S9J1) in which a sync's stale snapshot overwrote a concurrent terminal `completed`/`in_review` transition and dropped a comment added in the window. The guarantee is **no lost update**: a mutation that commits after the sync's local read is never overwritten by the sync's write-back. + +What is deliberately unchanged: + +- **Intentional reopen / auto-revert still works.** A newer local `open` (including the `wl audit-set --ready-to-close no` auto-revert to `open`/`plan_complete`) still beats an older remote `completed`/`in_review` — the lock serialises the writes, it does not block the transition. +- **`wl sync --if-idle` still skips instead of queueing** when the lock is held (see above). +- **Comments remain add-only** in the merge layer. + +Regression coverage: `tests/cli/sync-vs-mutation.test.ts` parks a `wl sync` inside `git fetch` and asserts that a concurrent terminal-state update and comment both survive. + ### Config Options Set in `.worklog/config.yaml` (local) or `.worklog/config.defaults.yaml` (team defaults): diff --git a/docs/design/incremental-sync.md b/docs/design/incremental-sync.md index 1aa9caafd..9e34dc9bd 100644 --- a/docs/design/incremental-sync.md +++ b/docs/design/incremental-sync.md @@ -293,7 +293,12 @@ the records changed since the last full snapshot — not unbounded history. The incremental path runs entirely inside the existing sync flow, so it inherits unchanged: -- the file lock (`withFileLock`, `getLockPathForJsonl`), +- the file lock (`withFileLock`, `getLockPathForJsonl`) — also acquired by the + mutating commands `wl update` / `wl comment add` / `wl audit-set` around + their read-modify-write, so a sync can never overwrite a mutation that + landed after its local read (no lost update; see + [DATA_SYNCING.md § Concurrent Writes](../DATA_SYNCING.md#concurrent-writes-sync-vs-mutating-commands) and + `tests/cli/sync-vs-mutation.test.ts`), - the single-flight guard and `--if-idle` skip semantics (WL-0MSAB7ZUC004SK7E), - the ephemeral JSONL pattern (SQLite → JSONL → push → delete). diff --git a/src/commands/audit-result.ts b/src/commands/audit-result.ts index 2c46066c3..64dc5d6fd 100644 --- a/src/commands/audit-result.ts +++ b/src/commands/audit-result.ts @@ -8,6 +8,7 @@ import type { PluginContext } from '../plugin-types.js'; import { promises as fs } from 'fs'; import { extractAuditFingerprint, formatInvalidAuditFirstLineMessage, inspectAuditFirstLine, redactAuditText, resolveAuditAuthor } from '../audit.js'; +import { withStoreMutationLock } from '../mutation-lock.js'; export default function register(ctx: PluginContext): void { const { program, output, utils } = ctx; @@ -111,7 +112,7 @@ export default function register(ctx: PluginContext): void { fingerprint?: string; prefix?: string; json?: boolean; - }) => { + }) => withStoreMutationLock(ctx.dataPath, async () => { utils.requireInitialized(); const db = utils.getDatabase(options.prefix); @@ -237,5 +238,5 @@ export default function register(ctx: PluginContext): void { if (reverted) { console.log(`[${reverted.item.id} reverted from ${reverted.from.status}/${reverted.from.stage} to ${reverted.to.status}/${reverted.to.stage}]`); } - }); + })); } \ No newline at end of file diff --git a/src/commands/comment.ts b/src/commands/comment.ts index 83d32dc8a..5064bd8cf 100644 --- a/src/commands/comment.ts +++ b/src/commands/comment.ts @@ -13,6 +13,7 @@ import type { } from '../cli-types.js'; import type { UpdateCommentInput } from '../types.js'; import { humanFormatComment, resolveFormat } from './helpers.js'; +import { withStoreMutationLock } from '../mutation-lock.js'; export default function register(ctx: PluginContext): void { const { program, output, utils } = ctx; @@ -74,12 +75,14 @@ export default function register(ctx: PluginContext): void { process.exit(1); } - const comment = db.createComment({ - workItemId: normalizedWorkItemId, - author: options.author, - comment: commentText, - references: refs, - }); + const comment = withStoreMutationLock(ctx.dataPath, () => + db.createComment({ + workItemId: normalizedWorkItemId, + author: options.author, + comment: commentText, + references: refs, + }) + ); if (!comment) { output.error(`Work item not found: ${workItemId}`, { success: false, error: `Work item not found: ${workItemId}` }); diff --git a/src/commands/update.ts b/src/commands/update.ts index 3a6b8df9f..9ab6fe112 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -14,6 +14,7 @@ import { normalizeActionArgs } from './cli-utils.js'; import { buildAuditEntry, extractAuditFingerprint, formatInvalidAuditFirstLineMessage, inspectAuditFirstLine, redactAuditText } from '../audit.js'; import { loadStatusStageRules, normalizeStatusValue } from '../status-stage-rules.js'; import { normalizePriority, CANONICAL_PRIORITIES } from '../validators/priority.js'; +import { withStoreMutationLock } from '../mutation-lock.js'; export default function register(ctx: PluginContext): void { const { program, output, utils } = ctx; @@ -47,7 +48,7 @@ export default function register(ctx: PluginContext): void { .option('--prefix <prefix>', 'Override the default prefix') .option('--no-re-sort', 'Skip automatic re-sort after the update') .option('--re-sort-sync', 'Force a synchronous re-sort after the update', false) - .action(async (...rawArgs: any[]) => { + .action(async (...rawArgs: any[]) => withStoreMutationLock(ctx.dataPath, async () => { // Accept re-sort flags to control automatic re-sort behavior after writes // --no-re-sort: skip auto re-sort // --re-sort-sync: force synchronous re-sort (blocking) @@ -634,5 +635,5 @@ export default function register(ctx: PluginContext): void { else void Promise.resolve().then(() => (db as any).reSort()); } } catch (_e) {} - }); + })); } diff --git a/src/mutation-lock.ts b/src/mutation-lock.ts new file mode 100644 index 000000000..f8186ecf9 --- /dev/null +++ b/src/mutation-lock.ts @@ -0,0 +1,37 @@ +/** + * Serialise mutating CLI commands on the same per-store advisory file lock + * that `wl sync` holds for its entire fetch → merge → write → push operation. + * + * Background (WL-0MUV2U9QF002S9J1 / CG-0MUUFGXFX004O2X7): `wl sync` reads a + * snapshot of the store, merges it in memory, then writes the merged set back. + * A mutating command (`wl update`, `wl comment add`, `wl audit-set`) that + * landed inside that read→write window was previously not serialised against + * the sync, so the sync's stale snapshot overwrote it — losing the status + * transition and any comment added in the window. Lost comments (which the + * merge layer otherwise only ever adds) were the tell-tale second symptom. + * + * Wrapping the mutation's read-modify-write in the same `withFileLock` that + * `sync` uses means a mutation and a sync can never interleave: the mutation + * either completes before the sync takes its snapshot or waits until the sync + * has released the lock. This is the fix chosen by the producer in preference + * to an optimistic `updatedAt` re-check (which would touch every write path). + * + * Reentrancy: `withFileLock` is reentrant per process, so a mutation that + * internally triggers a sync (e.g. an auto-sync path) does not deadlock. + */ + +import { withFileLock, getLockPathForJsonl } from './file-lock.js'; + +/** + * Run `fn` while holding the store's mutation lock. + * + * @param dataPath - The store's JSONL data-file path (`ctx.dataPath`); the + * lock path is derived with the same `getLockPathForJsonl` used by `sync`, + * so every participant contends on one lock. + * @param fn - The read-modify-write callback (sync or async). The lock is + * released when it settles, including on error. + * @returns Whatever `fn` returns (a promise when `fn` is async). + */ +export function withStoreMutationLock<T>(dataPath: string, fn: () => T): T { + return withFileLock(getLockPathForJsonl(dataPath), fn); +} diff --git a/tests/cli/sync-vs-mutation.test.ts b/tests/cli/sync-vs-mutation.test.ts new file mode 100644 index 000000000..e76b21c87 --- /dev/null +++ b/tests/cli/sync-vs-mutation.test.ts @@ -0,0 +1,290 @@ +/** + * Regression tests for the lost-update race between `wl sync` and the + * single-command mutators (WL-0MUV2U9QF002S9J1, origin CG-0MUUFGXFX004O2X7). + * + * `wl sync` holds the per-store advisory file lock for its whole + * fetch → merge → write → push operation, but `wl update`, `wl comment add` + * and `wl audit-set` historically did not. A mutation that landed inside the + * sync's read→write window was therefore overwritten by the sync's stale + * snapshot: a terminal `completed`/`in_review` transition was reverted and a + * concurrently-added comment was lost. + * + * These tests use REAL git + a real bare remote against the compiled CLI + * (dist/cli.js), and a PATH git shim that parks the sync inside `git fetch` + * until the test releases it. Parking the sync widens the read→write window + * deterministically, so the overlap does not depend on timing races. + * + * AC1: the terminal state + concurrently-added comment survive the sync. + * AC3: an intentional `wl audit-set --ready-to-close no` still reverts + * `completed`/`in_review` → `open`/`plan_complete` (the confounder is + * excluded, not the defect). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawn, spawnSync, execFileSync, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { writeConfig, writeInitSemaphore } from './cli-helpers.js'; + +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const projectRoot = path.resolve(__dirname, '..', '..'); +const cliPath = path.join(projectRoot, 'dist', 'cli.js'); +const mockBinDir = path.join(projectRoot, 'tests', 'cli', 'mock-bin'); + +/** PATH without the test mock-bin so subprocesses run the real git binary. */ +function realGitEnv(): Record<string, string> { + const pathVal = (process.env.PATH || '') + .split(path.delimiter) + .filter(p => path.resolve(p) !== path.resolve(mockBinDir)) + .join(path.delimiter); + return { ...process.env as Record<string, string>, PATH: pathVal }; +} + +function git(cwd: string, ...args: string[]): string { + const res = spawnSync('git', args, { cwd, encoding: 'utf-8', env: realGitEnv() }); + if (res.status !== 0) { + throw new Error(`git ${args.join(' ')} failed: ${res.stderr || res.stdout}`); + } + return (res.stdout || '').trim(); +} + +function runCli( + cwd: string, + args: string[], + env?: Record<string, string>, +): { stdout: string; stderr: string; status: number } { + const res = spawnSync('node', [cliPath, ...args], { + cwd, + encoding: 'utf-8', + env: env ?? realGitEnv(), + timeout: 60000, + }); + return { stdout: res.stdout || '', stderr: res.stderr || '', status: res.status ?? -1 }; +} + +interface SpawnResult { + stdout: string; + stderr: string; + status: number | null; +} + +interface SpawnedCli { + child: ChildProcess; + done: Promise<SpawnResult>; +} + +/** Spawn the compiled CLI without blocking the test process. */ +function spawnCli( + cwd: string, + args: string[], + env: Record<string, string>, +): SpawnedCli { + const child = spawn('node', [cliPath, ...args], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (d: Buffer) => { stdout += d.toString(); }); + child.stderr?.on('data', (d: Buffer) => { stderr += d.toString(); }); + const done = new Promise<SpawnResult>((resolve, reject) => { + child.on('error', reject); + child.on('close', status => resolve({ stdout, stderr, status })); + }); + return { child, done }; +} + +/** Wait until *file* exists (or throw after *timeoutMs*). */ +async function waitForFile(file: string, timeoutMs = 15000): Promise<void> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (fs.existsSync(file)) return; + await new Promise(r => setTimeout(r, 25)); + } + throw new Error(`Timed out waiting for file: ${file}`); +} + +/** Shape of the subset of `wl show --json` this suite asserts on. */ +interface ShowResult { + workItem: { id: string; status: string; stage: string }; + comments: Array<{ author: string; comment: string }>; +} + +function showItem(cwd: string, id: string): ShowResult { + const res = runCli(cwd, ['--json', 'show', id]); + expect(res.status, res.stderr).toBe(0); + return JSON.parse(res.stdout) as ShowResult; +} + +interface Setup { + root: string; + remote: string; + local: string; + shimDir: string; + marker: string; + release: string; + realGit: string; +} + +/** + * Build a git shim that parks every `git fetch` until a release file appears. + * This widens `wl sync`'s read→write window deterministically. + */ +function setupProject(): Setup { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-sync-mutation-')); + const remote = path.join(root, 'remote.git'); + const local = path.join(root, 'local'); + git(root, 'init', '--bare', '-q', remote); + git(root, 'init', '-q', local); + git(local, 'config', 'user.email', 'test@example.com'); + git(local, 'config', 'user.name', 'Test User'); + git(local, 'remote', 'add', 'origin', remote); + writeConfig(local, 'Sync Mutation Test', 'SMT'); + writeInitSemaphore(local); + fs.writeFileSync(path.join(local, 'README.md'), '# sync mutation\n', 'utf8'); + git(local, 'add', '-A'); + git(local, 'commit', '-q', '-m', 'init'); + + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wl-git-shim-')); + const marker = path.join(shimDir, 'fetch-started'); + const release = path.join(shimDir, 'fetch-release'); + const realGit = execFileSync('which', ['git'], { + encoding: 'utf-8', + env: realGitEnv(), + }).trim(); + const shim = [ + '#!/bin/sh', + 'if [ "$1" = "fetch" ]; then', + ' : > "$GIT_SHIM_MARKER"', + ' n=0', + ' while [ ! -f "$GIT_SHIM_RELEASE" ] && [ "$n" -lt 600 ]; do', + ' sleep 0.1', + ' n=$((n + 1))', + ' done', + 'fi', + 'exec "$GIT_SHIM_REAL" "$@"', + '', + ].join('\n'); + const shimPath = path.join(shimDir, 'git'); + fs.writeFileSync(shimPath, shim, { mode: 0o755 }); + + return { root, remote, local, shimDir, marker, release, realGit }; +} + +/** Environment for a spawned sync: the git shim is first on PATH. */ +function shimEnv(s: Setup): Record<string, string> { + return { + ...realGitEnv(), + PATH: `${s.shimDir}${path.delimiter}${realGitEnv().PATH}`, + GIT_SHIM_MARKER: s.marker, + GIT_SHIM_RELEASE: s.release, + GIT_SHIM_REAL: s.realGit, + }; +} + +/** Create a work item via the CLI and return its id. */ +function createItem(local: string, title: string): string { + const res = runCli(local, ['--json', 'create', '-t', title]); + if (res.status !== 0) { + throw new Error(`wl create failed: ${res.stderr}\n${res.stdout}`); + } + const parsed = JSON.parse(res.stdout); + const id: string = parsed.workItem?.id ?? parsed.id; + expect(id).toBeTruthy(); + return id; +} + +describe('wl sync vs concurrent mutation (lost-update race)', () => { + let s: Setup; + + beforeEach(() => { + s = setupProject(); + }); + + afterEach(() => { + fs.rmSync(s.root, { recursive: true, force: true }); + fs.rmSync(s.shimDir, { recursive: true, force: true }); + }); + + it('AC1/AC2: a concurrent completed/in_review update and comment survive a sync', async () => { + const id = createItem(s.local, 'Concurrent mutation target'); + + // Seed the remote with a first full snapshot (also establishes the + // watermark baseline so the second sync exercises the merge write-back). + const seed = runCli(s.local, ['--json', 'sync']); + expect(seed.status, seed.stderr).toBe(0); + expect(seed.stdout).toContain('"success": true'); + + // Park the sync inside `git fetch` (after it has taken its local + // snapshot, before it writes the merged set back). + const sync = spawnCli(s.local, ['--json', 'sync'], shimEnv(s)); + await waitForFile(s.marker, 20000); + + // Mutate the item to a terminal state and add a comment while the sync + // is parked. On the fixed code both commands wait for the sync's lock; + // on the buggy code they land inside the window and are clobbered. + const mutation = (async () => { + const upd = spawnCli( + s.local, + ['--json', 'update', id, '-s', 'completed', '--stage', 'in_review'], + realGitEnv(), + ); + const updRes = await upd.done; + expect(updRes.status, updRes.stderr).toBe(0); + const cmt = spawnCli( + s.local, + ['--json', 'comment', 'add', id, '-a', 'test', '-c', 'completed concurrently'], + realGitEnv(), + ); + const cmtRes = await cmt.done; + expect(cmtRes.status, cmtRes.stderr).toBe(0); + })(); + + // Give the mutation a chance to complete before we release the sync. On + // the buggy code it finishes here; on the fixed code it is still blocked + // on the store lock and will finish after the sync releases it. + await Promise.race([ + mutation, + new Promise(r => setTimeout(r, 3000)), + ]); + + // Release the parked fetch and let both processes finish. + fs.writeFileSync(s.release, 'go'); + const syncRes = await sync.done; + expect(syncRes.status, syncRes.stderr).toBe(0); + await mutation; + + // The terminal state and the comment must both have survived the sync. + const after = showItem(s.local, id); + expect(after.workItem.status).toBe('completed'); + expect(after.workItem.stage).toBe('in_review'); + expect( + after.comments.some(c => c.comment.includes('completed concurrently')), + ).toBe(true); + }, 90000); + + it('AC3: an intentional "Ready to close: No" still reverts to open/plan_complete', () => { + const id = createItem(s.local, 'Intentional revert target'); + + const complete = runCli( + s.local, + ['--json', 'update', id, '-s', 'completed', '--stage', 'in_review'], + ); + expect(complete.status, complete.stderr).toBe(0); + expect(showItem(s.local, id).workItem.status).toBe('completed'); + + // The documented auto-revert (WL-0MSKHYI5U0069FVV) is a legitimate + // lifecycle transition and must remain intact. + const audit = runCli( + s.local, + ['--json', 'audit-set', id, '--ready-to-close', 'no', '--summary', 'not ready'], + ); + expect(audit.status, audit.stderr).toBe(0); + + const after = showItem(s.local, id); + expect(after.workItem.status).toBe('open'); + expect(after.workItem.stage).toBe('plan_complete'); + }, 60000); +}); From 6531a5b158588645164fa300c3c07d8820bc6f82 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 03:08:53 +0100 Subject: [PATCH 15/27] feat(recovery): retry finish_reason: error in SERVER_ERROR category (WL-0MUQO8AEE003G1Z2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add retryable patterns for 'Provider finish_reason: error' and 'finish_reason: error' to DEFAULT_SERVER_ERROR_PATTERNS. pi-ai's mapStopReason() collapses the enriched finish_reason payload to this generic string, discarding type/message/suggested_action. The SERVER_ERROR category already has exponential backoff configured (2000ms base, 60000ms max), so these errors are now retried with the recovery loop instead of being lost. ClassifyError now matches these patterns before the default UNKNOWN fallback. Tests cover both error message variants with case-insensitive matching. Note: suggested_action is forwarded on the wire but mapStopReason() currently drops it — see upstream tracking at LP-0MUI5B98B0080CIB. --- .../lib/recovery/error-classification.test.ts | 16 ++++++++++++++++ .../Worklog/lib/recovery/error-patterns.ts | 9 +++++++++ 2 files changed, 25 insertions(+) diff --git a/packages/tui/extensions/Worklog/lib/recovery/error-classification.test.ts b/packages/tui/extensions/Worklog/lib/recovery/error-classification.test.ts index a8af1286b..423b9fcc4 100644 --- a/packages/tui/extensions/Worklog/lib/recovery/error-classification.test.ts +++ b/packages/tui/extensions/Worklog/lib/recovery/error-classification.test.ts @@ -96,6 +96,15 @@ describe('isServerError', () => { expect(isServerError(makeErrorMsg('429 Too Many Requests'))).toBe(false); }); + it('detects "finish_reason: error" from pi-ai mapStopReason()', () => { + // pi-ai collapses the enriched finish_reason payload to a generic string, + // discarding the type/message/suggested_action details (WL-0MUQO8AEE003G1Z2). + expect(isServerError(makeErrorMsg('Provider finish_reason: error'))).toBe(true); + expect(isServerError(makeErrorMsg('finish_reason: error'))).toBe(true); + expect(isServerError(makeErrorMsg('PROVIDER FINISH_REASON: ERROR'))).toBe(true); + expect(isServerError(makeErrorMsg('provider finish_reason: error'))).toBe(true); + }); + it('returns false for non-error messages', () => { expect(isServerError(makeErrorMsg('', 'stop'))).toBe(false); }); @@ -297,6 +306,13 @@ describe('classifyError', () => { expect(classifyError(makeErrorMsg('503 Service Unavailable'))).toBe(ErrorCategory.SERVER_ERROR); }); + it('classifies "Provider finish_reason: error" as SERVER_ERROR (retryable)', () => { + // pi-ai mapStopReason() produces this string; it must be retried via the + // SERVER_ERROR loop (WL-0MUQO8AEE003G1Z2). + expect(classifyError(makeErrorMsg('Provider finish_reason: error'))).toBe(ErrorCategory.SERVER_ERROR); + expect(classifyError(makeErrorMsg('finish_reason: error'))).toBe(ErrorCategory.SERVER_ERROR); + }); + it('classifies "all providers exhausted" as SERVER_ERROR (auto-retry)', () => { expect(classifyError(makeErrorMsg('All providers exhausted'))).toBe(ErrorCategory.SERVER_ERROR); expect(classifyError(makeErrorMsg('No provider available'))).toBe(ErrorCategory.SERVER_ERROR); diff --git a/packages/tui/extensions/Worklog/lib/recovery/error-patterns.ts b/packages/tui/extensions/Worklog/lib/recovery/error-patterns.ts index dee1af3f3..854864718 100644 --- a/packages/tui/extensions/Worklog/lib/recovery/error-patterns.ts +++ b/packages/tui/extensions/Worklog/lib/recovery/error-patterns.ts @@ -97,6 +97,15 @@ const DEFAULT_SERVER_ERROR_PATTERNS: RegExp[] = [ /server\s*encountered/i, /all providers exhausted/i, /no provider available/i, + // pi-ai mapStopReason() collapses the enriched finish_reason payload to + // the generic string "Provider finish_reason: error", discarding type/ + // message/suggested_action. Classify as server-error so the recovery loop + // retries with exponential backoff rather than losing a long task + // (WL-0MUQO8AEE003G1Z2). Note: suggested_action is forwarded on the wire + // but mapStopReason() currently drops it — see upstream tracking at + // LP-0MUI5B98B0080CIB. + /finish_reason:\s*error/i, + /provider finish_reason:\s*error/i, ]; const DEFAULT_AUTH_ERROR_PATTERNS: RegExp[] = [ From 34734218a297ee3e304db491db15238a2c86b89f Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 04:20:24 +0100 Subject: [PATCH 16/27] feat: replace single-key c shortcut with c-n/c-q chords (WL-0MTCLB50D0026YA7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-key 'c' shortcut with two chord options: - [c, n] → /intake command (create intake) — same as existing 'n' gate - [c, q] → !!wl create (create quick) — no issue-type gating Both chords visible in the help panel under leader key c. Adds 9 unit tests for chord resolution, help hints, and gating. --- packages/herdr/src/shortcut-config.test.ts | 87 ++++++++++++++++++++++ packages/herdr/src/shortcuts.json | 20 ++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/herdr/src/shortcut-config.test.ts b/packages/herdr/src/shortcut-config.test.ts index 1aee5cc7f..cb39747b3 100644 --- a/packages/herdr/src/shortcut-config.test.ts +++ b/packages/herdr/src/shortcut-config.test.ts @@ -307,6 +307,93 @@ describe('loadShortcutConfig — production shortcuts.json', () => { expect(reject?.command).toContain('--status open --stage plan_complete --priority medium'); }); +// ── c chord split: c-n (create intake) and c-q (create quick) (WL-0MTCLB50D0026YA7) ── + +describe('loadShortcutConfig — c chord split (WL-0MTCLB50D0026YA7)', () => { + it('no longer registers the old single-key c shortcut', () => { + const registry = loadShortcutConfig(); + expect(registry.lookupChordEntry(['c'], 'list', undefined, false)).toBeUndefined(); + expect(registry.lookupChordEntry(['c'], 'detail', undefined, false)).toBeUndefined(); + expect(registry.lookupChord(['c'], 'list')).toBeUndefined(); + }); + + it('registers [c, n] as create-intake (c-n chord)', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['c', 'n'], 'list', undefined, false); + expect(entry).toBeDefined(); + expect(entry?.command).toBe("/intake <description> --priority <priority default='medium'>"); + expect(entry?.label).toBe('create intake'); + expect(entry?.view).toBe('both'); + expect(entry?.model).toBe('plan'); + // c-n has the same work_item_types gate as the n shortcut + expect(entry?.workItemTypes).toEqual(['bug', 'docs', 'feature', 'task', 'chore', 'epic']); + }); + + it('registers [c, q] as create-quick (c-q chord)', () => { + const registry = loadShortcutConfig(); + const entry = registry.lookupChordEntry(['c', 'q'], 'list', undefined, false); + expect(entry).toBeDefined(); + expect(entry?.command).toBe("!!wl create -t <title> -d <description> -p <priority default='medium'>"); + expect(entry?.label).toBe('create quick'); + expect(entry?.view).toBe('both'); + // c-q has NO work_item_types gating — it creates a new root item + expect(entry?.workItemTypes).toBeUndefined(); + }); + + it('resolves both c-n and c-q via lookupChord', () => { + const registry = loadShortcutConfig(); + expect(registry.lookupChord(['c', 'n'], 'list')).toBe("/intake <description> --priority <priority default='medium'>"); + expect(registry.lookupChord(['c', 'q'], 'list')).toBe("!!wl create -t <title> -d <description> -p <priority default='medium'>"); + }); + + it('c-n and c-q appear under leader key c in help hints', () => { + const registry = loadShortcutConfig(); + const nextChords = registry.getChordByLeader('c', 'list', false); + expect(nextChords).toHaveLength(2); + + const labels = nextChords.map(e => e.label); + expect(labels).toContain('create intake'); + expect(labels).toContain('create quick'); + + const hints = formatChordHints(nextChords, ['c']); + expect(hints).toContain('n:'); + expect(hints).toContain('q:'); + }); + + it('c-n respects work_item_types gating (same as n shortcut)', () => { + const registry = loadShortcutConfig(); + const codeTypes = ['bug', 'docs', 'feature', 'task', 'chore', 'epic']; + for (const t of codeTypes) { + expect(registry.lookupChord(['c', 'n'], 'list', undefined, false, t)).toBeDefined(); + } + // Hidden on podcast-typed items + expect(registry.lookupChord(['c', 'n'], 'list', undefined, false, 'podcast')).toBeUndefined(); + }); + + it('c-q has no issue-type gating', () => { + const registry = loadShortcutConfig(); + // Available on every issue type + for (const t of ['bug', 'docs', 'feature', 'task', 'chore', 'epic', 'podcast']) { + expect(registry.lookupChord(['c', 'q'], 'list', undefined, false, t)).toBeDefined(); + } + }); + + it('c-n and c-q are visible in both list and detail views', () => { + const registry = loadShortcutConfig(); + for (const chord of [['c', 'n'], ['c', 'q']]) { + expect(registry.lookupChordEntry(chord as ['c', 'n'], 'list')).toBeDefined(); + expect(registry.lookupChordEntry(chord as ['c', 'n'], 'detail')).toBeDefined(); + } + }); + + it('c-n and c-q have work_item_types matching the n shortcut', () => { + const registry = loadShortcutConfig(); + const nEntry = registry.lookupChordEntry(['n'], 'list'); + const cnEntry = registry.lookupChordEntry(['c', 'n'], 'list'); + expect(cnEntry?.workItemTypes).toEqual(nEntry?.workItemTypes); + }); +}); + it('registers the d downtime-toggle chord to /downtime toggle (WL-0MSZ4NSOE007AQEF)', () => { const registry = loadShortcutConfig(); const entry = registry.lookupChordEntry(['d'], 'list', undefined, false); diff --git a/packages/herdr/src/shortcuts.json b/packages/herdr/src/shortcuts.json index 502b066d7..e78682c53 100644 --- a/packages/herdr/src/shortcuts.json +++ b/packages/herdr/src/shortcuts.json @@ -10,13 +10,25 @@ }, { "chord": [ - "c" + "c", + "n" ], "command": "/intake <description> --priority <priority default='medium'>", "view": "both", - "label": "create new", - "description": "Create a new work item with a description and priority.", - "model": "plan" + "label": "create intake", + "description": "Create a new work item with a description and priority (c n).", + "model": "plan", + "work_item_types": ["bug", "docs", "feature", "task", "chore", "epic"] + }, + { + "chord": [ + "c", + "q" + ], + "command": "!!wl create -t <title> -d <description> -p <priority default='medium'>", + "view": "both", + "label": "create quick", + "description": "Quickly create a new work item with title, description, and priority — no issue-type gating (c q)." }, { "chord": [ From 0314928c32918022ecc138ca9756bcc6ee9f0dc1 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 06:50:18 +0100 Subject: [PATCH 17/27] WL-0MUW3ODN00084XP0: Implementation complete --- README.md | 16 ++ packages/tui/extensions/README.md | 10 ++ scripts/install-pi-extension.sh | 32 ++++ tests/scripts/install-pi-extension.test.ts | 167 +++++++++++++++++++-- 4 files changed, 209 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 46d0e48d6..133ab7ff9 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,22 @@ Install the extension globally: npm run install:pi-extension ``` +> **Installer guards.** `scripts/install-pi-extension.sh` only points the global +> symlink at a checkout it can actually load. It is a safe no-op (exit `0`, no +> mutation of `~/.pi/agent/extensions`) when either of the following holds: +> +> - **Worktree context** — `REPO_ROOT` contains the `.worklog/worktrees/` path +> segment (as created by `/skill:implement`). This prevents an unbuilt, +> short-lived worktree from repointing — and then breaking — the machine-global +> extension once the worktree is removed. +> - **Unbuilt checkout** — `dist/wl-integration/spawn.js` is absent. The +> extension resolves this compiled output at runtime, so installing from an +> unbuilt checkout would produce a broken extension. Run `npm run build` +> first. +> +> Because the guards exit `0` rather than failing, `npm install` in a worktree +> still succeeds and leaves the existing global symlink untouched. + For a single-command install of all integrations (herdr plugin + Pi extension): ```bash diff --git a/packages/tui/extensions/README.md b/packages/tui/extensions/README.md index cf439d965..b92e9eee2 100644 --- a/packages/tui/extensions/README.md +++ b/packages/tui/extensions/README.md @@ -21,6 +21,16 @@ registers the agent-side plugin modules that complement the Herdr plugin > covers browsing, filtering, detail views, shortcuts, and code-freeze > awareness. See `packages/herdr/` for that plugin. +## Installation + +The global symlink is created by `npm run install:pi-extension` +(`scripts/install-pi-extension.sh`). The installer is guarded: it makes no +change to `~/.pi/agent/extensions` (and exits `0`) when run from an +`implement` worktree (`.worklog/worktrees/` path segment) or from a checkout +without compiled `dist/wl-integration/spawn.js` output. Run `npm run build` +from the main checkout before installing. See the top-level +[README](../../README.md#pi-agent-plugin) for details. + ## Activity Indicator The extension displays a **persistent activity indicator** in the Pi footer, diff --git a/scripts/install-pi-extension.sh b/scripts/install-pi-extension.sh index 46877ca47..1d3ce6ed2 100755 --- a/scripts/install-pi-extension.sh +++ b/scripts/install-pi-extension.sh @@ -7,6 +7,38 @@ EXTENSION_SOURCE_DIR="${REPO_ROOT}/packages/tui/extensions" TARGET_DIR="${PI_GLOBAL_EXTENSIONS_DIR:-${HOME}/.pi/agent/extensions}" TARGET_LINK="${TARGET_DIR}/worklog" +# ---------- Guards (WL-0MUSTNEQW003V3KO) ---------- +# +# These guards prevent the installer from mutating the global Pi extension +# symlink when it would be unsafe to do so (worktree context or unbuilt +# checkout). They run *before* any mutation of ${TARGET_DIR}. + +# Guard 1 — worktree context. +# If REPO_ROOT contains the worktree path segment created by +# `implement.py`, skip installation entirely. This prevents the script +# from repointing the global extension at an unbuilt worktree directory +# that will be deleted once the implement session ends. +if [[ "${REPO_ROOT}" == *".worklog/worktrees/"* ]]; then + echo "Skipping Pi extension install: REPO_ROOT is inside a worktree (${REPO_ROOT})." >&2 + echo "The global extension symlink is untouched — run from a built checkout instead." + exit 0 +fi + +# Guard 2 — unbuilt checkout. +# The extension runtime loads compiled output from dist/ (wl-integration.ts +# requires dist/wl-integration/spawn.js via realpath-resolved +# createRequire()). If this artefact is absent the installed extension will +# crash on use. Advise the developer to run `npm run build` instead of +# silently installing a broken link. +SPAWN_JS="${REPO_ROOT}/dist/wl-integration/spawn.js" +if [[ ! -f "${SPAWN_JS}" ]]; then + echo "Skipping Pi extension install: missing required dist output: dist/wl-integration/spawn.js" >&2 + echo "Run \`npm run build\` to compile, then retry \`npm run install:pi-extension\`." + exit 0 +fi + +# ---------- Main install flow ---------- + if [[ ! -d "${EXTENSION_SOURCE_DIR}" ]]; then echo "Extension source directory not found: ${EXTENSION_SOURCE_DIR}" >&2 exit 1 diff --git a/tests/scripts/install-pi-extension.test.ts b/tests/scripts/install-pi-extension.test.ts index 1474dcc0f..edb848d6a 100644 --- a/tests/scripts/install-pi-extension.test.ts +++ b/tests/scripts/install-pi-extension.test.ts @@ -2,19 +2,51 @@ 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 { execFileSync } from 'node:child_process'; +import { spawnSync } from 'node:child_process'; describe('install-pi-extension script', () => { - it('creates global ~/.pi/agent/extensions symlink to worklog extension directory', () => { + /** + * Copy the real install-pi-extension.sh to a temporary directory so the + * guard logic evaluates the *copied* script's own REPO_ROOT (which is NOT + * inside a worktree), avoiding the worktree guard firing on the worktree + * checkout where the test file lives. + */ + function installScriptInTempDir(root: string): string { const repoRoot = path.resolve(__dirname, '..', '..'); + const realScript = path.join(repoRoot, 'scripts', 'install-pi-extension.sh'); + const scriptsDir = path.join(root, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + const dest = path.join(scriptsDir, 'install-pi-extension.sh'); + fs.copyFileSync(realScript, dest); + return dest; + } + + it('creates global ~/.pi/agent/extensions symlink to worklog extension directory', () => { + // Reproduce a built, non-worktree checkout in a neutral directory so the + // worktree guard does NOT fire (the real worktree checkout path itself + // contains `.worklog/worktrees/`, which would trigger the guard). + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'worklog-pi-ext-success-')); const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'worklog-pi-ext-home-')); - const scriptPath = path.join(repoRoot, 'scripts', 'install-pi-extension.sh'); - execFileSync('bash', [scriptPath], { - cwd: repoRoot, - stdio: 'pipe', - env: { ...process.env, HOME: tempHome }, - }); + // Built checkout fixture: extension source + dist/wl-integration/spawn.js. + const extDir = path.join(tempDir, 'packages', 'tui', 'extensions'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync(path.join(extDir, 'index.ts'), 'export {};\n'); + const distWlDir = path.join(tempDir, 'dist', 'wl-integration'); + fs.mkdirSync(distWlDir, { recursive: true }); + fs.writeFileSync(path.join(distWlDir, 'spawn.js'), 'module.exports = {};\n'); + + const scriptPath = installScriptInTempDir(tempDir); + + const run = () => + spawnSync('bash', [scriptPath], { + cwd: tempDir, + stdio: 'pipe', + env: { ...process.env, HOME: tempHome }, + }); + + const result = run(); + expect(result.status).toBe(0); const linkPath = path.join(tempHome, '.pi', 'agent', 'extensions', 'worklog'); expect(fs.existsSync(linkPath)).toBe(true); @@ -23,16 +55,119 @@ describe('install-pi-extension script', () => { expect(stat.isSymbolicLink()).toBe(true); const target = fs.readlinkSync(linkPath); - const expectedTarget = path.join(repoRoot, 'packages', 'tui', 'extensions'); - expect(path.resolve(path.dirname(linkPath), target)).toBe(expectedTarget); + expect(path.resolve(path.dirname(linkPath), target)).toBe(extDir); - // Re-run to verify idempotent replacement path - execFileSync('bash', [scriptPath], { - cwd: repoRoot, - stdio: 'pipe', - env: { ...process.env, HOME: tempHome }, - }); + // Re-run to verify idempotent replacement path (no leftover backup files). + const result2 = run(); + expect(result2.status).toBe(0); const statAfter = fs.lstatSync(linkPath); expect(statAfter.isSymbolicLink()).toBe(true); + expect(path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath))).toBe(extDir); + }); + + // --------------------------------------------------------------------------- + // Guards added by WL-0MUSTNEQW003V3KO + // --------------------------------------------------------------------------- + + /** + * Run a copied install-pi-extension.sh from an arbitrary directory, with + * isolated HOME / PI_GLOBAL_EXTENSIONS_DIR so that no real extension is + * touched. The fixture under `fixtureRoot` must contain a `scripts/` + * directory with the script inside. + * + * Returns `{ result, homeDir }` where `homeDir` is the temp directory used + * for both HOME and PI_GLOBAL_EXTENSIONS_DIR. The guard-level tests assert + * on `homeDir` — the script places `${TARGET_LINK}` at + * `${PI_GLOBAL_EXTENSIONS_DIR}/worklog` (= `${homeDir}/worklog`). + */ + function runScriptInFixture( + fixtureRoot: string, + overrides: Record<string, string> = {}, + ): { result: ReturnType<typeof spawnSync>; homeDir: string } { + const scriptPath = path.join(fixtureRoot, 'scripts', 'install-pi-extension.sh'); + const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'worklog-pi-ext-fixture-home-')); + const result = spawnSync('bash', [scriptPath], { + cwd: fixtureRoot, + stdio: 'pipe', + env: { ...process.env, HOME: homeDir, PI_GLOBAL_EXTENSIONS_DIR: homeDir, ...overrides }, + }); + return { result, homeDir }; + } + + /** + * Build a minimal fixture tree that the script recognises as a valid + * repository containing the Pi extension source. + * + * @param root - the root directory to create under (created if absent). + * @param build - when true, also create the dist/ output the extension needs. + */ + function buildFixture(root: string, build = false): void { + const extDir = path.join(root, 'packages', 'tui', 'extensions'); + fs.mkdirSync(extDir, { recursive: true }); + // Minimal file to convince the script that the extension source exists. + fs.writeFileSync(path.join(extDir, 'index.ts'), 'export {};\n'); + + const scriptsDir = path.join(root, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + if (build) { + const distDir = path.join(root, 'dist'); + const distWlDir = path.join(distDir, 'wl-integration'); + fs.mkdirSync(distWlDir, { recursive: true }); + // Sentinel file that the extension loads at runtime. + fs.writeFileSync(path.join(distWlDir, 'spawn.js'), 'module.exports = {};\n'); + } + } + + it('skips installation and logs a clear message when REPO_ROOT contains .worklog/worktrees/', () => { + // WL-0MUSTNEQW003V3KO — AC 1, 4: worktree guard must leave TARGET_DIR untouched. + const tempBase = fs.mkdtempSync(path.join(os.tmpdir(), 'worklog-pi-ext-wt-')); + // The worktree path segment must appear in REPO_ROOT so the guard triggers. + const fixtureRoot = path.join(tempBase, '.worklog', 'worktrees', 'wl-FAKE-worktree'); + + buildFixture(fixtureRoot, true); // needs dist/ so the worktree guard fires first + + // Copy the real script into the fixture. + const repoRoot = path.resolve(__dirname, '..', '..'); + const realScript = path.join(repoRoot, 'scripts', 'install-pi-extension.sh'); + fs.writeFileSync( + path.join(fixtureRoot, 'scripts', 'install-pi-extension.sh'), + fs.readFileSync(realScript, 'utf8'), + ); + + const { result, homeDir } = runScriptInFixture(fixtureRoot); + expect(result.status).toBe(0); + + // stdout or stderr should mention the worktree skip. + const output = String(result.stdout) + String(result.stderr); + expect(output).toMatch(/worktree/i); + + // No symlink should have been created at ${PI_GLOBAL_EXTENSIONS_DIR}/worklog. + expect(fs.existsSync(path.join(homeDir, 'worklog'))).toBe(false); + }); + + it('skips installation and warns when dist/wl-integration/spawn.js is missing', () => { + // WL-0MUSTNEQW003V3KO — AC 2, 5: unbuilt-target guard must leave TARGET_DIR untouched. + const tempBase = fs.mkdtempSync(path.join(os.tmpdir(), 'worklog-pi-ext-unbuilt-')); + const fixtureRoot = path.join(tempBase, 'my-repo'); // normal path — no worktree segment + + buildFixture(fixtureRoot, false); // NO dist/ output + + const repoRoot = path.resolve(__dirname, '..', '..'); + const realScript = path.join(repoRoot, 'scripts', 'install-pi-extension.sh'); + fs.writeFileSync( + path.join(fixtureRoot, 'scripts', 'install-pi-extension.sh'), + fs.readFileSync(realScript, 'utf8'), + ); + + const { result, homeDir } = runScriptInFixture(fixtureRoot); + expect(result.status).toBe(0); + + // Should contain a warning about missing dist output. + const output = String(result.stdout) + String(result.stderr); + expect(output).toMatch(/dist/i); + + // No symlink should have been created. + expect(fs.existsSync(path.join(homeDir, 'worklog'))).toBe(false); }); }); From 762f1fa4bd0f9af828cb18cfccef1ea7dda49552 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 08:13:39 +0100 Subject: [PATCH 18/27] WL-0MTGDZVLV0055T4C: fix duplicate not-found error in wl show --- src/commands/show.ts | 16 +++++------ tests/cli/show-tolerant-id-resolution.test.ts | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/commands/show.ts b/src/commands/show.ts index 2941a3088..76885a3c4 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -66,16 +66,16 @@ export default function register(ctx: PluginContext): void { } if (!item) { - // Use the CLI output renderer for stderr when available so errors - // look consistent with other CLI output in TTY. In JSON mode we - // skip the human-formatted stderr output to keep stderr machine- - // readable and rely on output.error to emit structured JSON. - const cliOut = createCliOutputFromCommand(program.opts(), utils.getConfig() ?? undefined); - if (!program.opts().json) { + // Emit the error exactly once. In JSON mode use the structured + // `output.error` helper; in human mode use the CLI renderer so the + // message is consistent with other CLI output. Calling both would + // print the same error twice (WL-0MTGDZVLV0055T4C). + if (program.opts().json) { + output.error(`Work item not found: ${normalizedId}`, { success: false, error: `Work item not found: ${normalizedId}` }); + } else { + const cliOut = createCliOutputFromCommand(program.opts(), utils.getConfig() ?? undefined); cliOut.printError(`Work item not found: ${normalizedId}`); } - // Signal JSON consumers with structured error via output.error - output.error(`Work item not found: ${normalizedId}`, { success: false, error: `Work item not found: ${normalizedId}` }); process.exit(1); } diff --git a/tests/cli/show-tolerant-id-resolution.test.ts b/tests/cli/show-tolerant-id-resolution.test.ts index e115d659c..1d50b57ef 100644 --- a/tests/cli/show-tolerant-id-resolution.test.ts +++ b/tests/cli/show-tolerant-id-resolution.test.ts @@ -227,6 +227,33 @@ describe('tolerant work-item ID resolution', () => { expect(jsonErr.success).toBe(false); expect(jsonErr.error).toContain('Work item not found'); }); + + it('human mode prints the not-found error exactly once (WL-0MTGDZVLV0055T4C)', async () => { + let error: any; + try { + await execAsync(`tsx ${cliPath} show NONEXISTENT-999`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + + const stderr = error?.stderr ?? ''; + const occurrences = stderr.split('Work item not found').length - 1; + expect(occurrences).toBe(1); + }); + + it('human mode does not write the not-found error to stdout', async () => { + let error: any; + try { + await execAsync(`tsx ${cliPath} show NONEXISTENT-999`); + } catch (e: any) { + error = e; + } + expect(error).toBeDefined(); + + const stdout = error?.stdout ?? ''; + expect(stdout).not.toContain('Work item not found'); + }); }); // ── AC5: --json output is machine-parseable ──────────────────────────── From 3c7e888eb4d291372b3274ef1e8e5ba458249109 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 08:18:37 +0100 Subject: [PATCH 19/27] WL-0MUBEDFLC004JN86: Implementation complete --- docs/icons-design.md | 22 ++++++ packages/herdr/src/icons.test.ts | 4 +- packages/herdr/src/worklist-icons.test.ts | 48 +++++++++++++ packages/shared/src/icons.ts | 24 ++++--- tests/herdr/icons.test.ts | 86 ++++++++++++++++++++++- 5 files changed, 170 insertions(+), 14 deletions(-) diff --git a/docs/icons-design.md b/docs/icons-design.md index 9558502ce..7b348cdda 100644 --- a/docs/icons-design.md +++ b/docs/icons-design.md @@ -347,6 +347,28 @@ Icons are prepended before the title in CLI list output: > `src/theme.ts` remains the CLI's self-contained icon source; the Herdr > plugin imports from the shared `@worklog/shared/icons` module. +#### Herdr worklist icon prefix + +The Herdr worklist selection list renders a fixed-width icon prefix via +`getIconPrefix` (`packages/shared/src/icons.ts`) before the item ID/title. +The prefix is padded to `ICON_PREFIX_WIDTH` (13 terminal cells) so the ID +column aligns across rows, and the columns are (left to right): + +| Column | Content | Notes | +|--------|---------|-------| +| 0 | Priority icon | `🚨` critical, `⭐` high, `📋` medium, `🐢` low (added WL-0MTQYTA20009YXBT) | +| 1 | Agent status | Fixed-width reserved slot (`AGENT_SLOT_WIDTH`) | +| 2 | Status icon | `statusIcon` | +| 3 | Stage / audit icon | `stageDisplayIcon` (audit-aware for `in_review`) | +| 4 | Producer review flag | `needsProducerReviewIcon` | +| 5 | Epic icon | Optional, when `issueType === 'epic'` | + +When the priority is missing the priority column is empty; the fixed-width +padding keeps the remaining columns aligned. In `noIcons` mode the priority +column shows `PRIORITY_FALLBACK` (e.g. `[HIGH]`); text fallbacks are wider +than a single glyph, so the noIcons prefix is a fixed *minimum* width rather +than an exact match for the icon-mode width. + ### 13.3 CLI Detail Output File: `src/cli-output.ts`, `src/commands/helpers.ts` (`humanFormatWorkItem`) diff --git a/packages/herdr/src/icons.test.ts b/packages/herdr/src/icons.test.ts index da81e4abf..4bb12ea22 100644 --- a/packages/herdr/src/icons.test.ts +++ b/packages/herdr/src/icons.test.ts @@ -108,8 +108,8 @@ describe('getIconPrefix — fixed-width agent slot (AC3 alignment invariance)', 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); + expect(stringDisplayWidth(getIconPrefix(base))).toBe(13); + expect(stringDisplayWidth(getIconPrefix(withAgent))).toBe(13); }); it('keeps the remaining icons at identical columns (no column shift)', () => { diff --git a/packages/herdr/src/worklist-icons.test.ts b/packages/herdr/src/worklist-icons.test.ts index aa766b560..8fa398db7 100644 --- a/packages/herdr/src/worklist-icons.test.ts +++ b/packages/herdr/src/worklist-icons.test.ts @@ -37,6 +37,7 @@ vi.mock('./notify.js', () => ({ import { runWorklistTui } from './worklist.js'; import { setExecFileAsync, resetExecFileAsync } from './fetcher.js'; import type { WorkItem } from './fetcher.js'; +import { stringDisplayWidth } from '@worklog/shared/icons'; // --------------------------------------------------------------------------- // Fake stdin/stdout harness (same pattern as worklist-agent-state.test.ts) @@ -198,4 +199,51 @@ describe('worklist — showIcons gating through runWorklistTui', () => { await quit(p); }); + + it('renders the priority icon first in the list-row prefix (WL-0MTQYTA20009YXBT)', async () => { + vi.useFakeTimers(); + process.env.HERDR_PANE_ID = 'w1:pCM'; + setExecFileAsync(makeExecMock() as any); + + const fetcher = vi.fn(async (): Promise<WorkItem[]> => [ + { id: 'WL-CRIT', title: 'Critical task', status: 'open', stage: 'idea', priority: 'critical' }, + { id: 'WL-PLAIN', title: 'Plain task', status: 'open', stage: 'idea' }, + ]); + + const p = runWorklistTui(fetcher, undefined, undefined, { + autoRefresh: false, + autoSync: false, + showHelpText: false, + }); + await vi.advanceTimersByTimeAsync(0); + await quit(p); + + // The list is rendered above the metadata panel, so the first line + // containing an item ID is its list row. + const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); + const lines = writes.join('').split('\n'); + // Normalize the selection marker (`▸`): stringDisplayWidth models it as + // 2 cells but terminals render it as 1, so the selected row would + // otherwise measure 1 cell wider than the non-selected row. + const norm = (s: string): string => stripAnsi(s).replace('▸', ' '); + const findLine = (id: string): string => + norm(lines.find((l) => l.includes(id)) ?? ''); + const critLine = findLine('WL-CRIT'); + const plainLine = findLine('WL-PLAIN'); + expect(critLine).not.toBe(''); + expect(plainLine).not.toBe(''); + + // The critical priority glyph (🚨) appears in the prefix, before the ID + // (and therefore before the after-title priority text). + const critPrefix = critLine.slice(0, critLine.indexOf('WL-CRIT')); + expect(critPrefix).toContain('\u{1F6A8}'); + // A missing priority leaves the column empty (no stray priority glyph). + const plainPrefix = plainLine.slice(0, plainLine.indexOf('WL-PLAIN')); + expect(plainPrefix).not.toContain('\u{1F6A8}'); + + // Alignment is preserved: the item-ID column is identical either way. + const idCol = (line: string, id: string): number => + stringDisplayWidth(line.slice(0, line.indexOf(id))); + expect(idCol(critLine, 'WL-CRIT')).toBe(idCol(plainLine, 'WL-PLAIN')); + }); }); diff --git a/packages/shared/src/icons.ts b/packages/shared/src/icons.ts index 8477b2601..e5e9d9d8f 100644 --- a/packages/shared/src/icons.ts +++ b/packages/shared/src/icons.ts @@ -701,7 +701,7 @@ export function stringDisplayWidth(s: string): number { } /** Fixed target width for icon prefix alignment (terminal cells). */ -const ICON_PREFIX_WIDTH = 12; +const ICON_PREFIX_WIDTH = 13; // ── Icon prefix composition ─────────────────────────────────────────── @@ -753,7 +753,10 @@ export function getIconPrefixParts( ): IconPrefixParts { const noIcons = opts?.noIcons ?? false; - // Column 0: agent status — fixed-width reserved slot so rows with and + // Column 0: priority icon (new, WL-0MTQYTA20009YXBT) + const priorityIconText = priorityIcon(item.priority, { noIcons }); + + // Column 1: 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 }); @@ -778,7 +781,7 @@ export function getIconPrefixParts( // 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(''); + let prefix = [priorityIconText, agentSlot, coreIcons, epicSuffix].filter(Boolean).join(''); const width = stringDisplayWidth(prefix); if (width < ICON_PREFIX_WIDTH) { prefix = prefix.padEnd(prefix.length + (ICON_PREFIX_WIDTH - width), ' '); @@ -787,7 +790,7 @@ export function getIconPrefixParts( // `sIcon` and `secondIcon` are always non-empty (status/stage icons have // fallbacks), so these indices are always valid and point at the stage // icon the coverage dimming wraps. - const stageStart = agentSlot.length + sIcon.length; + const stageStart = priorityIconText.length + agentSlot.length + sIcon.length; return { text: prefix, stageStart, stageEnd: stageStart + secondIcon.length }; } @@ -797,12 +800,13 @@ export function getIconPrefixParts( * to a fixed display width so the item-ID column aligns vertically * 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 + * Column layout (left to right, WL-0MTQYTA20009YXBT): + * 0. Priority icon (new) + * 1. Agent status (fixed-width reserved slot, WL-0MSBQUJQX005RAT9) + * 2. Status icon + * 3. Stage icon (for in_review items, shows audit-aware icon instead) + * 4. Producer review flag + * 5. Optional epic icon */ export function getIconPrefix(item: IconPrefixItem, opts?: IconOptions): string { return getIconPrefixParts(item, opts).text; diff --git a/tests/herdr/icons.test.ts b/tests/herdr/icons.test.ts index e715c308b..4094a72df 100644 --- a/tests/herdr/icons.test.ts +++ b/tests/herdr/icons.test.ts @@ -228,11 +228,17 @@ describe('getIconPrefix', () => { expect(prefix).toBeTruthy(); }); - it('returns same display width in icon and noIcons mode', () => { + it('pads icon-mode prefixes to the fixed width and shows fallbacks in noIcons mode', () => { const item: WorkItem = { id: 'T1', title: 'Test', status: 'open', priority: 'high' }; const withIcons = getIconPrefix(item, { noIcons: false }); const withoutIcons = getIconPrefix(item, { noIcons: true }); - expect(stringDisplayWidth(withIcons)).toBe(stringDisplayWidth(withoutIcons)); + // Icon mode is padded to the fixed ICON_PREFIX_WIDTH (13 cells) so the + // item-ID column aligns. noIcons mode renders text fallbacks, which are + // wider than a single glyph, so its width is a >= floor rather than an + // exact match (WL-0MTQYTA20009YXBT). + expect(stringDisplayWidth(withIcons)).toBe(13); + expect(withoutIcons).not.toMatch(/\p{Emoji}/u); + expect(stringDisplayWidth(withoutIcons)).toBeGreaterThanOrEqual(13); }); it('produces a prefix with no spaces between consecutive icons', () => { @@ -307,4 +313,80 @@ describe('getIconPrefix', () => { const withoutWidth = stringDisplayWidth(getIconPrefix(withoutReview)); expect(withWidth).toBe(withoutWidth); }); + + // ── Priority icon in the prefix (WL-0MUBEDFLC004JN86) ───────────── + + it('includes a priority icon for each priority level', () => { + const expected: Record<string, string> = { + critical: '\u{1F6A8}', // 🚨 + high: '\u{2B50}', // ⭐ + medium: '\u{1F4CB}', // 📋 + low: '\u{1F422}', // 🐢 + }; + for (const [priority, glyph] of Object.entries(expected)) { + const prefix = getIconPrefix({ id: 'T1', title: 'T', status: 'open', priority }); + expect(prefix.startsWith(glyph)).toBe(true); + } + }); + + it('renders the PRIORITY_FALLBACK text first when noIcons is enabled', () => { + const item: WorkItem = { id: 'T1', title: 'T', status: 'open', priority: 'high' }; + const prefix = getIconPrefix(item, { noIcons: true }); + expect(prefix.startsWith('[HIGH]')).toBe(true); + expect(prefix).not.toContain('\u{2B50}'); + }); + + it('reserves an empty priority cell when priority is missing', () => { + const undefinedPriority: WorkItem = { id: 'T1', title: 'T', status: 'open' }; + const nullPriority = { id: 'T2', title: 'T', status: 'open', priority: null } as unknown as WorkItem; + + const undefinedPrefix = getIconPrefix(undefinedPriority); + const nullPrefix = getIconPrefix(nullPriority); + + // No stray priority glyph or fallback text appears, and both cases align + // identically (the fixed-width padding absorbs the empty cell). + expect(undefinedPrefix).not.toMatch(/\u{1F6A8}|\u{2B50}|\u{1F4CB}|\u{1F422}/u); + expect(nullPrefix).toBe(undefinedPrefix); + }); + + it('places the priority icon before the agent status slot', () => { + const item: WorkItem = { + id: 'T1', title: 'T', status: 'open', priority: 'critical', agentState: 'working', + }; + const prefix = getIconPrefix(item); + // Priority glyph (🚨) must come before the agent-status glyph (🟢). + expect(prefix.indexOf('\u{1F6A8}')).toBeLessThan(prefix.indexOf('\u{1F7E2}')); + }); + + it('keeps the prefix width fixed at 13 cells with and without a priority', () => { + const withPriority: WorkItem = { id: 'T1', title: 'T', status: 'open', priority: 'high' }; + const withoutPriority: WorkItem = { id: 'T2', title: 'T', status: 'open' }; + + expect(stringDisplayWidth(getIconPrefix(withPriority))).toBe(13); + expect(stringDisplayWidth(getIconPrefix(withoutPriority))).toBe(13); + }); + + it('keeps the prefix width aligned across all priority/anchor combinations', () => { + const items: WorkItem[] = [ + { id: 'T1', title: 'T', status: 'open', priority: 'critical' }, + { id: 'T2', title: 'T', status: 'open', priority: 'high', stage: 'in_review' }, + { id: 'T3', title: 'T', status: 'completed', stage: 'in_review', needsProducerReview: true }, + { id: 'T4', title: 'T', status: 'blocked', issueType: 'epic' as const, priority: 'low' }, + { id: 'T5', title: 'T', status: 'open' }, + ]; + const widths = items.map((item) => stringDisplayWidth(getIconPrefix(item))); + expect(widths.every((w) => w === 13)).toBe(true); + }); + + it('renders the priority icon in noIcons mode as the fallback text', () => { + const item: WorkItem = { id: 'T1', title: 'T', status: 'open', priority: 'medium', agentState: 'idle' }; + const withIcons = getIconPrefix(item, { noIcons: false }); + const withoutIcons = getIconPrefix(item, { noIcons: true }); + + expect(withIcons).toContain('\u{1F4CB}'); + expect(withoutIcons).not.toContain('\u{1F4CB}'); + // The priority fallback is prepended (before the agent fallback). + expect(withoutIcons).toContain('[MED ]'); + expect(withoutIcons.indexOf('[MED ]')).toBeLessThan(withoutIcons.indexOf('[IDLE]')); + }); }); From 9879fd4bc3686c587b13a81b04fa7cf005bcb0bf Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 08:47:13 +0100 Subject: [PATCH 20/27] WL-0MUWCIGNB0025SG6: Implementation complete --- tests/herdr/settings.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/herdr/settings.test.ts b/tests/herdr/settings.test.ts index 716ba81cd..63b0ecc90 100644 --- a/tests/herdr/settings.test.ts +++ b/tests/herdr/settings.test.ts @@ -61,8 +61,10 @@ describe('defaultSettings', () => { expect(defaultSettings.downtimeEnabled).toBe(true); }); - it('has a 60s idle threshold by default', () => { - expect(defaultSettings.downtimeIdleThresholdMs).toBe(60000); + it('has a 75s idle threshold by default', () => { + // Default raised 60 → 75 s in WL-0MUV0VEFW007ZBB7 so active sessions + // (1-minute slot-exhaustion cooldown) can reclaim a freed slot first. + expect(defaultSettings.downtimeIdleThresholdMs).toBe(75000); }); it('has downtimeRequiredFreeSlots 2 (spare-capacity default) by default', () => { @@ -124,7 +126,7 @@ describe('loadSettings', () => { }), 'utf-8'); const settings = loadSettings(settingsPath); expect(settings.downtimePollIntervalMs).toBe(10000); - expect(settings.downtimeIdleThresholdMs).toBe(60000); + expect(settings.downtimeIdleThresholdMs).toBe(75000); expect(settings.downtimeRequiredFreeSlots).toBe(2); expect(settings.downtimeModel).toBe('plan'); }); @@ -137,7 +139,7 @@ describe('loadSettings', () => { const settings = loadSettings(settingsPath); expect(settings.downtimeEnabled).toBe(false); expect(settings.downtimeProxyUrl).toBe('http://10.0.0.5:8000'); - expect(settings.downtimeIdleThresholdMs).toBe(60000); // from defaults + expect(settings.downtimeIdleThresholdMs).toBe(75000); // from defaults expect(settings.downtimePollIntervalMs).toBe(10000); // from defaults expect(settings.downtimeRequiredFreeSlots).toBe(2); // default N=2 (spare-capacity) }); From 5c0c3aab5694761f154b9322f04a7a2bb2364bf1 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 08:55:02 +0100 Subject: [PATCH 21/27] WL-0MUW2IFTE001ZL1A: Implementation complete --- CLI.md | 12 + docs/AUDIT_STATUS.md | 12 + packages/herdr/src/fetcher.ts | 9 +- src/audit-fingerprint.ts | 365 ++++++++++++++++++ src/commands/list.ts | 41 +- .../audit-current-fingerprint-list.test.ts | 116 ++++++ tests/unit/audit-fingerprint-parity.test.ts | 124 ++++++ tests/unit/audit-fingerprint.test.ts | 267 +++++++++++++ 8 files changed, 938 insertions(+), 8 deletions(-) create mode 100644 src/audit-fingerprint.ts create mode 100644 tests/cli/audit-current-fingerprint-list.test.ts create mode 100644 tests/unit/audit-fingerprint-parity.test.ts create mode 100644 tests/unit/audit-fingerprint.test.ts diff --git a/CLI.md b/CLI.md index 54a370bef..648e53293 100644 --- a/CLI.md +++ b/CLI.md @@ -782,6 +782,18 @@ wl --json list -s open --tags backlog wl list --needs-producer-review ``` +When `--json` is used, each work item includes the audit enrichment fields +`auditResult`, `auditedAt`, and `fingerprint` (from the `audit_results` +table), plus `currentFingerprint` — the canonical content fingerprint of the +item's *current* content (per-touched-path git state + description hash + Key +Files, matching the audit skill's algorithm). `currentFingerprint` is +computed only for items that carry a stored `fingerprint`; it is `null` +otherwise, or when git cannot determine it. Comparing `fingerprint` with +`currentFingerprint` is the primary `isAuditFresh` gate, so a fresh audit +stays fresh across `updatedAt` churn (WL-0MUN7QWFP0010EQC). Like the other +enrichment fields, `currentFingerprint` is omitted when an explicit `--fields` +projection is active. + --- ### `search` <query> [options] diff --git a/docs/AUDIT_STATUS.md b/docs/AUDIT_STATUS.md index 6251bfcaa..dd4fe9263 100644 --- a/docs/AUDIT_STATUS.md +++ b/docs/AUDIT_STATUS.md @@ -238,6 +238,18 @@ Fingerprint sources: `wl audit-set --fingerprint <hex>`, or an line. An explicit `--fingerprint`/`--audit-fingerprint` flag wins over an embedded line. +**Current fingerprint at read time.** `wl list --json` emits +`currentFingerprint` for every item: the canonical fingerprint recomputed from +the item's current content (per-touched-path git state + description hash + Key +Files) via a TypeScript port of the audit skill's algorithm. It is computed for +items that carry a stored `fingerprint` and is `null` otherwise (or when git +cannot determine it). Consumers that only have `wl list` output — the herdr +selection list, the TUI extension, the review-queue banner — can therefore take +the primary content gate instead of the 60 s time gate +(WL-0MUN7QWFP0010EQC). The port is parity-tested against +`audit_runner._compute_content_fingerprint` (skipped when the audit skill is not +installed). + Flag-only flips of `needsProducerReview` do not bump `updatedAt` (WL-0MSN6ZCTN0027U2R), and neither do comment writes (WL-0MUBVH6JM0093KVM): comment create/update/delete bump `activityAt` only. `updatedAt` therefore diff --git a/packages/herdr/src/fetcher.ts b/packages/herdr/src/fetcher.ts index 71f960eb6..4cc965686 100644 --- a/packages/herdr/src/fetcher.ts +++ b/packages/herdr/src/fetcher.ts @@ -750,10 +750,11 @@ export interface ReviewQueueState { * (the same query as the former `fetchCompletedItemCount`). "Outstanding * audit" reuses the shared `isAuditFresh` predicate: a missing `auditedAt` * or a stored audit that is not current counts as outstanding; a fresh - * audit (passed or failed) does not. `wl list --json` does not expose - * `currentFingerprint`, so the predicate degrades to the 60 s time gate for - * these items — the same semantics the audit-dispatch tier uses for a - * non-hydrated list (AC3). A CLI error or unparseable output resolves to + * audit (passed or failed) does not. `wl list --json` exposes + * `currentFingerprint` for items that carry a stored fingerprint, so the + * predicate uses the primary content gate rather than the 60 s time gate + * (WL-0MUN7QWFP0010EQC); items without a stored fingerprint still fall back + * to the time gate. A CLI error or unparseable output resolves to * undefined (never throws). * * Root-only is intentional (WL-0MSTLFW14000KPEC): children are not counted as diff --git a/src/audit-fingerprint.ts b/src/audit-fingerprint.ts new file mode 100644 index 000000000..20b60404b --- /dev/null +++ b/src/audit-fingerprint.ts @@ -0,0 +1,365 @@ +/** + * Canonical audit content fingerprint — TypeScript port. + * + * Mirrors the audit skill's `audit_runner._compute_content_fingerprint` + * (SorraAgents) so a stored audit `fingerprint` can be compared against the + * item's *current* content at read time by the shared `isAuditFresh` + * predicate (WL-0MUBVH5S0008NQ9K). Without this, `wl list` emits no + * `currentFingerprint` and freshness always degrades to the fragile 60 s time + * gate (WL-0MUN7QWFP0010EQC). + * + * The canonical fingerprint is: + * + * ```text + * sha256( json.dumps({ + * "path_states": { <touched path>: {"head": ..., "worktree": ...} }, + * "description_hash": sha256(description), + * "key_files": [ ...Key Files parsed from the description... ], + * }, sort_keys=True) ) + * ``` + * + * The payload is serialised with Python's `json.dumps(..., sort_keys=True)` + * canonical form (`, ` / `: ` separators, recursively sorted keys, + * `ensure_ascii` escaping) and hashed as UTF-8, so the digest is byte-for-byte + * identical to the Python implementation. `json.dumps` semantics are covered + * by tests; the arrangement of the payload mirrors the Python source. + * + * Comment-recorded commit hashes are deliberately NOT resolved here: the + * canonical read path (`audit_runner.py check-freshness` → + * `_check_audit_freshness`) also omits them, so the two computations agree. + */ + +import { createHash } from 'crypto'; +import { execFileSync } from 'child_process'; + +/** Result of a git invocation. A non-zero exit is `ok: false`, never thrown. */ +export interface GitResult { + ok: boolean; + stdout: string; +} + +/** + * Injectable git runner. Receives the git arguments (without the leading + * `git`) and returns their result. Production callers supply a + * `child_process`-backed runner rooted at the repository; tests supply a + * fixture so no real repository is needed. + */ +export type GitRunner = (args: string[]) => GitResult; + +/** Marker used to delimit commit headers in `git log --format`. */ +const TOUCHED_FILES_COMMIT_MARKER = '__WL_COMMIT__'; + +/** + * Build the production git runner for a repository working directory. + * + * Non-zero exits are returned as `{ ok: false, stdout }` rather than thrown, + * mirroring the audit skill's `subprocess.run(check=False)` contract: several + * probes (`git rev-parse HEAD:<path>`) legitimately fail for untracked paths + * and must fall back rather than abort the whole computation. + */ +export function createGitRunner(cwd: string): GitRunner { + return (args: string[]): GitResult => { + try { + const stdout = execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + return { ok: true, stdout }; + } catch (error) { + const stdout = (error as { stdout?: string | Buffer }).stdout; + return { ok: false, stdout: stdout ? stdout.toString() : '' }; + } + }; +} + +/** SHA-256 hex digest of a UTF-8 string. */ +export function sha256Hex(text: string): string { + return createHash('sha256').update(text, 'utf8').digest('hex'); +} + +/** + * Normalise a git-reported or Key-Files path for comparison. + * + * Mirrors `audit_runner._normalise_repo_path`: strips surrounding + * whitespace/backticks/quotes and a leading `./`, so the same file resolved + * from `git log --name-only` (which may quote paths with special characters) + * and from the description's Key Files section compares equal. + */ +export function normaliseRepoPath(path: string | null | undefined): string { + let p = (path ?? '').trim(); + // Python str.strip("`") removes leading/trailing backticks only. + p = p.replace(/^`+/, '').replace(/`+$/, '').trim(); + if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) { + p = p.slice(1, -1); + } + p = p.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + while (p.startsWith('./')) p = p.slice(2); + return p.trim(); +} + +/** + * Extract file paths from a work item's Key Files section. + * + * Mirrors `audit_runner._extract_key_files`: finds a markdown heading like + * `## Key Files` (or `## Key Files (predicted)`) and returns the + * backtick-quoted paths listed beneath it (one per bullet/numbered line), + * falling back to the first whitespace-delimited token on a bullet line. + * Returns an empty list when no Key Files section is present. + */ +export function extractKeyFiles(description: string | null | undefined): string[] { + if (!description) return []; + const heading = /^#{0,4}\s*Key Files.*?$/im.exec(description); + if (!heading) return []; + + const files: string[] = []; + const rest = description.slice(heading.index + heading[0].length); + for (const rawLine of rest.split('\n')) { + const stripped = rawLine.trim(); + if (!stripped) continue; + if (/^#{1,6}\s/.test(stripped)) break; // next heading ends the section + const backtick = /`([^`]+)`/.exec(stripped); + let candidate = ''; + if (backtick) { + candidate = backtick[1].trim(); + } else { + const bullet = /^[-*]\s+(\S+)/.exec(stripped); + candidate = bullet ? bullet[1] : ''; + } + if (candidate) files.push(candidate); + } + return files; +} + +/** + * Serialise a value exactly as Python's `json.dumps(value, sort_keys=True)` + * does: recursively sorted object keys, `, ` / `: ` separators, and + * `ensure_ascii=True` string escaping (non-ASCII as `\uXXXX`, surrogate + * pairs for supplementary-plane characters). + * + * The fingerprint payload contains only strings, string arrays and nested + * string objects, so number/bool/null handling is present for completeness + * but is not exercised by the payload. + */ +export function canonicalJson(value: unknown): string { + if (value === null) return 'null'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') return JSON.stringify(value); + if (typeof value === 'string') return pythonJsonString(value); + if (Array.isArray(value)) { + return '[' + value.map(canonicalJson).join(', ') + ']'; + } + if (typeof value === 'object') { + const record = value as Record<string, unknown>; + const keys = Object.keys(record).sort(); + return ( + '{' + + keys.map(k => pythonJsonString(k) + ': ' + canonicalJson(record[k])).join(', ') + + '}' + ); + } + throw new TypeError(`canonicalJson: unsupported value type ${typeof value}`); +} + +/** + * Python `json.dumps(ensure_ascii=True)` string encoding, quoted. + * + * Control characters use the short escapes Python emits (`\b \f \n \r \t`), + * other control characters and non-ASCII code points use `\uXXXX` (with a + * surrogate pair above U+FFFF). DEL (U+007F) is ASCII in Python's encoder and + * is therefore left literal, as are `/` and every other printable ASCII + * character. + */ +function pythonJsonString(text: string): string { + let out = '"'; + for (const ch of text) { + const cp = ch.codePointAt(0) as number; + switch (ch) { + case '"': + out += '\\"'; + break; + case '\\': + out += '\\\\'; + break; + case '\b': + out += '\\b'; + break; + case '\f': + out += '\\f'; + break; + case '\n': + out += '\\n'; + break; + case '\r': + out += '\\r'; + break; + case '\t': + out += '\\t'; + break; + default: + if (cp < 0x20) { + out += '\\u' + cp.toString(16).padStart(4, '0'); + } else if (cp > 0x7f) { + if (cp > 0xffff) { + const offset = cp - 0x10000; + const high = 0xd800 + (offset >> 10); + const low = 0xdc00 + (offset & 0x3ff); + out += '\\u' + high.toString(16).padStart(4, '0'); + out += '\\u' + low.toString(16).padStart(4, '0'); + } else { + out += '\\u' + cp.toString(16).padStart(4, '0'); + } + } else { + out += ch; + } + } + } + return out + '"'; +} + +/** + * Resolve the set of repository paths a work item touched. + * + * Mirrors `audit_runner._resolve_touched_files` (without the optional + * comment-hash source, matching the canonical read path): the union of + * files in commits whose message references the work item id and the + * `Key Files` entries from the description. Normalised, de-duplicated and + * sorted. Returns `null` (fail-open) when the set cannot be determined — git + * unavailable, or no paths recorded. + */ +export function resolveTouchedFiles( + id: string, + description: string, + runGit: GitRunner +): string[] | null { + const issueId = (id || '').trim(); + const paths = new Set<string>(); + + if (issueId) { + const proc = runGit([ + 'log', + '--all', + '--fixed-strings', + `--grep=${issueId}`, + '--name-only', + `--format=${TOUCHED_FILES_COMMIT_MARKER}%H`, + ]); + if (!proc.ok) return null; + for (const line of proc.stdout.split('\n')) { + const stripped = line.trim(); + if (!stripped || stripped.startsWith(TOUCHED_FILES_COMMIT_MARKER)) continue; + const normalised = normaliseRepoPath(stripped); + if (normalised) paths.add(normalised); + } + } + + for (const keyFile of extractKeyFiles(description)) { + const normalised = normaliseRepoPath(keyFile); + if (normalised) paths.add(normalised); + } + + if (paths.size === 0) return null; + return [...paths].sort(); +} + +/** + * Parse the path from a `git status --porcelain` entry. + * + * Mirrors `audit_runner._parse_porcelain_path`. Handles the rename/copy form + * (`R old -> new` → `new`). The leading status column is significant, so + * only trailing whitespace is stripped before slicing. + */ +export function parsePorcelainPath(line: string): string { + let entry = line.replace(/\s+$/, ''); + if (!entry) return ''; + if (entry.includes(' -> ')) { + entry = entry.split(' -> ')[1]; + } else if (entry.length > 3) { + entry = entry.slice(3); + } else { + return ''; + } + return normaliseRepoPath(entry); +} + +/** + * Capture the current state of each touched path. + * + * Mirrors `audit_runner._compute_path_fingerprints`: for each path records + * `head` (its blob hash at HEAD, else the latest commit touching it, else + * empty for a never-committed path) and `worktree` (the narrowed + * working-tree state from `git status --porcelain --` and + * `git diff --name-only HEAD --`). Returns `null` when a required git call + * fails, so callers fail open. + */ +export function computePathFingerprints( + paths: string[], + runGit: GitRunner +): Record<string, { head: string; worktree: string }> | null { + if (paths.length === 0) return null; + + const worktree: Record<string, string[]> = {}; + for (const path of paths) worktree[path] = []; + + const commands: string[][] = [ + ['status', '--porcelain', '--', ...paths], + ['diff', '--name-only', 'HEAD', '--', ...paths], + ]; + for (const cmd of commands) { + const proc = runGit(cmd); + if (!proc.ok) return null; + const diffOnly = cmd[0] === 'diff'; + for (const line of proc.stdout.split('\n')) { + const stripped = line.trim(); + if (!stripped) continue; + const path = diffOnly ? normaliseRepoPath(stripped) : parsePorcelainPath(line); + if (path && path in worktree) worktree[path].push(stripped); + } + } + + const states: Record<string, { head: string; worktree: string }> = {}; + for (const path of paths) { + let head = ''; + const revParse = runGit(['rev-parse', `HEAD:${path}`]); + if (revParse.ok && revParse.stdout.trim()) { + head = revParse.stdout.trim(); + } else { + // Not present at HEAD (deleted / untracked): fall back to the latest + // commit that touched the path (empty when never committed). + const logProc = runGit(['log', '-1', '--format=%H', '--', path]); + if (!logProc.ok) return null; + head = logProc.stdout.trim(); + } + const unique = [...new Set(worktree[path])].sort(); + states[path] = { head, worktree: unique.join('\n') }; + } + return states; +} + +/** + * Compute the canonical content fingerprint for a work item at the current + * repository state. + * + * Returns a SHA-256 hex digest, or `null` when the fingerprint cannot be + * determined (touched set unresolved, git unavailable, or a required git call + * failed) — callers treat `null` as "cannot prove freshness" and fall back to + * the time gate (fail-safe). + */ +export function computeContentFingerprint(input: { + id: string; + description: string; + runGit: GitRunner; +}): string | null { + const { id, description, runGit } = input; + const keyFiles = extractKeyFiles(description); + const touched = resolveTouchedFiles(id, description, runGit); + if (touched === null) return null; + const pathStates = computePathFingerprints(touched, runGit); + if (pathStates === null) return null; + const payload = canonicalJson({ + path_states: pathStates, + description_hash: sha256Hex(description), + key_files: keyFiles, + }); + return sha256Hex(payload); +} diff --git a/src/commands/list.ts b/src/commands/list.ts index 44a9c18b4..f92fa33c5 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -4,8 +4,10 @@ import type { PluginContext } from '../plugin-types.js'; import type { ListOptions } from '../cli-types.js'; -import type { WorkItemQuery, WorkItemStatus, WorkItemPriority } from '../types.js'; +import type { WorkItem, WorkItemQuery, WorkItemStatus, WorkItemPriority } from '../types.js'; import { pickFields, VALID_FIELDS } from '@worklog/shared/fields'; +import { getGitRepoRoot } from '@worklog/shared/worklog-paths'; +import { computeContentFingerprint, createGitRunner } from '../audit-fingerprint.js'; import { displayItemTree, displayItemTreeWithFormat, humanFormatWorkItem, humanFormatProjected, resolveFormat, sortByPriorityAndDate } from './helpers.js'; import { compareGroupableItems } from './grouping.js'; @@ -157,6 +159,34 @@ export default function register(ctx: PluginContext): void { auditMapForSort.set(ar.workItemId, { readyToClose: ar.readyToClose, auditedAt: ar.auditedAt ?? null, fingerprint: ar.fingerprint ?? null }); } } + // Canonical content fingerprints (WL-0MUN7QWFP0010EQC). An item that + // carries a stored audit fingerprint gets its *current* fingerprint + // computed so the shared `isAuditFresh` predicate can use the primary + // content gate instead of the fragile 60 s time gate. Computed lazily + // and memoised per item id, and only when a stored fingerprint exists + // (the gate requires both sides), so items without an audit pay no git + // cost. `null` (unresolvable / git unavailable) fails safe to the time + // gate. + const gitRepoRoot = getGitRepoRoot() ?? process.cwd(); + const runGit = createGitRunner(gitRepoRoot); + const currentFingerprintCache = new Map<string, string | null>(); + const currentFingerprintFor = ( + item: WorkItem, + storedFingerprint: string | null | undefined, + ): string | null => { + if (!storedFingerprint) return null; + if (currentFingerprintCache.has(item.id)) { + return currentFingerprintCache.get(item.id) ?? null; + } + const computed = computeContentFingerprint({ + id: item.id, + description: item.description ?? '', + runGit, + }); + currentFingerprintCache.set(item.id, computed); + return computed; + }; + const sortedAll = items.slice().sort((a, b) => { // In-review lists: bucket sort first if (isInReviewList && auditMapForSort) { @@ -168,6 +198,7 @@ export default function register(ctx: PluginContext): void { auditResult: arA ? arA.readyToClose : null, auditedAt: arA ? arA.auditedAt : null, fingerprint: arA ? arA.fingerprint : null, + currentFingerprint: currentFingerprintFor(a, arA?.fingerprint ?? null), updatedAt: a.updatedAt, }, { id: b.id, stage: b.stage, priority: b.priority, filePaths: [], @@ -175,6 +206,7 @@ export default function register(ctx: PluginContext): void { auditResult: arB ? arB.readyToClose : null, auditedAt: arB ? arB.auditedAt : null, fingerprint: arB ? arB.fingerprint : null, + currentFingerprint: currentFingerprintFor(b, arB?.fingerprint ?? null), updatedAt: b.updatedAt, }, ); @@ -217,14 +249,15 @@ export default function register(ctx: PluginContext): void { auditResult: audit ? audit.readyToClose : null, auditedAt: audit ? audit.auditedAt : null, fingerprint: audit ? audit.fingerprint : null, + currentFingerprint: currentFingerprintFor(item, audit?.fingerprint ?? null), childCount: childCounts.get(item.id) ?? 0, }; }); // Apply --fields projection when requested (id always included). // The projected items drop enrichment fields (auditResult, auditedAt, - // childCount) unless explicitly requested in the field list... they are - // never part of the shared VALID_FIELDS vocabulary, so they are omitted - // whenever a projection is active. + // fingerprint, currentFingerprint, childCount) unless explicitly + // requested: they are not part of the shared VALID_FIELDS vocabulary, + // so a projection is a strict subset of the requested fields. const jsonItems = requestedFields ? enrichedItems.map(item => pickFields(item, requestedFields)) : enrichedItems; diff --git a/tests/cli/audit-current-fingerprint-list.test.ts b/tests/cli/audit-current-fingerprint-list.test.ts new file mode 100644 index 000000000..e5ca76b2a --- /dev/null +++ b/tests/cli/audit-current-fingerprint-list.test.ts @@ -0,0 +1,116 @@ +/** + * `wl list --json` emits `currentFingerprint` and it drives audit freshness — + * end-to-end integration test (WL-0MUN7QWFP0010EQC). + * + * Exercises the real CLI command path in-process against a temporary worklog + * while keeping the process cwd inside this (git) repository, so the canonical + * fingerprint can be computed from the item's Key Files. Verifies: + * + * - `currentFingerprint` is present in the JSON output; + * - it equals the stored audit `fingerprint` for unchanged content, so + * `isAuditFresh` takes the primary content gate (fresh despite a stale + * `updatedAt`), fixing the "audit immediately invalidated" bug; + * - it survives `--fields` projection. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { runInProcess } from './cli-inproc.js'; +import { writeConfig, writeInitSemaphore, cliPath } from './cli-helpers.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; +import { computeContentFingerprint, createGitRunner } from '../../src/audit-fingerprint.js'; +import { getGitRepoRoot } from '@worklog/shared/worklog-paths'; +import { isAuditFresh } from '@worklog/shared/icons'; + +describe('wl list --json currentFingerprint (WL-0MUN7QWFP0010EQC)', () => { + let tempDir: string; + let wlDir: string; + let originalCwd: string; + + beforeEach(() => { + originalCwd = process.cwd(); + tempDir = createTempDir(); + writeConfig(tempDir, 'Fingerprint Project', 'CFP'); + writeInitSemaphore(tempDir); + wlDir = path.join(tempDir, '.worklog'); + }); + + afterEach(() => { + process.chdir(originalCwd); + cleanupTempDir(tempDir); + }); + + async function createItem(description: string): Promise<{ id: string; description: string }> { + const descFile = path.join(tempDir, 'description.md'); + fs.writeFileSync(descFile, description); + const res = await runInProcess( + `tsx ${cliPath} --json --worklog-dir ${wlDir} create -t "Fingerprint item" --description-file ${descFile}`, + ); + const created = JSON.parse(res.stdout); + expect(created.success).toBe(true); + return { id: created.workItem.id, description: created.workItem.description }; + } + + it('emits a currentFingerprint matching the stored fingerprint for unchanged content', async () => { + const repoRoot = getGitRepoRoot() ?? process.cwd(); + const { id, description } = await createItem( + ['## Summary', 'Fingerprint integration fixture.', '## Key Files', '- `package.json`'].join('\n'), + ); + const expected = computeContentFingerprint({ + id, + description, + runGit: createGitRunner(repoRoot), + }); + expect(expected).toMatch(/^[0-9a-f]{64}$/); + + const auditRes = await runInProcess( + `tsx ${cliPath} --json --worklog-dir ${wlDir} audit-set ${id} --ready-to-close yes --fingerprint ${expected} --summary "Ready to close: Yes"`, + ); + expect(JSON.parse(auditRes.stdout).success).toBe(true); + + const listRes = await runInProcess(`tsx ${cliPath} --json --worklog-dir ${wlDir} list`); + const payload = JSON.parse(listRes.stdout); + const item = payload.workItems.find((w: { id: string }) => w.id === id); + expect(item).toBeTruthy(); + expect(item.fingerprint).toBe(expected); + expect(item.currentFingerprint).toBe(expected); + // The primary content gate now applies: the audit is fresh because the + // fingerprints match, independent of the updatedAt/time gate. + expect( + isAuditFresh(item.auditedAt, item.updatedAt, item.fingerprint, item.currentFingerprint), + ).toBe(true); + }); + + it('keeps a strict --fields projection (enrichment fields, including currentFingerprint, are dropped)', async () => { + const repoRoot = getGitRepoRoot() ?? process.cwd(); + const { id, description } = await createItem( + ['## Work', 'Projection fixture.', '## Key Files', '- `package.json`'].join('\n'), + ); + const expected = computeContentFingerprint({ + id, + description, + runGit: createGitRunner(repoRoot), + }); + await runInProcess( + `tsx ${cliPath} --json --worklog-dir ${wlDir} audit-set ${id} --ready-to-close yes --fingerprint ${expected} --summary "ok"`, + ); + + const listRes = await runInProcess( + `tsx ${cliPath} --json --worklog-dir ${wlDir} list --fields id,title,status`, + ); + const payload = JSON.parse(listRes.stdout); + const item = payload.workItems.find((w: { id: string }) => w.id === id); + expect(item).toBeTruthy(); + expect(Object.keys(item).sort()).toEqual(['id', 'status', 'title']); + expect(item.currentFingerprint).toBeUndefined(); + }); + + it('reports null currentFingerprint for items without a stored audit fingerprint', async () => { + const { id } = await createItem('## Key Files\n- `package.json`'); + const listRes = await runInProcess(`tsx ${cliPath} --json --worklog-dir ${wlDir} list`); + const payload = JSON.parse(listRes.stdout); + const item = payload.workItems.find((w: { id: string }) => w.id === id); + expect(item.currentFingerprint).toBeNull(); + }); +}); diff --git a/tests/unit/audit-fingerprint-parity.test.ts b/tests/unit/audit-fingerprint-parity.test.ts new file mode 100644 index 000000000..557d2c9b6 --- /dev/null +++ b/tests/unit/audit-fingerprint-parity.test.ts @@ -0,0 +1,124 @@ +/** + * Byte-for-byte parity between the TypeScript canonical content fingerprint + * and the audit skill's Python implementation + * (`audit_runner._compute_content_fingerprint`) — WL-0MUN7QWFP0010EQC. + * + * The TS port exists so `wl list` can emit `currentFingerprint` without a + * runtime Python dependency; this test guards against drift by computing the + * same fingerprint both ways over a real (temporary) git repository. + * + * The test is skipped when the audit skill's runner or a `python3` interpreter + * is unavailable (e.g. CI without the SorraAgents skill installed), so it + * never blocks the suite — it is an additional safety net, not the sole + * correctness proof (the unit tests pin the algorithm independently). + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { computeContentFingerprint, createGitRunner } from '../../src/audit-fingerprint.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; + +function findAuditScripts(): string | null { + const candidates = [ + process.env.AUDIT_SKILL_SCRIPTS, + path.join(os.homedir(), '.pi', 'agent', 'skills', 'audit', 'scripts'), + path.join(process.cwd(), '..', 'SorraAgents', 'skill', 'audit', 'scripts'), + ].filter((p): p is string => Boolean(p)); + for (const dir of candidates) { + if (fs.existsSync(path.join(dir, 'audit_runner.py'))) return dir; + } + return null; +} + +function pythonAvailable(): boolean { + try { + execFileSync('python3', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const AUDIT_SCRIPTS = findAuditScripts(); +const PYTHON = pythonAvailable(); + +function haveGit(): boolean { + try { + execFileSync('git', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const canRun = AUDIT_SCRIPTS !== null && PYTHON && haveGit(); + +describe.skipIf(!canRun)('canonical fingerprint parity (TS vs audit skill Python)', () => { + it('produces the same digest as audit_runner._compute_content_fingerprint', () => { + const repo = createTempDir(); + try { + const filePath = path.join(repo, 'implementation.ts'); + fs.writeFileSync(filePath, 'export const value = 42;\n'); + + const id = 'CFP-PARITY001'; + const description = [ + '## Summary', + 'Parity fixture.', + '## Key Files', + '- `implementation.ts`', + ].join('\n'); + + const env = { + ...process.env, + GIT_AUTHOR_NAME: 'Test', + GIT_AUTHOR_EMAIL: 'test@example.com', + GIT_COMMITTER_NAME: 'Test', + GIT_COMMITTER_EMAIL: 'test@example.com', + }; + const git = (args: string[]) => + execFileSync('git', args, { cwd: repo, env, stdio: ['ignore', 'pipe', 'pipe'] }); + git(['init']); + git(['add', 'implementation.ts']); + git(['commit', '-m', `${id}: parity fixture`]); + + const workItem = { id, description }; + const workItemFile = path.join(repo, 'work-item.json'); + fs.writeFileSync(workItemFile, JSON.stringify(workItem)); + + const script = path.join(repo, 'parity.py'); + fs.writeFileSync( + script, + [ + 'import json, subprocess, sys', + 'sys.path.insert(0, sys.argv[3])', + 'import audit_runner as ar', + 'repo = sys.argv[1]', + 'work_item = json.loads(open(sys.argv[2]).read())', + 'def runner(cmd):', + ' return subprocess.run(list(cmd), check=False, text=True,', + ' capture_output=True, cwd=repo)', + 'print(ar._compute_content_fingerprint(', + ' runner, work_item["id"], work_item=work_item, comments=None))', + ].join('\n'), + ); + + const pythonOut = execFileSync('python3', [script, repo, workItemFile, AUDIT_SCRIPTS], { + encoding: 'utf8', + }).trim(); + + const tsOut = computeContentFingerprint({ + id, + description, + runGit: createGitRunner(repo), + }); + + expect(tsOut).toBe(pythonOut); + expect(tsOut).toMatch(/^[0-9a-f]{64}$/); + } finally { + cleanupTempDir(repo); + } + }); +}); diff --git a/tests/unit/audit-fingerprint.test.ts b/tests/unit/audit-fingerprint.test.ts new file mode 100644 index 000000000..fe3770074 --- /dev/null +++ b/tests/unit/audit-fingerprint.test.ts @@ -0,0 +1,267 @@ +/** + * Canonical audit content fingerprint (TypeScript port) — unit tests + * (WL-0MUN7QWFP0010EQC). + * + * These tests pin the pure pieces (Python-compatible canonical JSON, Key Files + * extraction, path normalisation, touched-file resolution, path states) and + * the failure modes (git unavailable → `null`, fail-safe). Byte-for-byte + * equivalence with the canonical Python implementation is covered separately + * in `audit-fingerprint-parity.test.ts`. + */ + +import { describe, it, expect } from 'vitest'; +import { + canonicalJson, + computeContentFingerprint, + computePathFingerprints, + extractKeyFiles, + normaliseRepoPath, + parsePorcelainPath, + resolveTouchedFiles, + type GitResult, + type GitRunner, +} from '../../src/audit-fingerprint.js'; + +/** A git runner that answers from fixed fixture outputs. */ +function fixtureRunner(opts: { + allLog?: string; + logAllOk?: boolean; + status?: string; + statusOk?: boolean; + diff?: string; + diffOk?: boolean; + heads?: Record<string, string | null>; // null → not at HEAD (fall back to log -1) + singleLog?: string; + singleLogOk?: boolean; +} = {}): GitRunner { + return (args: string[]): GitResult => { + if (args[0] === 'log' && args.includes('--all')) { + return { ok: opts.logAllOk ?? true, stdout: opts.allLog ?? '' }; + } + if (args[0] === 'status') { + return { ok: opts.statusOk ?? true, stdout: opts.status ?? '' }; + } + if (args[0] === 'diff') { + return { ok: opts.diffOk ?? true, stdout: opts.diff ?? '' }; + } + if (args[0] === 'rev-parse') { + const path = args[1].slice('HEAD:'.length); + const head = opts.heads?.[path]; + if (head === undefined || head === null) return { ok: false, stdout: '' }; + return { ok: true, stdout: `${head}\n` }; + } + if (args[0] === 'log') { + return { ok: opts.singleLogOk ?? true, stdout: opts.singleLog ?? '' }; + } + return { ok: true, stdout: '' }; + }; +} + +describe('canonicalJson (Python json.dumps(sort_keys=True) compatibility)', () => { + it('sorts object keys recursively and uses Python separators', () => { + expect(canonicalJson({ b: 1, a: 2 })).toBe('{"a": 2, "b": 1}'); + expect(canonicalJson({ z: { b: 'x', a: 'y' }, a: [] })).toBe( + '{"a": [], "z": {"a": "y", "b": "x"}}', + ); + }); + + it('preserves array order', () => { + expect(canonicalJson(['b', 'a'])).toBe('["b", "a"]'); + }); + + it('escapes non-ASCII as \\uXXXX (ensure_ascii) including surrogate pairs', () => { + expect(canonicalJson('café')).toBe('"caf\\u00e9"'); + expect(canonicalJson('😀')).toBe('"\\ud83d\\ude00"'); + }); + + it('escapes control characters but leaves DEL (U+007F) and "/" literal', () => { + expect(canonicalJson('a\nb')).toBe('"a\\nb"'); + expect(canonicalJson('a/b')).toBe('"a/b"'); + expect(canonicalJson('\u007f')).toBe('"\u007f"'); + }); + + it('escapes quotes and backslashes', () => { + expect(canonicalJson('a"b\\c')).toBe('"a\\"b\\\\c"'); + }); +}); + +describe('extractKeyFiles', () => { + it('returns backtick-quoted paths under a Key Files heading', () => { + const desc = [ + '## Summary', + 'text', + '## Key Files', + '- `src/a.ts` — the A helper', + '- `src/b.ts`', + '', + '## Notes', + '- `ignored.ts`', + ].join('\n'); + expect(extractKeyFiles(desc)).toEqual(['src/a.ts', 'src/b.ts']); + }); + + it('supports heading variants and the bullet fallback', () => { + const desc = ['### Key Files (predicted)', '- src/plain.ts', ''].join('\n'); + expect(extractKeyFiles(desc)).toEqual(['src/plain.ts']); + }); + + it('returns an empty list when there is no Key Files section', () => { + expect(extractKeyFiles('## Summary\nno files here')).toEqual([]); + expect(extractKeyFiles('')).toEqual([]); + }); +}); + +describe('normaliseRepoPath', () => { + it('strips backticks, quotes and a leading ./', () => { + expect(normaliseRepoPath('`src/a.ts`')).toBe('src/a.ts'); + expect(normaliseRepoPath('"./src/a.ts"')).toBe('src/a.ts'); + expect(normaliseRepoPath(' ./src/a.ts ')).toBe('src/a.ts'); + }); +}); + +describe('parsePorcelainPath', () => { + it('parses plain and rename entries', () => { + expect(parsePorcelainPath(' M src/a.ts')).toBe('src/a.ts'); + expect(parsePorcelainPath('?? src/new.ts')).toBe('src/new.ts'); + expect(parsePorcelainPath('R old.ts -> new.ts')).toBe('new.ts'); + expect(parsePorcelainPath('XY')).toBe(''); + }); +}); + +describe('resolveTouchedFiles', () => { + it('unions commit-referenced files and Key Files, sorted and de-duplicated', () => { + const runner = fixtureRunner({ + allLog: [ + '__WL_COMMIT__abc', + 'src/b.ts', + 'src/a.ts', + '__WL_COMMIT__def', + 'src/a.ts', + '', + ].join('\n'), + }); + const desc = '## Key Files\n- `src/c.ts`'; + expect(resolveTouchedFiles('CFP-1', desc, runner)).toEqual([ + 'src/a.ts', + 'src/b.ts', + 'src/c.ts', + ]); + }); + + it('returns null when the git log probe fails (fail-safe)', () => { + const runner = fixtureRunner({ logAllOk: false }); + expect(resolveTouchedFiles('CFP-1', '## Key Files\n- `src/a.ts`', runner)).toBeNull(); + }); + + it('returns null when no paths are recorded', () => { + const runner = fixtureRunner(); + expect(resolveTouchedFiles('CFP-1', 'no key files', runner)).toBeNull(); + }); +}); + +describe('computePathFingerprints', () => { + it('records HEAD blob state and the narrowed working-tree state', () => { + const runner = fixtureRunner({ + heads: { 'src/a.ts': 'blobhash', 'src/b.ts': 'blobhash2' }, + status: ' M src/a.ts', + diff: 'src/b.ts', + }); + const states = computePathFingerprints(['src/a.ts', 'src/b.ts'], runner); + expect(states).toEqual({ + 'src/a.ts': { head: 'blobhash', worktree: 'M src/a.ts' }, + 'src/b.ts': { head: 'blobhash2', worktree: 'src/b.ts' }, + }); + }); + + it('falls back to the latest commit when the path is not at HEAD', () => { + const runner = fixtureRunner({ heads: { 'src/a.ts': null }, singleLog: 'deadbeef\n' }); + const states = computePathFingerprints(['src/a.ts'], runner); + expect(states).toEqual({ 'src/a.ts': { head: 'deadbeef', worktree: '' } }); + }); + + it('returns null when a required git probe fails', () => { + const runner = fixtureRunner({ statusOk: false }); + expect(computePathFingerprints(['src/a.ts'], runner)).toBeNull(); + }); +}); + +describe('computeContentFingerprint', () => { + const baseRunner = () => + fixtureRunner({ + allLog: '__WL_COMMIT__abc\nsrc/a.ts\n', + heads: { 'src/a.ts': 'blobhash' }, + status: '', + diff: '', + }); + + it('produces a deterministic 64-hex digest', () => { + const first = computeContentFingerprint({ + id: 'CFP-1', + description: '## Key Files\n- `src/a.ts`', + runGit: baseRunner(), + }); + const second = computeContentFingerprint({ + id: 'CFP-1', + description: '## Key Files\n- `src/a.ts`', + runGit: baseRunner(), + }); + expect(first).toMatch(/^[0-9a-f]{64}$/); + expect(first).toBe(second); + }); + + it('changes when a touched path blob changes', () => { + const before = computeContentFingerprint({ + id: 'CFP-1', + description: '## Key Files\n- `src/a.ts`', + runGit: baseRunner(), + }); + const after = computeContentFingerprint({ + id: 'CFP-1', + description: '## Key Files\n- `src/a.ts`', + runGit: fixtureRunner({ + allLog: '__WL_COMMIT__abc\nsrc/a.ts\n', + heads: { 'src/a.ts': 'differentblob' }, + }), + }); + expect(before).not.toBe(after); + }); + + it('changes when the working tree is dirty for a touched path', () => { + const before = computeContentFingerprint({ + id: 'CFP-1', + description: '## Key Files\n- `src/a.ts`', + runGit: baseRunner(), + }); + const after = computeContentFingerprint({ + id: 'CFP-1', + description: '## Key Files\n- `src/a.ts`', + runGit: fixtureRunner({ + allLog: '__WL_COMMIT__abc\nsrc/a.ts\n', + heads: { 'src/a.ts': 'blobhash' }, + status: ' M src/a.ts', + }), + }); + expect(before).not.toBe(after); + }); + + it('changes when the description changes', () => { + const before = computeContentFingerprint({ + id: 'CFP-1', + description: '## Key Files\n- `src/a.ts`', + runGit: baseRunner(), + }); + const after = computeContentFingerprint({ + id: 'CFP-1', + description: '## Key Files\n- `src/a.ts`\n\nedited', + runGit: baseRunner(), + }); + expect(before).not.toBe(after); + }); + + it('returns null (fail-safe) when git is unavailable', () => { + const runner: GitRunner = () => ({ ok: false, stdout: '' }); + expect( + computeContentFingerprint({ id: 'CFP-1', description: 'x', runGit: runner }), + ).toBeNull(); + }); +}); From 4423940fd04f1385799ac35d2f231a7f54139ad2 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 09:15:48 +0100 Subject: [PATCH 22/27] WL-0MTCLB50D0026YA7: Implementation complete --- packages/herdr/src/chord-quick-create.test.ts | 199 ++++++++++++++++++ packages/herdr/src/worklist.ts | 6 +- 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 packages/herdr/src/chord-quick-create.test.ts diff --git a/packages/herdr/src/chord-quick-create.test.ts b/packages/herdr/src/chord-quick-create.test.ts new file mode 100644 index 000000000..a56e1153d --- /dev/null +++ b/packages/herdr/src/chord-quick-create.test.ts @@ -0,0 +1,199 @@ +/** + * packages/herdr/src/chord-quick-create.test.ts — Regression tests for the + * `c q` (create quick) chord (WL-0MTCLB50D0026YA7). + * + * The `c q` chord's second key is `q`, which is also the TUI's quit key. + * The quit-key handler originally ran BEFORE chord-mode handling, so + * pressing `c` then `q` exited the extension instead of opening the + * quick-create form (reported by manual review: "Using c-q crashed the + * herdr extension"). + * + * These tests drive the real runWorklistTui input loop with the production + * shortcut registry (`loadShortcutConfig`) to verify that: + * - `c` then `q` opens the quick-create form (it does NOT quit), and + * - the form dispatches the expected `wl create` command on submit. + * + * Run: npx vitest run packages/herdr/src/chord-quick-create.test.ts + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// --------------------------------------------------------------------------- +// Module mocks (must be hoisted before worklist.js is imported) +// --------------------------------------------------------------------------- + +vi.mock('./fetcher.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('./fetcher.js')>(); + return { + ...actual, + fetchActionableCount: vi.fn().mockResolvedValue(0), + fetchChildrenForItem: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock('./auto-sync.js', () => ({ + runSync: vi.fn().mockResolvedValue({ success: true }), + createSyncTimer: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })), + clampSyncInterval: vi.fn((v: number) => v), +})); + +vi.mock('./notify.js', () => ({ + showToast: vi.fn(), +})); + +import { runWorklistTui } from './worklist.js'; +import { loadShortcutConfig } from './shortcut-config.js'; +import { setLogPath, resetLogPath } from './command-log.js'; + +// --------------------------------------------------------------------------- +// Fake stdin/stdout harness (same pattern as notify-dispatch.test.ts) +// --------------------------------------------------------------------------- + +let dataHandler: ((chunk: Buffer) => void) | undefined; +let writes: string[]; +let tmpDir: string; + +beforeEach(() => { + vi.clearAllMocks(); + dataHandler = undefined; + writes = []; + + // Isolate the command log so dispatched commands never touch the user's + // real ~/.config/herdr log. + tmpDir = mkdtempSync(join(tmpdir(), 'herdr-cqc-')); + setLogPath(join(tmpDir, 'cmdlog.json')); + + 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(() => { + resetLogPath(); + rmSync(tmpDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +/** Small async tick helper so awaited promises settle. */ +function tick(): Promise<void> { + return new Promise((r) => setTimeout(r, 5)); +} + +/** + * The last full render written to stdout. Since every render starts with + * ANSI.clear + cursorHome, the last write is a complete redraw. + */ +function lastRender(): string { + return writes[writes.length - 1] ?? ''; +} + +/** True when the quick-create form page is the current render. */ +function quickCreateFormShowing(): boolean { + const out = lastRender(); + return out.includes('Command Input') && out.includes('title') && out.includes('description'); +} + +function startTui(onCommand?: (c: string, model?: string) => void): Promise<unknown> { + return runWorklistTui(async () => [], [], loadShortcutConfig(), { + autoRefresh: false, + autoSync: false, + showHelpText: false, + onCommand, + }); +} + +describe('c q (create quick) chord (WL-0MTCLB50D0026YA7)', () => { + it('opens the quick-create form instead of quitting the extension', async () => { + const p = startTui(); + await tick(); + + // Press the leader key `c`, then `q`. Historically `q` quit the TUI + // here because the quit-key handler ran before chord-mode handling. + dataHandler?.(Buffer.from('c')); + await tick(); + dataHandler?.(Buffer.from('q')); + await tick(); + + // The form must be open. If `q` had quit the extension, the last render + // would not be the quick-create form. + expect(quickCreateFormShowing()).toBe(true); + + // Clean up: cancel the form, then quit. + dataHandler?.(Buffer.from('\x1b')); + await tick(); + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('dispatches wl create with the form values on submit', async () => { + const onCommand = vi.fn(); + const p = startTui(onCommand); + await tick(); + + dataHandler?.(Buffer.from('c')); + await tick(); + dataHandler?.(Buffer.from('q')); + await tick(); + + // title + for (const ch of 'My title') { + dataHandler?.(Buffer.from(ch)); + await tick(); + } + // description + dataHandler?.(Buffer.from('\t')); + await tick(); + for (const ch of 'My desc') { + dataHandler?.(Buffer.from(ch)); + await tick(); + } + // priority keeps its inline default 'medium' + dataHandler?.(Buffer.from('\r')); + await tick(); + await tick(); + + expect(onCommand).toHaveBeenCalledTimes(1); + // Shell-route values are POSIX single-quoted (form-dialog.ts). + expect(onCommand).toHaveBeenCalledWith( + "!!wl create -t 'My title' -d 'My desc' -p 'medium'", + undefined, + ); + + dataHandler?.(Buffer.from('q')); + await p; + }); + + it('still quits on a bare q when no chord is pending', async () => { + const p = startTui(); + await tick(); + + dataHandler?.(Buffer.from('q')); + const result = await p; + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/herdr/src/worklist.ts b/packages/herdr/src/worklist.ts index ae060bdb6..d52659e1c 100644 --- a/packages/herdr/src/worklist.ts +++ b/packages/herdr/src/worklist.ts @@ -5578,7 +5578,11 @@ export async function runWorklistTui( return; } - if (key === 'q' && state.mode !== 'filter') { + // Quit on a bare `q` — but NOT while a chord is pending, because `q` + // may be the chord's next key (e.g. the `c q` create-quick chord, + // WL-0MTCLB50D0026YA7). Chord-mode handling below consumes the key + // when `chordState.pendingKeys` is non-empty. + if (key === 'q' && state.mode !== 'filter' && chordState.pendingKeys.length === 0) { cleanup(); resolve(undefined); return; From cf2c69bb66abf39792ccca6d86f3b31ca24610ae Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 10:03:32 +0100 Subject: [PATCH 23/27] WL-0MUMCZAC40058HCC: Implementation complete --- docs/dev/downtime-dispatcher.md | 35 ++-- packages/herdr/src/index.ts | 44 ++++- packages/herdr/src/pane-close-cli.ts | 39 ++++- packages/herdr/src/pane-close-herdr.test.ts | 183 +++++++++++++++++++- packages/herdr/src/pane-close-herdr.ts | 179 +++++++++++++++++-- packages/herdr/src/pane-close-reaper.ts | 7 + packages/herdr/src/pane-close.test.ts | 37 ++++ packages/herdr/src/pane-close.ts | 30 +++- 8 files changed, 516 insertions(+), 38 deletions(-) diff --git a/docs/dev/downtime-dispatcher.md b/docs/dev/downtime-dispatcher.md index f093804ae..2e6b41445 100644 --- a/docs/dev/downtime-dispatcher.md +++ b/docs/dev/downtime-dispatcher.md @@ -1113,17 +1113,31 @@ idempotent: (manually opened panes, and marker-less panes whose agent died). It runs on the downtime-worker tick at most once per `PANE_CLOSE_REAPER_INTERVAL_MS` (default 60 s), gated by the `paneCloseEnabled` setting (default on), with - a `paneCloseIdleThresholdMinutes` idle threshold (default 30, clamped - [1, 1440]). + a `paneCloseIdleThresholdMinutes` idle threshold (default 0 — idle close + disabled — clamped [0, 1440]) and a `paneCloseGracePeriodMinutes` grace + period (default 5, clamped [1, 1440]). The reaper classifies each Herdr pane via the shared `classifySession()` module (`packages/herdr/src/pane-close.ts`): close when the final assistant -message ends with `</end_session>`, when the agent process is gone, or when -the agent is alive but idle beyond the threshold. It never closes an -`implement` pane, an item awaiting producer review, the invoking pane, or a -pane with live children. A close failure for one pane is recorded and the -run continues; a reaper throw is caught and logged so it can never crash the -worker. +message ends with `</end_session>`, or (only when the idle threshold is +positive) when the agent is alive but idle beyond the threshold. It never +closes an `implement` pane, an item awaiting producer review, the invoking +pane, a pane with live children, or a pane still within its grace period. A +dead agent is **not** closed — the operator may still need to read the final +output. A close failure for one pane is recorded and the run continues; a +reaper throw is caught and logged so it can never crash the worker. + +**Active-agent signals (parent AC3).** The production deps +(`pane-close-herdr.ts`) populate the classifier's activity signals so +long-running-but-silent agents are not reaped: the raw herdr `agent_status` +(a `work`-class status keeps the pane `active`), recent file modifications +in the pane's workspace (`dirHasRecentModifications`, 2-minute window, +bounded scan), and the count of spawned child processes from +`herdr pane process-info` (excluding the pane's own foreground +process-group leader, so an idle agent is not misclassified as having +children). The work-item stage and producer-review flag are fetched once +per pane (`getItemInfo`) and recorded in every +`CloseReasonSnapshot`/reaper-ledger row (parent AC6). **Coexistence.** With Mechanism B disabled there is a single active closer, so the previous non-atomic double-close race is gone. The reaper still skips @@ -1171,7 +1185,8 @@ status refresh unchanged at 30s.** | Non-terminal pane-close cooldown | **30 min** (`downtimeNonTerminalCooldownMs`; clamped to 1 min – 24 h; holds same-kind re-dispatch after a non-terminal pane close, WL-0MUKYERLZ006ELL5) | `DEFAULT_DOWNTIME_NON_TERMINAL_COOLDOWN_MS`, `clampDowntimeNonTerminalCooldownMs` (`downtime-worker.ts`) | | Per-item/per-kind attempt cap | **3** (`downtimeMaxAttempts`; clamped to 1 – 10; flags `needsProducerReview` and stops re-dispatch of that kind once the cap is reached at the current stage, WL-0MUKYEXMK0033MFK) | `DEFAULT_DOWNTIME_MAX_ATTEMPTS`, `clampDowntimeMaxAttempts` (`downtime-worker.ts`), `countAttempts` (`downtime-log.ts`) | | Pane-closure reaper cadence | **60 s** (`PANE_CLOSE_REAPER_INTERVAL_MS`; gated by `paneCloseEnabled`, WL-0MUJL1NAH0042GOS) | `pane-close-scheduler.ts` | -| Pane-closure idle threshold | **30 min** (`paneCloseIdleThresholdMinutes`; clamped to 1 min – 24 h) | `pane-close-scheduler.ts` | +| Pane-closure idle threshold | **0 min** — idle close disabled (`paneCloseIdleThresholdMinutes`; clamped to 0 min – 24 h) | `pane-close-scheduler.ts` | +| Pane-closure grace period | **5 min** (`paneCloseGracePeriodMinutes`; clamped to 1 min – 24 h; no pane is eligible for close within this window of first dispatch, WL-0MUMM5IUF003EVT8) | `pane-close-scheduler.ts` | | (removed) Max running downtime panes | **none** — no client-side pane cap; the LLM idle / free-slot check is the concurrency limiter (WL-0MU2EP6JL006A1U3) | `downtime-worker.ts` | Both dispatch-poll and idle-threshold are configurable in the herdr plugin @@ -1202,7 +1217,7 @@ on load | `packages/herdr/src/pane-close.ts` | Shared pane-closure classifier (`classifySession`, `extractFinalAssistantText`) consumed by the reaper and `pane-triage` (WL-0MUJL1NAH0042GOS) | | `packages/herdr/src/pane-close-reaper.ts` | Closure reaper orchestration + CLI (`runReaper`, `runReaperCli`) | | `packages/herdr/src/pane-close-scheduler.ts` | Periodic scheduling, settings clamps, enabled guard (`runScheduledPaneClose`) | -| `packages/herdr/src/pane-close-herdr.ts` | Production `ReaperDeps` over `herdr pane list` + pi session logs | +| `packages/herdr/src/pane-close-herdr.ts` | Production `ReaperDeps` over `herdr pane list` + `herdr pane process-info` + pi session logs (activity probes: agent status, recent file modifications, spawned child processes) | | `packages/herdr/src/process-group.ts` | Session-scoped child-process teardown (SIGTERM → grace → SIGKILL) | | `packages/herdr/shared/send-to-pi.sh` | `--anchor <paneId>` \u2192 `herdr pane split --pane <anchor>` (no `pane current` in anchor mode); forwards `--cwd`/`--model`/`AUDIT_PHASE2_PARALLELISM` | | `packages/herdr/shared/grid.py` | Grid rebalance around anchor pane | diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 53a675271..8cca1284a 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -61,7 +61,12 @@ import { removeActiveAuditMarker, } from './machine-coordination.js'; import { loadSettings, getDefaultSettingsPath, clampBrowseItemCount, defaultSettings } from './settings.js'; -import { createHerdrReaperDeps } from './pane-close-herdr.js'; +import { + countSpawnedChildProcesses, + createHerdrReaperDeps, + dirHasRecentModifications, + parsePaneProcessInfo, +} from './pane-close-herdr.js'; import { runScheduledPaneClose } from './pane-close-scheduler.js'; import { createDowntimeWorker, @@ -1610,17 +1615,48 @@ export function createDowntimeDeps( } }, invokingPaneId, - getNeedsProducerReview: async (itemId: string) => { + // Item lookup (parent AC2/AC6): fetch both the producer-review flag + // and the work-item stage so the close-decision snapshot records the + // stage. Fail-closed: an unreadable item stays review-blocked. + getItemInfo: async (itemId: string) => { const { stdout } = await getExecFileAsync()( 'wl', ['show', itemId, '--json'], { encoding: 'utf8', timeout: DOWNTIME_WL_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, cwd }, ); const parsed = extractJson(stdout) as { - workItem?: { needsProducerReview?: boolean }; + workItem?: { needsProducerReview?: boolean; stage?: string }; + }; + return { + needsProducerReview: parsed?.workItem?.needsProducerReview === true, + stage: parsed?.workItem?.stage, }; - return parsed?.workItem?.needsProducerReview === true; }, + // Live-child probe (parent AC1/AC3): count foreground processes in + // the pane other than the agent itself via `herdr pane process-info`. + // Fail-closed: an unreadable/failed lookup yields 0. + childProcessCount: async (pane) => { + try { + const { stdout } = await getExecFileAsync()( + herdrBin, + ['pane', 'process-info', '--pane', pane.paneId], + { + encoding: 'utf8', + timeout: DOWNTIME_WL_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + cwd, + }, + ); + return countSpawnedChildProcesses(parsePaneProcessInfo(stdout)); + } catch { + return 0; // fail-closed: no evidence of spawned children + } + }, + // Recent file-modification probe (parent AC3): scan the pane's + // workspace for files changed within the activity window. Fail-closed + // on an unreadable directory. + hasRecentFileModifications: (pane) => + dirHasRecentModifications(pane.foregroundCwd ?? pane.cwd), }); const result = await runScheduledPaneClose( deps, diff --git a/packages/herdr/src/pane-close-cli.ts b/packages/herdr/src/pane-close-cli.ts index f32a79841..d78011aa8 100644 --- a/packages/herdr/src/pane-close-cli.ts +++ b/packages/herdr/src/pane-close-cli.ts @@ -23,7 +23,12 @@ import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; -import { createHerdrReaperDeps } from './pane-close-herdr.js'; +import { + countSpawnedChildProcesses, + createHerdrReaperDeps, + dirHasRecentModifications, + parsePaneProcessInfo, +} from './pane-close-herdr.js'; import type { ReaperDeps } from './pane-close-reaper.js'; import { runReaperCli } from './pane-close-reaper.js'; @@ -113,7 +118,8 @@ export function createPaneCloseCliDeps(io: PaneCloseCliIo, fixtureRaw?: string): } }, invokingPaneId, - getNeedsProducerReview: async (itemId: string) => { + // Item lookup (parent AC2/AC6): producer-review flag + work-item stage. + getItemInfo: async (itemId: string) => { const { stdout } = await io.execFileAsync('wl', ['show', itemId, '--json'], { encoding: 'utf8', timeout: CLI_COMMAND_TIMEOUT_MS, @@ -121,11 +127,36 @@ export function createPaneCloseCliDeps(io: PaneCloseCliIo, fixtureRaw?: string): cwd: io.cwd, }); const parsed = extractJsonValue(stdout) as { - workItem?: { needsProducerReview?: boolean }; + workItem?: { needsProducerReview?: boolean; stage?: string }; }; // Fail-closed: an unreadable item is treated as review-blocked. - return parsed?.workItem?.needsProducerReview === true; + return { + needsProducerReview: parsed?.workItem?.needsProducerReview === true, + stage: parsed?.workItem?.stage, + }; + }, + // Live-child probe (parent AC1/AC3): count foreground processes other + // than the agent itself. Fail-closed to 0. + childProcessCount: async (pane) => { + try { + const { stdout } = await io.execFileAsync( + herdrBin, + ['pane', 'process-info', '--pane', pane.paneId], + { + encoding: 'utf8', + timeout: CLI_COMMAND_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + cwd: io.cwd, + }, + ); + return countSpawnedChildProcesses(parsePaneProcessInfo(stdout)); + } catch { + return 0; + } }, + // Recent file-modification probe (parent AC3). Fail-closed. + hasRecentFileModifications: (pane) => + dirHasRecentModifications(pane.foregroundCwd ?? pane.cwd), }); } diff --git a/packages/herdr/src/pane-close-herdr.test.ts b/packages/herdr/src/pane-close-herdr.test.ts index 524645acd..6d4ad23b5 100644 --- a/packages/herdr/src/pane-close-herdr.test.ts +++ b/packages/herdr/src/pane-close-herdr.test.ts @@ -3,7 +3,7 @@ * herdr/session reaper deps (WL-0MUJW9FFW009008M / WL-0MUJL1NAH0042GOS). */ import { describe, expect, it, vi } from 'vitest'; -import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -14,6 +14,9 @@ import { readFinalAssistantEntries, readSessionTailLines, createHerdrReaperDeps, + parsePaneProcessInfo, + countSpawnedChildProcesses, + dirHasRecentModifications, } from './pane-close-herdr'; describe('parseHerdrPaneCloseList', () => { @@ -317,8 +320,95 @@ describe('createHerdrReaperDeps', () => { expect(panes).toHaveLength(1); expect(panes[0].hasRecentFileModifications).toBe(true); expect(panes[0].hasActiveNetworkConnections).toBe(true); - expect(recent).toHaveBeenCalledWith('w1:p1'); - expect(network).toHaveBeenCalledWith('w1:p1'); + expect(recent).toHaveBeenCalledWith(expect.objectContaining({ paneId: 'w1:p1' })); + expect(network).toHaveBeenCalledWith(expect.objectContaining({ paneId: 'w1:p1' })); + }); + + it('populates itemStage and producer-review from getItemInfo (parent AC6)', async () => { + const raw = JSON.stringify({ + panes: [ + { + pane_id: 'w1:p1', + label: 'Downtime triggered plan Foo - WL-0ABC123', + agent: 'pi', + agent_status: 'idle', + agent_session: { value: '/tmp/item-info.jsonl' }, + }, + ], + }); + const getItemInfo = vi.fn().mockResolvedValue({ needsProducerReview: false, stage: 'plan_complete' }); + const deps = createHerdrReaperDeps({ + listPanesRaw: vi.fn().mockResolvedValue(raw), + closePane: vi.fn(), + getItemInfo, + }); + const panes = await deps.listPanes(); + expect(getItemInfo).toHaveBeenCalledWith('WL-0ABC123'); + expect(panes[0].itemStage).toBe('plan_complete'); + expect(panes[0].needsProducerReview).toBe(false); + }); + + it('treats a failed getItemInfo lookup as review-blocked (fail-closed)', async () => { + const raw = JSON.stringify({ + panes: [ + { + pane_id: 'w1:p1', + label: 'Downtime triggered plan Foo - WL-0ABC123', + agent: 'pi', + agent_status: 'idle', + agent_session: { value: '/tmp/item-info-fail.jsonl' }, + }, + ], + }); + const deps = createHerdrReaperDeps({ + listPanesRaw: vi.fn().mockResolvedValue(raw), + closePane: vi.fn(), + getItemInfo: vi.fn().mockRejectedValue(new Error('wl down')), + }); + const panes = await deps.listPanes(); + expect(panes[0].needsProducerReview).toBe(true); + }); + + it('maps the raw herdr agent status into PaneStatus (parent AC3/AC6)', async () => { + const raw = JSON.stringify({ + panes: [ + { + pane_id: 'w1:p1', + label: 'Downtime triggered plan Foo - WL-0ABC123', + agent: 'pi', + agent_status: 'work', + agent_session: { value: '/tmp/agent-status.jsonl' }, + }, + ], + }); + const deps = createHerdrReaperDeps({ + listPanesRaw: vi.fn().mockResolvedValue(raw), + closePane: vi.fn(), + }); + const panes = await deps.listPanes(); + expect(panes[0].agentStatus).toBe('work'); + expect(panes[0].agentProcessAlive).toBe(true); + }); + + it('awaits an async childProcessCount probe (parent AC1/AC3)', async () => { + const raw = JSON.stringify({ + panes: [ + { + pane_id: 'w1:p1', + label: 'Downtime triggered plan Foo - WL-0ABC123', + agent: 'pi', + agent_status: 'idle', + agent_session: { value: '/tmp/child-count.jsonl' }, + }, + ], + }); + const deps = createHerdrReaperDeps({ + listPanesRaw: vi.fn().mockResolvedValue(raw), + closePane: vi.fn(), + childProcessCount: vi.fn().mockResolvedValue(2), + }); + const panes = await deps.listPanes(); + expect(panes[0].childProcessCount).toBe(2); }); it('defaults activity probes to false when absent (backwards compatible)', async () => { @@ -342,3 +432,90 @@ describe('createHerdrReaperDeps', () => { expect(panes[0].hasActiveNetworkConnections).toBe(false); }); }); + +describe('parsePaneProcessInfo', () => { + it('parses the {result:{process_info:{…}}} envelope', () => { + const raw = JSON.stringify({ + id: 'cli:pane:process_info', + result: { + process_info: { + foreground_process_group_id: 2817319, + foreground_processes: [ + { pid: 2817319, cmdline: 'npm run dev --host' }, + { pid: 2817357, cmdline: 'sh -c vite --host' }, + { pid: 2817359, cmdline: 'node vite --host' }, + ], + }, + type: 'pane_process_info', + }, + }); + const info = parsePaneProcessInfo(raw); + expect(info).toEqual({ + foregroundProcessGroupId: 2817319, + foregroundPids: [2817319, 2817357, 2817359], + }); + }); + + it('tolerates log lines before the JSON payload', () => { + const raw = 'connecting...\n' + JSON.stringify({ result: { process_info: { foreground_processes: [{ pid: 7 }] } } }); + expect(parsePaneProcessInfo(raw)).toEqual({ foregroundProcessGroupId: undefined, foregroundPids: [7] }); + }); + + it('returns null when no JSON envelope is present', () => { + expect(parsePaneProcessInfo('no json here')).toBeNull(); + expect(parsePaneProcessInfo('{not valid json')).toBeNull(); + }); +}); + +describe('countSpawnedChildProcesses', () => { + it('excludes the foreground process-group leader (the agent itself)', () => { + expect( + countSpawnedChildProcesses({ + foregroundProcessGroupId: 100, + foregroundPids: [100, 101, 102], + }), + ).toBe(2); + }); + + it('returns 0 for an idle pane whose only process is the agent', () => { + expect(countSpawnedChildProcesses({ foregroundProcessGroupId: 5, foregroundPids: [5] })).toBe(0); + }); + + it('returns 0 for absent process-info (fail-closed)', () => { + expect(countSpawnedChildProcesses(null)).toBe(0); + }); +}); + +describe('dirHasRecentModifications', () => { + it('detects a recently modified file and ignores stale ones', () => { + const dir = mkdtempSync(join(tmpdir(), 'herdr-recent-')); + try { + writeFileSync(join(dir, 'fresh.txt'), 'x'); + const now = Date.now(); + // Freshly written file is within the window. + expect(dirHasRecentModifications(dir, { windowMs: 60_000, nowMs: now })).toBe(true); + // A window in the past (now far in the future) sees it as stale. + expect( + dirHasRecentModifications(dir, { windowMs: 1_000, nowMs: now + 3_600_000 }), + ).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('returns false for an absent directory (fail-closed)', () => { + expect(dirHasRecentModifications('/nonexistent/does/not/exist')).toBe(false); + expect(dirHasRecentModifications(undefined)).toBe(false); + }); + + it('descends into subdirectories', () => { + const dir = mkdtempSync(join(tmpdir(), 'herdr-recent-sub-')); + try { + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'nested.ts'), 'x'); + expect(dirHasRecentModifications(dir, { windowMs: 60_000 })).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/herdr/src/pane-close-herdr.ts b/packages/herdr/src/pane-close-herdr.ts index c0a686008..9dc7d930d 100644 --- a/packages/herdr/src/pane-close-herdr.ts +++ b/packages/herdr/src/pane-close-herdr.ts @@ -9,7 +9,8 @@ * the I/O shell is thin and fail-closed (never throws into the worker). */ -import { closeSync, openSync, readSync, statSync } from 'node:fs'; +import { closeSync, openSync, readdirSync, readSync, statSync } from 'node:fs'; +import { join } from 'node:path'; import type { PaneStatus, ReaperDeps } from './pane-close-reaper.js'; import { terminateProcessGroup } from './process-group.js'; @@ -49,6 +50,10 @@ export interface ParsedHerdrPane { sessionPath?: string; workspaceId?: string; tabId?: string; + /** Pane working directory, when reported (activity-probe input). */ + cwd?: string; + /** Foreground process working directory, when reported. */ + foregroundCwd?: string; } /** @@ -119,6 +124,14 @@ export function parseHerdrPaneCloseList(raw: string): ParsedHerdrPane[] | null { : typeof rec.tabId === 'string' ? rec.tabId : undefined, + cwd: + typeof (rec as Record<string, unknown>).cwd === 'string' + ? ((rec as Record<string, unknown>).cwd as string) + : undefined, + foregroundCwd: + typeof (rec as Record<string, unknown>).foreground_cwd === 'string' + ? ((rec as Record<string, unknown>).foreground_cwd as string) + : undefined, }); } return parsed; @@ -307,6 +320,123 @@ export function readSessionTailLines( return output.slice(-tailLines); } +// ── Production activity probes (parent AC3) ──────────────────────────── + +/** Default recent-modification window for the file activity probe (2 min). */ +export const RECENT_FILE_ACTIVITY_WINDOW_MS = 2 * 60 * 1000; + +/** Maximum directory entries inspected by the activity scan (cost bound). */ +export const RECENT_FILE_SCAN_MAX_ENTRIES = 2000; + +/** + * Best-effort check for recently modified files under `dir` (parent AC3). + * + * Walks the directory tree breadth-first (bounded to `maxEntries` entries, + * skipping VCS/dependency noise) and returns true as soon as a regular file + * with an mtime within `windowMs` is found. Fail-closed and never throws: + * an absent/unreadable directory or a stat failure yields no activity. + */ +export function dirHasRecentModifications( + dir: string | undefined, + opts?: { windowMs?: number; nowMs?: number; maxEntries?: number; ignore?: string[] }, +): boolean { + if (typeof dir !== 'string' || dir === '') return false; + const windowMs = opts?.windowMs ?? RECENT_FILE_ACTIVITY_WINDOW_MS; + const nowMs = opts?.nowMs ?? Date.now(); + const maxEntries = opts?.maxEntries ?? RECENT_FILE_SCAN_MAX_ENTRIES; + const cutoff = nowMs - windowMs; + const ignore = new Set(opts?.ignore ?? ['node_modules', '.git', '.worklog', 'dist']); + let visited = 0; + const stack: string[] = [dir]; + while (stack.length > 0 && visited < maxEntries) { + const current = stack.pop() as string; + let entries; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + continue; // fail-closed on unreadable directories + } + for (const entry of entries) { + visited++; + if (visited > maxEntries) break; + if (ignore.has(entry.name)) continue; + const full = join(current, entry.name); + if (entry.isDirectory()) { + stack.push(full); + } else if (entry.isFile()) { + try { + if (statSync(full).mtimeMs >= cutoff) return true; + } catch { + // ignore an unreadable file + } + } + } + } + return false; +} + +/** Normalised subset of `herdr pane process-info` used by the reaper. */ +export interface PaneProcessInfo { + /** Foreground process-group leader id, when reported. */ + foregroundProcessGroupId?: number; + /** PIDs of the pane's foreground processes. */ + foregroundPids: number[]; +} + +/** + * Parse `herdr pane process-info --pane <id>` output. Tolerates log lines + * before the JSON envelope and the `{result:{process_info:{…}}}` shape. + * Returns `null` when no process-info can be found (caller fails closed). + */ +export function parsePaneProcessInfo(raw: string): PaneProcessInfo | null { + const brace = raw.indexOf('{'); + if (brace < 0) return null; + let payload: unknown; + try { + payload = JSON.parse(raw.slice(brace)); + } catch { + return null; + } + if (!payload || typeof payload !== 'object') return null; + const obj = payload as Record<string, unknown>; + const result = obj.result; + const resultObj = + result && typeof result === 'object' ? (result as Record<string, unknown>) : null; + const info = resultObj?.process_info ?? obj.process_info; + if (!info || typeof info !== 'object') return null; + const rec = info as Record<string, unknown>; + const group = rec.foreground_process_group_id; + const procs = rec.foreground_processes; + const foregroundPids: number[] = []; + if (Array.isArray(procs)) { + for (const proc of procs) { + if (!proc || typeof proc !== 'object') continue; + const pid = (proc as Record<string, unknown>).pid; + if (typeof pid === 'number' && Number.isFinite(pid)) foregroundPids.push(pid); + } + } + return { + foregroundProcessGroupId: + typeof group === 'number' && Number.isFinite(group) ? group : undefined, + foregroundPids, + }; +} + +/** + * Count processes in the pane *spawned by* the agent — i.e. foreground + * processes other than the foreground process-group leader (the agent + * itself). Excluding the leader is essential: counting the agent's own + * process would make the classifier's `live-children` guard fire for every + * live pane and prevent all auto-close (parent AC1). + * + * Absent/unparsable process-info yields 0 (no activity). + */ +export function countSpawnedChildProcesses(info: PaneProcessInfo | null): number { + if (!info) return 0; + const leader = info.foregroundProcessGroupId; + return info.foregroundPids.filter((pid) => leader === undefined || pid !== leader).length; +} + // ── Production deps factory ─────────────────────────────────────────── /** Injectable I/O for the production deps (tests/other callers may override). */ @@ -319,19 +449,29 @@ export interface HerdrReaperIo { invokingPaneId?: string; /** Optional producer-review lookup; absent → false (fail-closed default). */ getNeedsProducerReview?(itemId: string): Promise<boolean>; - /** Count session-scoped child processes; absent → 0. */ - childProcessCount?(paneId: string): number; + /** + * Optional combined item-info lookup (parent AC6). When supplied it + * supersedes `getNeedsProducerReview` and additionally populates + * `PaneStatus.itemStage` so the close-decision snapshot records the + * work-item stage. Absent → falls back to `getNeedsProducerReview`. + */ + getItemInfo?(itemId: string): Promise<{ needsProducerReview: boolean; stage?: string }>; + /** + * Count session-scoped child processes for a pane (parent AC1/AC3); + * absent → 0. Receives the parsed pane so callers can probe by session + * path / cwd rather than pane id alone. + */ + childProcessCount?(pane: ParsedHerdrPane): number | Promise<number>; /** * Recent file-modification probe (parent AC3); absent → false (no - * activity). Kept injectable because workspace scanning is platform- and - * cost-sensitive. + * activity). Receives the parsed pane so callers can scan its workspace. */ - hasRecentFileModifications?(paneId: string): boolean; + hasRecentFileModifications?(pane: ParsedHerdrPane): boolean; /** * Active network-connection probe (parent AC3); absent → false (no - * activity). Kept injectable for the same reason. + * activity). Platform-specific and kept injectable. */ - hasActiveNetworkConnections?(paneId: string): boolean; + hasActiveNetworkConnections?(pane: ParsedHerdrPane): boolean; /** Clock (injectable for tests). */ now?(): number; } @@ -386,21 +526,33 @@ export function createHerdrReaperDeps(io: HerdrReaperIo): ReaperDeps { const status = (pane.agentStatus ?? '').toLowerCase(); const agentProcessAlive = status !== 'done' && status !== 'exited' && status !== ''; + // Producer-review + item-stage lookup (parent AC2/AC6). Prefer the + // combined `getItemInfo`; fall back to the boolean-only lookup. let needsProducerReview = false; - if (itemId !== '' && typeof io.getNeedsProducerReview === 'function') { + let itemStage: string | undefined; + if (itemId !== '' && typeof io.getItemInfo === 'function') { try { - needsProducerReview = await io.getNeedsProducerReview(itemId); + const info = await io.getItemInfo(itemId); + needsProducerReview = info.needsProducerReview === true; + itemStage = typeof info.stage === 'string' && info.stage !== '' ? info.stage : undefined; } catch { // fail-closed: an unreadable item must never be closed without // evidence — treat as review-blocked (never auto-close). needsProducerReview = true; } + } else if (itemId !== '' && typeof io.getNeedsProducerReview === 'function') { + try { + needsProducerReview = await io.getNeedsProducerReview(itemId); + } catch { + needsProducerReview = true; + } } result.push({ id: pane.paneId, kind: paneKindFromLabel(pane.label), itemId, + itemStage, title: pane.label || pane.paneId, workspaceId: pane.workspaceId, tabId: pane.tabId, @@ -408,13 +560,14 @@ export function createHerdrReaperDeps(io: HerdrReaperIo): ReaperDeps { sessionEntries: entries, sessionTail, agentProcessAlive, + agentStatus: pane.agentStatus, idleMs, needsProducerReview, isInvokingPane: pane.paneId === io.invokingPaneId, - childProcessCount: io.childProcessCount?.(pane.paneId) ?? 0, + childProcessCount: (await io.childProcessCount?.(pane)) ?? 0, ageSinceDispatchMs, - hasRecentFileModifications: io.hasRecentFileModifications?.(pane.paneId) ?? false, - hasActiveNetworkConnections: io.hasActiveNetworkConnections?.(pane.paneId) ?? false, + hasRecentFileModifications: io.hasRecentFileModifications?.(pane) ?? false, + hasActiveNetworkConnections: io.hasActiveNetworkConnections?.(pane) ?? false, }); } return result; diff --git a/packages/herdr/src/pane-close-reaper.ts b/packages/herdr/src/pane-close-reaper.ts index 54547ce4f..35a89ccf3 100644 --- a/packages/herdr/src/pane-close-reaper.ts +++ b/packages/herdr/src/pane-close-reaper.ts @@ -97,6 +97,12 @@ export interface PaneStatus { hasActiveNetworkConnections?: boolean; /** The PID of the agent process (for process-group teardown). */ pid?: number; + /** + * Raw herdr agent status (`idle`, `work`, `done`, `unknown`, …), when + * known. Surfaced in the close-decision snapshot and used as an activity + * signal (parent AC3/AC6). + */ + agentStatus?: string; } /** The result of classifying one pane. */ @@ -258,6 +264,7 @@ function toSessionSample(ps: PaneStatus): SessionSample { hasRecentFileModifications: ps.hasRecentFileModifications, hasActiveNetworkConnections: ps.hasActiveNetworkConnections, itemStage: ps.itemStage, + agentStatus: ps.agentStatus, }; } diff --git a/packages/herdr/src/pane-close.test.ts b/packages/herdr/src/pane-close.test.ts index d49352a0a..dbe24dad4 100644 --- a/packages/herdr/src/pane-close.test.ts +++ b/packages/herdr/src/pane-close.test.ts @@ -535,6 +535,30 @@ describe('classifySession — active-agent signals table (parent AC3)', () => { opts: { idleThresholdMs: THRESHOLD_MS }, expected: { close: false, reasonCode: 'active' }, }, + { + name: 'a work agent status keeps an over-threshold pane active (parent AC3)', + sample: { agentProcessAlive: true, idleMs: THRESHOLD_MS + 1, agentStatus: 'work' }, + opts: { idleThresholdMs: THRESHOLD_MS }, + expected: { close: false, reasonCode: 'active' }, + }, + { + name: 'other work-class statuses are case-insensitive', + sample: { agentProcessAlive: true, idleMs: THRESHOLD_MS + 1, agentStatus: 'BUSY' }, + opts: { idleThresholdMs: THRESHOLD_MS }, + expected: { close: false, reasonCode: 'active' }, + }, + { + name: 'an idle agent status falls through to the idle-threshold close', + sample: { agentProcessAlive: true, idleMs: THRESHOLD_MS + 1, agentStatus: 'idle' }, + opts: { idleThresholdMs: THRESHOLD_MS }, + expected: { close: true, reasonCode: 'idle-threshold' }, + }, + { + name: 'an unknown agent status falls through to the idle-threshold close', + sample: { agentProcessAlive: true, idleMs: THRESHOLD_MS + 1, agentStatus: 'unknown' }, + opts: { idleThresholdMs: THRESHOLD_MS }, + expected: { close: true, reasonCode: 'idle-threshold' }, + }, { name: 'explicitly false signals fall through to the idle-threshold close', sample: { @@ -729,6 +753,19 @@ describe('classifySession — reasonSnapshot (parent AC6 / AC4.1)', () => { reasonCode: 'active', expected: { hasRecentFileModifications: false, hasActiveNetworkConnections: true }, }, + { + name: 'active via agent status', + sample: { agentStatus: 'work' }, + reasonCode: 'active', + expected: { agentStatus: 'work', hasRecentFileModifications: false }, + }, + { + name: 'idle-threshold records the agent status for diagnosis', + sample: { agentProcessAlive: true, idleMs: 700_000, agentStatus: 'idle' }, + opts: { idleThresholdMs: 600_000 }, + reasonCode: 'idle-threshold', + expected: { agentStatus: 'idle', agentProcessAlive: true, idleMs: 700_000 }, + }, ]; for (const row of rows) { diff --git a/packages/herdr/src/pane-close.ts b/packages/herdr/src/pane-close.ts index 64bc25bf2..8c748096c 100644 --- a/packages/herdr/src/pane-close.ts +++ b/packages/herdr/src/pane-close.ts @@ -35,6 +35,15 @@ */ export const DEFAULT_IDLE_THRESHOLD_MS = 30 * 60 * 1000; +/** + * herdr `agent_status` values that mean the agent is actively working + * (parent AC3). A pane reporting one of these is kept open by the + * activity guard even when its idle time exceeds the threshold: an agent + * can be silent-for-a-while yet busy (long-running tool call, model + * thinking) and must not be reaped. + */ +const ACTIVE_AGENT_STATUSES = new Set(['work', 'working', 'busy', 'running']); + // ── Types ───────────────────────────────────────────────────────────── /** @@ -74,6 +83,12 @@ export interface SessionSample { * connections. Optional/tolerant: absent or `false` = no activity. */ hasActiveNetworkConnections?: boolean; + /** + * Raw herdr agent status (`idle`, `work`, `done`, `unknown`, …), when + * known. Used both for the logging snapshot and as an activity signal + * (parent AC3): a `work`-class status keeps the pane open. + */ + agentStatus?: string; } /** @@ -101,6 +116,8 @@ export interface CloseReasonSnapshot { hasRecentFileModifications?: boolean; /** Active network-connection activity signal. */ hasActiveNetworkConnections?: boolean; + /** Raw herdr agent status at decision time, when known. */ + agentStatus?: string; /** Pane age since first dispatch (ms), when known. */ ageSinceDispatchMs?: number; /** Configured grace period (ms); `0` = disabled. */ @@ -192,6 +209,8 @@ export function classifySession( // Full state snapshot (parent AC6 / AC4.1): attached to every decision so // the reason can be reconstructed from the log alone. + const agentStatus = + typeof sample.agentStatus === 'string' ? sample.agentStatus : undefined; const reasonSnapshot: CloseReasonSnapshot = { kind: sample.kind, agentProcessAlive: sample.agentProcessAlive === true, @@ -199,6 +218,7 @@ export function classifySession( itemStage: typeof sample.itemStage === 'string' ? sample.itemStage : undefined, needsProducerReview: sample.needsProducerReview === true, isInvokingPane: sample.isInvokingPane === true, + agentStatus, childProcessCount: typeof sample.childProcessCount === 'number' && Number.isFinite(sample.childProcessCount) ? sample.childProcessCount @@ -251,12 +271,14 @@ export function classifySession( } // 5. Active-agent signals (parent AC3): a pane showing recent file or - // network activity is still working even when its idle time exceeds the - // threshold. This guards the idle-threshold path only — an explicit - // `</end_session>` marker (above) still closes the pane. + // network activity, or reporting a `work`-class agent status, is still + // working even when its idle time exceeds the threshold. This guards the + // idle-threshold path only — an explicit `</end_session>` marker (above) + // still closes the pane. if ( sample.hasRecentFileModifications === true || - sample.hasActiveNetworkConnections === true + sample.hasActiveNetworkConnections === true || + (agentStatus !== undefined && ACTIVE_AGENT_STATUSES.has(agentStatus.toLowerCase())) ) { return decide(false, 'active'); } From e09a447733e08b0d3686b33104dd9b2917814bee Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 11:17:44 +0100 Subject: [PATCH 24/27] WL-0MUWIHUL2000JEFR: Implementation complete --- docs/design/mode-switch-worker.md | 30 +- packages/herdr/README.md | 6 +- packages/herdr/src/downtime-worker.test.ts | 34 -- packages/herdr/src/downtime-worker.ts | 33 -- .../src/drain-dispatch-integration.test.ts | 272 ------------- packages/herdr/src/index.ts | 7 - packages/herdr/src/mode-switch-worker.test.ts | 357 ++---------------- packages/herdr/src/mode-switch-worker.ts | 122 ++---- 8 files changed, 86 insertions(+), 775 deletions(-) delete mode 100644 packages/herdr/src/drain-dispatch-integration.test.ts diff --git a/docs/design/mode-switch-worker.md b/docs/design/mode-switch-worker.md index 2159a954c..99aaebebb 100644 --- a/docs/design/mode-switch-worker.md +++ b/docs/design/mode-switch-worker.md @@ -28,7 +28,12 @@ presence and proxy idle state, without operator intervention: `/downtime toggle` chord do **not** count as operator activity. - The worker records the operator timestamp and POSTs `/admin/set-mode {"mode":"fast"}` — unless the last-known mode is already - fast (dedup; refreshed via `GET /admin/mode` per poll). + fast (dedup). Idle ticks (the only other mode refresh) do not run while the + operator is active, so when the cached mode says fast the command path + re-reads the actual mode via `GET /admin/mode` first: a schedule-driven flip + to cheap can never suppress the operator's explicit fast request + (WL-0MUWIHUL2000JEFR). A failed re-read keeps the cached mode + (fail-closed: never switch off an unknown state). - **Fail-open:** the POST is fire-and-forget and can never block or delay command dispatch — a slow or unresponsive proxy never delays the operator. @@ -43,26 +48,6 @@ presence and proxy idle state, without operator intervention: responses yield no switch and never crash or block the plugin. A `409` (mode-switch restart in progress) is a no-op, retried on a later tick. -### Drain before switching (WL-0MUL0KO7Q003O7YJ) - -When the idle window is met **and** the proxy is idle, the worker enters a -**draining** state instead of switching immediately: it waits until the active -session count has fallen to the cheap pool's budget (`freeSlots >= -DOWNTIME_AUDIT_MIN_FREE_SLOTS = 2`), then switches. In-flight work is never -killed — draining only delays the switch and blocks *new* dispatches. - -While draining: - -- The downtime dispatcher pauses **new** dispatches. The mode-switch worker - exposes `getIsDraining()`; `index.ts` forwards it to the dispatcher as - `config().drainPaused`, and `DowntimeWorker.tick()` refuses to dispatch while - it is `true`. The poll and `onProxyIdle` hook still run every tick, so the - mode-switch worker keeps observing the live free-slot count. -- Once the budget frees, the drain completes, the proxy switches to cheap, - `getIsDraining()` clears, and dispatch resumes on the next idle tick. -- An operator agent-route command (`onOperatorCommand`) cancels the drain - immediately, resets the idle clock, and keeps the proxy fast. - ### Restart semantics On plugin/pane restart the idle clock resets to "active now" (construction @@ -117,9 +102,6 @@ time-based plan. - `packages/herdr/src/mode-switch-worker.test.ts` — worker core tests - `packages/herdr/src/mode-switch-integration.test.ts` — wiring tests (settings → worker, route classification, scheduler interval constants) -- `packages/herdr/src/drain-dispatch-integration.test.ts` — drain → dispatch - integration tests (the live `getIsDraining()` → `config().drainPaused` - wiring pauses, resumes and cancels dispatch) - `packages/herdr/src/settings.ts` — settings schema + validation/clamps - `packages/herdr/src/index.ts` — agent-route hook wiring - `packages/herdr/src/worklist.ts` — scheduler task registration \ No newline at end of file diff --git a/packages/herdr/README.md b/packages/herdr/README.md index ebb871485..ec108acc3 100644 --- a/packages/herdr/README.md +++ b/packages/herdr/README.md @@ -856,7 +856,11 @@ proxy idle state: records operator activity and triggers a switch to fast mode via `POST {proxyUrl}/admin/set-mode` when the proxy is not already in fast mode. The POST is fire-and-forget and cannot block command dispatch (fail-open), - so a slow or unresponsive proxy never delays the operator. + so a slow or unresponsive proxy never delays the operator. When the cached + mode says fast the command path re-reads the proxy's actual mode + (`GET /admin/mode`) first, so a schedule-driven flip to cheap while the + operator is active can never suppress the explicit fast switch + (WL-0MUWIHUL2000JEFR). - **Cheap on idle (fail-closed)** — once the operator has been inactive for `modeSwitchIdleThresholdMs` **and** the proxy reports idle (via backend `GET {proxyUrl}/llama/local/status`; no local query, no model switch, no diff --git a/packages/herdr/src/downtime-worker.test.ts b/packages/herdr/src/downtime-worker.test.ts index ecc480af2..76c17f860 100644 --- a/packages/herdr/src/downtime-worker.test.ts +++ b/packages/herdr/src/downtime-worker.test.ts @@ -4581,7 +4581,6 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { deps?: Partial<DowntimeWorkerDeps>; mode?: 'cheap' | 'fast'; concurrentDispatchCap?: number; - drainPaused?: boolean; } = {}) { const cfg = { enabled: overrides.enabled ?? true, @@ -4595,8 +4594,6 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { ...(overrides.concurrentDispatchCap !== undefined ? { concurrentDispatchCap: overrides.concurrentDispatchCap } : {}), - // Drain pause signal (parent WL-0MUL0KO7Q003O7YJ, F2). - drainPaused: overrides.drainPaused, }; const fetcher = vi .fn() @@ -4710,37 +4707,6 @@ describe('downtime worker orchestrator (createDowntimeWorker)', () => { expect(call[1]).not.toHaveProperty('spawnConfig'); }); - it('F2: a drainPaused config pauses new dispatches and reports the draining block token', async () => { - // While the mode-switch worker drains active sessions down to the cheap - // pool budget, the dispatcher must not spawn NEW panes. The poll still - // runs (so the mode-switch worker keeps observing free slots) and the - // refusal is neutral — no candidate was even selected. - const { worker, deps } = makeWorker({ thresholdMs: 0, drainPaused: true }); - vi.setSystemTime(1_000_000); - - const result = await worker.tick(); - - expect(result).toEqual({ polled: true, dispatched: false, idle: true }); - expect(worker.blockReason).toBe('draining'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - }); - - it('F2: dispatch resumes on the next idle tick once the drain signal clears', async () => { - const { worker, deps, cfg } = makeWorker({ thresholdMs: 0, drainPaused: true }); - vi.setSystemTime(1_000_000); - const paused = await worker.tick(); - expect(paused.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - // The drain completed (the proxy switched to cheap) → the very next - // idle tick dispatches normally. - cfg.drainPaused = false; - vi.setSystemTime(1_000_001); - const resumed = await worker.tick(); - expect(resumed.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - it('requires a fresh full idle period after a dispatch (AC5)', async () => { const { worker, deps, cfg } = makeWorker(); const start = 1_000_000; diff --git a/packages/herdr/src/downtime-worker.ts b/packages/herdr/src/downtime-worker.ts index 72084f91f..11de5b3ba 100644 --- a/packages/herdr/src/downtime-worker.ts +++ b/packages/herdr/src/downtime-worker.ts @@ -5308,17 +5308,6 @@ export interface DowntimeWorkerConfig { * at `'1'`. */ mode?: 'cheap' | 'fast'; - /** - * Drain-pause signal from the mode-switch worker (parent - * WL-0MUL0KO7Q003O7YJ, F2 WL-0MUNMCZ2K003DZ50): `true` while the worker - * is draining active sessions down to the cheap-pool budget. When set, - * `tick()` polls and tracks idle as usual but never dispatches a NEW - * work item — in-flight work is untouched. Re-read every tick, so once - * the drain ends and the proxy switches to cheap, dispatch resumes on - * the next idle tick. Defaults to `undefined` → no pause (backward - * compatible fail-open: an unwired worker dispatches as before). - */ - drainPaused?: boolean; /** * Maximum concurrent dispatch pipelines (WL-0MT50S9JW001DHME). Defaults * to `DISPATCH_PIPELINE_SINGLE_FLIGHT` (1). Only a single-flight budget @@ -6197,28 +6186,6 @@ export function createDowntimeWorker(opts: DowntimeWorkerConfig): DowntimeWorker const contentionQueueDepth = typeof status.contention_queue_depth === 'number' ? status.contention_queue_depth : 0; - // ── Drain pause (parent WL-0MUL0KO7Q003O7YJ, F2 WL-0MUNMCZ2K003DZ50) ── - // While the mode-switch worker drains active sessions down to the - // cheap-pool budget, pause NEW dispatches. The gate sits AFTER the - // poll + `onProxyIdle` hook above, so the mode-switch worker keeps - // receiving a fresh status every tick and can observe the drain - // complete; in-flight panes are never touched. `drainPaused` is - // re-read from config() each tick, so once the drain ends and the - // proxy switches to cheap, dispatch resumes on the next idle tick. - // Neutral refusal (no strike, no cooldown) like the slot-owned / - // contention gates below. Fail-open: `undefined` (unwired) never - // pauses. - if (cfg.drainPaused === true) { - void recordDecision(cfg.cwd, 'draining', { - freeSlots, - totalSlots: status.total_slots, - ownerPresent: ownerLeaseHeld, - runningPanes, - contentionDepth: contentionQueueDepth, - }); - return { polled: true, dispatched: false, idle: true }; - } - // Single machine-wide dispatch gate applied to BOTH coordination and // legacy modes: a new pane must never be dispatched while the slot is // owned or the proxy reports live contention. Each refusal is neutral diff --git a/packages/herdr/src/drain-dispatch-integration.test.ts b/packages/herdr/src/drain-dispatch-integration.test.ts deleted file mode 100644 index d34c04b69..000000000 --- a/packages/herdr/src/drain-dispatch-integration.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -/** - * Integration tests for the drain-pause dispatch signal (F5, parent - * WL-0MUL0KO7Q003O7YJ). - * - * These are true integration tests: they wire the REAL mode-switch worker's - * `getIsDraining()` state into the REAL downtime worker's - * `config().drainPaused` — the exact production wiring in index.ts - * (`drainPaused: modeSwitchHolder.worker?.getIsDraining() ?? false`, - * F2 WL-0MUNMCZ2K003DZ50) — and assert the full loop: - * - * 1. while the mode-switch worker is draining, the downtime worker polls - * and tracks idle but does NOT dispatch a new work item; - * 2. once the drain completes (the proxy switches to cheap), the very next - * idle tick dispatches normally; - * 3. while NOT draining, dispatch works normally (fail-open baseline); - * 4. an operator command during the drain cancels it, so the downtime - * worker resumes dispatching on the next tick (the drain signal is live). - * - * The F2 unit tests pin the `drainPaused` config contract in isolation; the - * F4 tests pin the mode-switch drain state machine in isolation. These tests - * pin the WIRING between them, which neither unit suite covers. - * - * Run: npx vitest run packages/herdr/src/drain-dispatch-integration.test.ts - */ - -import { describe, it, expect, vi } from 'vitest'; -import { - createDowntimeWorker, - createDowntimePoller, - type DowntimeWorkerDeps, - type LlamaStatus, -} from './downtime-worker.js'; -import { - createModeSwitchWorker, - type AdminApiFetcher, - type ProxyMode, -} from './mode-switch-worker.js'; -import { idleAllSlotsFree, jsonResponseFixture } from './downtime-worker.fixtures.js'; - -// ── Test helpers ────────────────────────────────────────────────────── - -let clock: number; - -function now(): number { - return clock; -} - -function advance(ms: number): void { - clock += ms; -} - -/** Build a per-slot `/llama/local/status` payload (LP-0MSG5TA7Y002GN39). */ -function perSlotStatus(overrides: Partial<LlamaStatus> = {}): LlamaStatus { - return { - llama_server_running: true, - active_query: false, - local_active_query: false, - model_switch_in_progress: false, - local_lease_active: false, - available_slots: 3, - total_slots: 3, - slots: [ - { slot_id: 'slot-1', is_processing: false }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - ...overrides, - }; -} - -/** - * 1 of 3 slots free, with the operator's session holding the two busy slots: - * the per-slot idle-entry gate passes (≥1 free) but the cheap pool's drain - * budget (≥2 free) is unmet → the mode-switch worker enters drain. - */ -function perSlotDrainPending(): LlamaStatus { - return perSlotStatus({ - active_query: true, - local_active_query: true, - local_lease_active: true, - available_slots: 1, - total_slots: 3, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: true }, - { slot_id: 'slot-3', is_processing: false }, - ], - }); -} - -/** 2 of 3 slots free — the drain-completion budget is met. */ -function perSlotDrainComplete(): LlamaStatus { - return perSlotStatus({ - active_query: true, - local_active_query: true, - local_lease_active: true, - available_slots: 2, - total_slots: 3, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - }); -} - -interface MockAdminApi { - fetcher: AdminApiFetcher; -} - -/** A minimal admin API: `GET /admin/mode` always `fast`, `POST` always 200. */ -function mockAdminApi(): MockAdminApi { - let mode: ProxyMode = 'fast'; - const fetcher: AdminApiFetcher = async (_url, init) => { - if ((init?.method ?? 'GET') === 'POST') { - mode = JSON.parse(init?.body ?? '{}').mode ?? mode; - return { ok: true, status: 200, json: async () => ({ ok: true }) }; - } - return { ok: true, status: 200, json: async () => ({ mode }) }; - }; - return { fetcher }; -} - -/** - * Dispatch-capable downtime-worker deps (mirrors the shared `makeDeps` in - * downtime-worker.test.ts): an empty Herdr list falls through to the legacy - * tier chain, where the intake tier resolves the stubbed candidate and the - * pane spawn succeeds. - */ -function makeDeps(): DowntimeWorkerDeps { - return { - getHerdrListHead: vi.fn().mockResolvedValue({ ok: true, items: [] }), - getNextItem: vi.fn().mockResolvedValue({ - ok: true, - candidate: { id: 'WL-ABC', title: 'Some task', stage: 'intake_complete' }, - }), - getNextAuditCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - getActiveAudit: vi.fn().mockResolvedValue({ ok: true, active: false }), - getNextImplementCandidate: vi.fn().mockResolvedValue(null), - getNextCriticalCandidate: vi.fn().mockResolvedValue({ ok: true, candidate: null }), - claimItem: vi.fn().mockResolvedValue({ ok: true }), - spawnAgentPane: vi.fn().mockResolvedValue({ ok: true }), - recordDispatch: vi.fn().mockResolvedValue(true), - recordDispatchFailure: vi.fn().mockResolvedValue(undefined), - rollbackClaim: vi.fn().mockResolvedValue(true), - recordError: vi.fn().mockResolvedValue(undefined), - getDueScheduledPrompt: vi.fn().mockResolvedValue(null), - recordScheduledPromptTrigger: vi.fn().mockResolvedValue(true), - readCodeFreezeStatus: vi.fn().mockReturnValue('not-frozen'), - hasFreshAudit: vi.fn().mockResolvedValue(false), - getReviewQueueCount: vi.fn().mockResolvedValue(0), - getRunningDowntimePanes: vi - .fn() - .mockResolvedValue({ ok: true, count: 0, paneIds: [], records: [] }), - }; -} - -/** - * Build a downtime worker whose `drainPaused` reads the live mode-switch drain - * state — the production index.ts wiring. - */ -function makeWiredDowntimeWorker(modeSwitch: ReturnType<typeof createModeSwitchWorker>) { - const deps = makeDeps(); - const poller = createDowntimePoller( - 'http://proxy:8000', - vi.fn().mockResolvedValue(jsonResponseFixture(idleAllSlotsFree)), - ); - const worker = createDowntimeWorker({ - poller, - deps, - config: () => ({ - enabled: true, - // A zero threshold makes the idle run dispatch on its first idle tick, - // so the tests isolate the drain signal rather than idle timing. - thresholdMs: 0, - requiredFreeSlots: 0, - model: 'plan', - cwd: '/repo', - noCandidateCooldownMs: 3_600_000, - drainPaused: modeSwitch.getIsDraining(), - }), - }); - return { worker, deps }; -} - -// ── Drain-pause integration ─────────────────────────────────────────── - -describe('mode-switch drain → downtime dispatch integration', () => { - it('pauses new dispatches while draining, then resumes once the drain completes', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const modeSwitch = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - - // Idle window met + proxy idle but only 1 free slot → enter drain. - await modeSwitch.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotDrainPending(), - }); - expect(modeSwitch.getIsDraining()).toBe(true); - - const { worker, deps } = makeWiredDowntimeWorker(modeSwitch); - - // While draining: the worker polls (so the mode-switch worker keeps - // observing free slots) but never spawns a new pane. - const paused = await worker.tick(); - expect(paused).toEqual({ polled: true, dispatched: false, idle: true }); - expect(worker.blockReason).toBe('draining'); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - // The cheap budget frees → the drain completes and the proxy switches to - // cheap. - await modeSwitch.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotDrainComplete(), - }); - expect(modeSwitch.getIsDraining()).toBe(false); - expect(modeSwitch.getLastKnownMode()).toBe('cheap'); - - // The next idle tick dispatches normally (the drain signal is live). - const resumed = await worker.tick(); - expect(resumed.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('dispatches normally while NOT draining (fail-open baseline)', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - // Never ticked → not draining. - const modeSwitch = createModeSwitchWorker({ fetcher: api.fetcher, now }); - expect(modeSwitch.getIsDraining()).toBe(false); - - const { worker, deps } = makeWiredDowntimeWorker(modeSwitch); - - const result = await worker.tick(); - expect(result.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); - - it('an operator command during drain cancels it, so dispatch resumes on the next tick', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const modeSwitch = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - await modeSwitch.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotDrainPending(), - }); - expect(modeSwitch.getIsDraining()).toBe(true); - - const { worker, deps } = makeWiredDowntimeWorker(modeSwitch); - const paused = await worker.tick(); - expect(paused.dispatched).toBe(false); - expect(deps.spawnAgentPane).not.toHaveBeenCalled(); - - // The operator returns: the drain is abandoned and the idle clock resets. - modeSwitch.onOperatorCommand('http://proxy'); - expect(modeSwitch.getIsDraining()).toBe(false); - - // Dispatch resumes on the next tick — the drain signal was live, not - // cached at worker construction. - const resumed = await worker.tick(); - expect(resumed.dispatched).toBe(true); - expect(deps.spawnAgentPane).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/herdr/src/index.ts b/packages/herdr/src/index.ts index 8cca1284a..514d7bbf8 100644 --- a/packages/herdr/src/index.ts +++ b/packages/herdr/src/index.ts @@ -1917,13 +1917,6 @@ async function main(): Promise<void> { // (conservative `undefined` → PARALLELISM=1) and populated immediately // after, so later ticks read the real mode. mode: modeSwitchHolder.worker?.getLastKnownMode() ?? undefined, - // Drain-pause signal (parent WL-0MUL0KO7Q003O7YJ, F2 - // WL-0MUNMCZ2K003DZ50): while the mode-switch worker drains sessions - // down to the cheap-pool budget, the dispatcher pauses NEW panes. The - // holder is empty during synchronous construction (conservative - // `false` → dispatch unaffected), populated immediately after, so - // later ticks read the live drain state. - drainPaused: modeSwitchHolder.worker?.getIsDraining() ?? false, noCandidateCooldownMs: s.downtimeNoCandidateCooldownMs, // Non-terminal pane-close cooldown (WL-0MUKYERLZ006ELL5). nonTerminalCooldownMs: s.downtimeNonTerminalCooldownMs, diff --git a/packages/herdr/src/mode-switch-worker.test.ts b/packages/herdr/src/mode-switch-worker.test.ts index 583a281b9..e4a7449aa 100644 --- a/packages/herdr/src/mode-switch-worker.test.ts +++ b/packages/herdr/src/mode-switch-worker.test.ts @@ -30,7 +30,7 @@ import { type AdminApiFetcher, type ProxyMode, } from './mode-switch-worker.js'; -import type { LlamaStatus } from './downtime-worker.js'; +import { DOWNTIME_AUDIT_MIN_FREE_SLOTS, type LlamaStatus } from './downtime-worker.js'; // ── Test helpers ────────────────────────────────────────────────────── @@ -294,6 +294,30 @@ describe('fast switch on command', () => { expect(setModePosts(api)).toHaveLength(0); }); + it('a command issued while the proxy is actually cheap forces a fast switch despite a stale cached mode', async () => { + clock = 1_000_000; + const api = mockAdminApi(); + // First command: the mode is unknown → the fast POST fires and the cache + // becomes 'fast'. + const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + expect(worker.getLastKnownMode()).toBe('fast'); + api.reset(); + + // The proxy flips to cheap on its own schedule while the operator is + // active — idle ticks, the only other mode refresh, do not run. The + // cached mode therefore still says 'fast'; the operator's next command + // must not be suppressed by that stale cache (WL-0MUWIHUL2000JEFR). + api.setGetMode(() => 'cheap'); + worker.onOperatorCommand('http://proxy'); + await flushAsync(); + expect(setModePosts(api)).toHaveLength(1); + expect(setModePosts(api)[0].body).toBe(JSON.stringify({ mode: 'fast' })); + expect(worker.getLastKnownMode()).toBe('fast'); + }); + it('a failed fast switch never throws and never blocks the command (fail-open)', async () => { clock = 1_000_000; const api = mockAdminApi(); @@ -429,293 +453,6 @@ describe('cheap switch trigger', () => { }); }); -// ── Drain before cheap switch (parent WL-0MUL0KO7Q003O7YJ) ───────────── - -describe('drain before cheap switch', () => { - it('getIsDraining() starts false', () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - expect(worker.getIsDraining()).toBe(false); - }); - - it('idle + proxy idle but fewer than the cheap budget free → enters drain with no switch', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - // 1 free of 3: the per-slot idle-entry gate passes (≥1 free) but the - // cheap pool's drain budget (≥2 free) is not yet met. - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(setModePosts(api)).toHaveLength(0); - expect(worker.getIsDraining()).toBe(true); - // Mode tracking is unaffected by the drain state. - expect(worker.getLastKnownMode()).toBe('fast'); - }); - - it('drain completes once the cheap budget frees → cheap switch, draining clears', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(true); - - // A slot frees: now 2 of 3 free → the drain completes and switches. - api.reset(); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotDrainComplete(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(false); - expect(setModePosts(api)).toHaveLength(1); - expect(setModePosts(api)[0].body).toBe(JSON.stringify({ mode: 'cheap' })); - expect(worker.getLastKnownMode()).toBe('cheap'); - }); - - it('stays draining across multiple ticks while the cheap budget is unmet', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - for (let i = 0; i < 3; i += 1) { - advance(10_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(true); - } - expect(setModePosts(api)).toHaveLength(0); - }); - - it('an operator command during drain cancels it and resets the idle clock (proxy stays fast)', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(true); - - // Operator returns: the drain is cancelled and the idle clock resets to - // "active now" (the fast-mode dedup skips a redundant fast POST). - api.reset(); - worker.onOperatorCommand('http://proxy'); - await flushAsync(); - expect(worker.getIsDraining()).toBe(false); - - // The fresh idle window means the very next tick does not re-enter drain. - api.reset(); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(false); - expect(setModePosts(api)).toHaveLength(0); - }); - - it('fail-closed: a proxy status failure during drain does not exit drain and never switches', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(true); - - api.reset(); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: null, // endpoint failure / timeout / ambiguous - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(true); // no drain exit - expect(setModePosts(api)).toHaveLength(0); - }); - - it('a non-leader never enters drain', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ - fetcher: api.fetcher, - now, - isLeader: () => false, - }); - advance(900_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(false); - expect(setModePosts(api)).toHaveLength(0); - }); - - it('no redundant switching: a persisted cheap mode short-circuits before drain entry', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - // The proxy is already cheap: even with the idle window met and the cheap - // budget unmet, the worker must not enter drain nor POST again. - api.setGetMode(() => 'cheap'); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(false); - expect(setModePosts(api)).toHaveLength(0); - expect(worker.getLastKnownMode()).toBe('cheap'); - }); - - it('drain completing into a 409 restart no-op leaves the mode fast and retries on a later tick', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(true); - - // The proxy reports a mode-switch restart in progress (409) when the - // drain completes: treated as a no-op, the mode stays fast. - api.setSetMode(() => ({ status: 409, body: { error: 'mode-switch restart in progress' } })); - api.reset(); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotDrainComplete(), - }); - await flushAsync(); - expect(setModePosts(api)).toHaveLength(1); // attempted once - expect(worker.getLastKnownMode()).toBe('fast'); // 409 → no mode change - expect(worker.getIsDraining()).toBe(false); - - // The next tick retries and succeeds once the restart has settled. - api.setSetMode(() => ({ status: 200, body: { ok: true } })); - api.reset(); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotDrainComplete(), - }); - await flushAsync(); - expect(setModePosts(api)).toHaveLength(1); - expect(worker.getLastKnownMode()).toBe('cheap'); - }); - - it('operator command during drain while already fast follows the normal fast path with no redundant POST', async () => { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); - advance(900_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(true); - expect(worker.getLastKnownMode()).toBe('fast'); - - // The proxy is already fast: the fast path dedups, so cancelling the - // drain fires no redundant POST and the tracked mode is unchanged. - api.reset(); - worker.onOperatorCommand('http://proxy'); - await flushAsync(); - expect(worker.getIsDraining()).toBe(false); - expect(setModePosts(api)).toHaveLength(0); - expect(worker.getLastKnownMode()).toBe('fast'); - }); - - it('a cross-pane shared activity broadcast during drain cancels it', async () => { - const dir = mkdtempSync(join(tmpdir(), 'wl-mode-drain-activity-')); - try { - clock = 1_000_000; - const api = mockAdminApi(); - const worker = createModeSwitchWorker({ - fetcher: api.fetcher, - now, - isLeader: () => true, - coordinationDir: dir, - }); - advance(900_000); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(true); - - // Another pane receives an operator command and broadcasts its activity: - // the idle clock advances, so the next tick cancels the drain. - advance(10_000); - writeSharedActivity(dir, clock); - api.reset(); - await worker.tick({ - enabled: true, - idleThresholdMs: 900_000, - proxyUrl: 'http://proxy', - proxyStatus: perSlotOperatorFree(), - }); - await flushAsync(); - expect(worker.getIsDraining()).toBe(false); - expect(setModePosts(api)).toHaveLength(0); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); -}); - // ── Fail-closed paths ──────────────────────────────────────────────── describe('fail-closed paths', () => { @@ -1037,41 +774,24 @@ function perSlotOperatorFree(overrides: Partial<LlamaStatus> = {}): LlamaStatus }); } -/** - * Per-slot payload with 2 of 3 slots free — the cheap pool's spare capacity - * (parent WL-0MUL0KO7Q003O7YJ): one downtime pane holds a busy slot, but the - * two free slots satisfy the drain-completion budget, so the idle cheap - * switch fires. - */ -function perSlotDrainComplete(overrides: Partial<LlamaStatus> = {}): LlamaStatus { - return perSlotStatus({ - active_query: true, - local_active_query: true, - local_lease_active: true, - available_slots: 2, - total_slots: 3, - slots: [ - { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: false }, - { slot_id: 'slot-3', is_processing: false }, - ], - ...overrides, - }); -} describe('per-slot operator gate', () => { - it('AC1: per-slot payload, ≥2 free slots → posts cheap even while another slot is busy (downtime panes)', async () => { + it('AC1/regression: per-slot payload, fewer free slots than the audit budget → posts cheap even while the other two slots are busy (downtime panes); the withdrawn slot-count gate must not return (WL-0MUWIHUL2000JEFR)', async () => { clock = 1_000_000; const api = mockAdminApi(); const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); advance(900_000); // idle window met + const status = perSlotOperatorFree(); + // The withdrawn client drain required `freeSlots >= 2` + // (`MODE_SWITCH_DRAIN_FREE_SLOTS`, borrowed from the audit-dispatch budget + // `DOWNTIME_AUDIT_MIN_FREE_SLOTS`). This payload has only 1 free slot, so + // a reintroduced slot-count gate would leave the switch unfired. + expect(status.available_slots).toBeLessThan(DOWNTIME_AUDIT_MIN_FREE_SLOTS); await worker.tick({ enabled: true, idleThresholdMs: 900_000, proxyUrl: 'http://proxy', - // Drain-complete: the cheap pool's 2 free slots are available even - // though a downtime pane holds the third slot busy. - proxyStatus: perSlotDrainComplete(), + proxyStatus: status, }); await flushAsync(); expect(setModePosts(api)).toHaveLength(1); @@ -1100,23 +820,22 @@ describe('per-slot operator gate', () => { expect(setModePosts(api)).toHaveLength(0); }); - it('AC3: busy slots with ≥2 free slots → switch fires (spare-capacity semantics)', async () => { + it('AC3: busy slots with ≥1 free slot → switch fires (spare-capacity semantics)', async () => { clock = 1_000_000; const api = mockAdminApi(); const worker = createModeSwitchWorker({ fetcher: api.fetcher, now }); advance(900_000); - // 2 free of 3, without the global query/lease signals: the per-slot - // spare-capacity gate (busy slot does not block) plus the drain-complete - // budget (≥2 free) fires the switch. + // 1 free of 3, without the global query/lease signals: the per-slot + // spare-capacity gate (≥1 free) must still fire. await worker.tick({ enabled: true, idleThresholdMs: 900_000, proxyUrl: 'http://proxy', proxyStatus: perSlotStatus({ - available_slots: 2, + available_slots: 1, slots: [ { slot_id: 'slot-1', is_processing: true }, - { slot_id: 'slot-2', is_processing: false }, + { slot_id: 'slot-2', is_processing: true }, { slot_id: 'slot-3', is_processing: false }, ], }), diff --git a/packages/herdr/src/mode-switch-worker.ts b/packages/herdr/src/mode-switch-worker.ts index ecd6c5e5c..b7cc700a9 100644 --- a/packages/herdr/src/mode-switch-worker.ts +++ b/packages/herdr/src/mode-switch-worker.ts @@ -9,8 +9,11 @@ * the plugin (WL-0MSN3FWV5008KQE9), the worker records the operator * timestamp and fire-and-forget POSTs `/admin/set-mode {"mode":"fast"}`. * A failed switch never blocks or delays the command dispatch (fail-open: - * the pane opens regardless). The fast switch fires on the instance that - * received the command, whichever pane that is — see the leader note below. + * the pane opens regardless). When the cached mode says `fast` the command + * path re-reads the actual mode first, so a schedule-driven flip to cheap + * never suppresses the explicit fast request (WL-0MUWIHUL2000JEFR). The + * fast switch fires on the instance that received the command, whichever + * pane that is — see the leader note below. * - **Leader-only cheap switching (WL-0MU6MXCZZ007ZXT9, AC1):** only the * downtime leader (`DowntimeWorker.isLeader`, reused — no separate * election) runs the idle cheap-switch logic. Non-leader instances skip @@ -25,20 +28,12 @@ * max of the local clock and the shared timestamp, so the proxy stays fast * while **any** pane has recent activity. A read/write failure falls back * to the local clock (AC6, fail-closed: never a premature cheap switch). - * - **Cheap switch on idle (drain before switching, parent - * WL-0MUL0KO7Q003O7YJ):** on each tick (scheduler task, or the downtime + * - **Cheap switch on idle:** on each tick (scheduler task, or the downtime * dispatcher's `onProxyIdle` callback — the leader is the only pane that * runs that dispatcher), when the operator has been idle * (no agent-route commands on any pane) for ≥ `modeSwitchIdleThresholdMs` * **AND** the proxy reports idle (reusing `evaluateIdle` from - * downtime-worker.ts), the worker drains active sessions down to the - * cheap pool's spare capacity before POSTing - * `/admin/set-mode {"mode":"cheap"}`. The drain completes once - * `evaluateIdle(status, MODE_SWITCH_DRAIN_FREE_SLOTS)` holds (≥ 2 free - * slots); until then the worker reports `getIsDraining() === true` so the - * downtime dispatcher pauses NEW dispatches (existing in-flight work is - * never killed). An operator agent-route command cancels the drain and - * resets the idle clock (the proxy stays fast). + * downtime-worker.ts), the worker POSTs `/admin/set-mode {"mode":"cheap"}`. * **Per-slot operator gate (parent WL-0MT9F67Y3008S0PR, decision 1.a):** * when the proxy serves per-slot identity (`slots[]` valid per * `parseLlamaStatus`), the idle gate requires only ≥ 1 free slot @@ -46,14 +41,13 @@ * semantics shared with the downtime dispatcher, whose autonomous * query/lease work holds the OTHER busy slots. A downtime pane is exactly * the work the operator wants to keep running while the proxy runs cheap, - * so the busy slots must **not** block the idle *entry* gate (Q4b - * decision; accepted tradeoff: a fast-mode downtime request in flight - * during the mode restart is killed and retried by its client). The drain - * *completion* gate then still requires the cheap pool's 2 free slots. - * Without per-slot data the all-slots-free fail-closed entry fallback is - * unchanged (`evaluateIdle(status, 0)` — full global checks). In every - * case a busy proxy (server down, model switch, or 0 free slots) - * **delays** the switch — it never kills in-flight work. + * so the busy slots must **not** delay the switch (Q4b decision; + * accepted tradeoff: a fast-mode downtime request in flight during the + * mode restart is killed and retried by its client). Without per-slot + * data the all-slots-free fail-closed fallback is unchanged + * (`evaluateIdle(status, 0)` — full global checks). In every case a busy + * proxy (server down, model switch, or 0 free slots) **delays** the + * switch — it never kills in-flight work. * - **No redundant switching / no hammering:** the worker skips a switch when * the persisted mode already matches the target (tracking last-known mode, * refreshed via `GET /admin/mode` on each poll). At most one switch per @@ -89,7 +83,6 @@ import { join } from 'node:path'; import { evaluateIdle, parseLlamaStatus, - DOWNTIME_AUDIT_MIN_FREE_SLOTS, DOWNTIME_PANE_MIN_FREE_SLOTS, type LlamaStatus, } from './downtime-worker.js'; @@ -106,15 +99,6 @@ export const ADMIN_SET_MODE_PATH = '/admin/set-mode'; /** Default idle window before switching to cheap mode: 15 minutes. */ export const DEFAULT_MODE_SWITCH_IDLE_THRESHOLD_MS = 900_000; -/** - * Free slots the proxy must report before the idle cheap switch completes. - * Reuses the audit-dispatch slot budget (`DOWNTIME_AUDIT_MIN_FREE_SLOTS` = 2) - * as the cheap-mode pool size (parent WL-0MUL0KO7Q003O7YJ): the worker drains - * active sessions down to this spare-capacity level before switching, so the - * two-slot cheap pool always has room for the operator to return. - */ -export const MODE_SWITCH_DRAIN_FREE_SLOTS = DOWNTIME_AUDIT_MIN_FREE_SLOTS; - /** * Defensive floor for the idle threshold (60s): a trivially small window * would defeat the operator-presence gating (never switch to cheap right @@ -392,9 +376,8 @@ export type Now = () => number; export interface ModeSwitchWorker { /** * Record that the operator issued an agent-route command through the - * plugin. Updates the idle clock, cancels any in-progress drain (the - * operator is back — the cheap switch is abandoned), and (fire-and-forget) - * POSTs `/admin/set-mode {"mode":"fast"}` when the last known mode is not + * plugin. Updates the idle clock and (fire-and-forget) POSTs + * `/admin/set-mode {"mode":"fast"}` when the last known mode is not * already fast. Never blocks or delays the command dispatch (fail-open). * `proxyUrl` is the shared `downtimeProxyUrl`. */ @@ -414,15 +397,6 @@ export interface ModeSwitchWorker { * mode (`null`) conservatively maps to the serial `'1'` default. */ getLastKnownMode(): ProxyMode | null; - /** - * Whether the worker is currently draining active sessions before the idle - * cheap switch (parent WL-0MUL0KO7Q003O7YJ): true once the idle window is - * met and the proxy is idle but fewer than the cheap pool's free slots are - * available, cleared once the switch completes or an operator command - * arrives. The downtime dispatcher reads this to pause new dispatches while - * draining (WL-0MUNMCZ2K003DZ50). - */ - getIsDraining(): boolean; } /** @@ -484,16 +458,6 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit let lastKnownMode: ProxyMode | null = null; /** True while a mode-switch POST is in flight (per-process single-flight). */ let switchInFlight = false; - /** - * True while draining active sessions before the idle cheap switch: the - * idle window is met, the proxy is idle, but fewer than - * `MODE_SWITCH_DRAIN_FREE_SLOTS` slots are free. While draining the worker - * keeps polling (it does not kill in-flight work) and the downtime - * dispatcher pauses NEW dispatches so the slots can fall to the cheap - * pool's budget. Cleared on switch completion, operator command, or a - * resumed idle window (parent WL-0MUL0KO7Q003O7YJ). - */ - let draining = false; /** * Fire-and-forget mode switch with per-process single-flight and @@ -519,12 +483,25 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit } }; + /** + * Fire the operator-command fast switch. The command path must not trust a + * possibly-stale `lastKnownMode`: the proxy can flip to cheap on its own + * schedule while the operator is active, and idle ticks — the only other + * place the mode is refreshed — do not run while active. When the cache + * says `fast` (the only value that would suppress the switch), re-read the + * actual mode first; on a read failure keep the cache (fail-closed: never + * switch off an unknown state). Fire-and-forget; never throws. + */ + const fireFastSwitch = async (proxyUrl: string): Promise<void> => { + if (lastKnownMode === 'fast') { + const current = await getAdminMode(proxyUrl, fetcher); + if (current !== null) lastKnownMode = current; + } + await fireSwitch(proxyUrl, 'fast'); + }; + return { onOperatorCommand(proxyUrl: string): void { - // The operator is back: an in-progress drain is abandoned (the cheap - // switch is cancelled) and the idle clock resets to "active now" - // (parent WL-0MUL0KO7Q003O7YJ — operator-command cancellation). - draining = false; // Record operator activity (agent-route command) on the local clock. lastOperatorCommandAt = now(); // Broadcast to every pane — leader and non-leader alike — so the @@ -538,17 +515,13 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit // leader — gating fast on leadership would leave the proxy in cheap // while the operator is actively working (AC4). Only the *cheap* idle // switch is leader-gated; see tick() (AC1). - void fireSwitch(proxyUrl, 'fast'); + void fireFastSwitch(proxyUrl); }, getLastKnownMode(): ProxyMode | null { return lastKnownMode; }, - getIsDraining(): boolean { - return draining; - }, - async tick(opts: ModeSwitchTickOptions): Promise<void> { if (!opts.enabled) return; // Leader-only cheap switching (AC1): non-leader instances skip the @@ -564,12 +537,7 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit // clock can never be null — restart resets to "active now", so a fresh // full window is always required.) const elapsed = now() - effectiveLastActivityAt(); - if (elapsed < opts.idleThresholdMs) { - // Activity resumed (local or shared cross-pane command): any - // in-progress drain is abandoned and the cheap switch is cancelled. - draining = false; - return; - } + if (elapsed < opts.idleThresholdMs) return; // Fetch proxy status when not provided (the worker fetches its own // idle state from the proxy so the caller doesn't need to). null @@ -603,25 +571,9 @@ export function createModeSwitchWorker(deps?: ModeSwitchWorkerOptions): ModeSwit lastKnownMode = currentMode; } // Skip when the persisted mode already matches the target (or is - // unknown/read-unreachable — fail-closed no-op). A no-op also clears - // any stale drain state. - if (lastKnownMode === 'cheap' || lastKnownMode === null) { - draining = false; - return; - } - - // Drain before switching (parent WL-0MUL0KO7Q003O7YJ): the cheap pool - // holds `MODE_SWITCH_DRAIN_FREE_SLOTS` (2) slots, so wait until that - // many slots are free before switching. If the budget is already - // available the switch fires on this tick; otherwise the worker enters - // (or stays in) the draining state and the downtime dispatcher pauses - // new work (WL-0MUNMCZ2K003DZ50) so the active sessions can subside. - if (!evaluateIdle(proxyStatus, MODE_SWITCH_DRAIN_FREE_SLOTS)) { - draining = true; - return; - } + // unknown/read-unreachable — fail-closed no-op). + if (lastKnownMode === 'cheap' || lastKnownMode === null) return; - draining = false; await fireSwitch(opts.proxyUrl, 'cheap'); }, }; From 55b085a17f62aa2ec4cfbd3911b837d204b8fa21 Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 12:00:33 +0100 Subject: [PATCH 25/27] WL-0MUWCGF670087GAS: Add typed cta config field, resolver, docs, tests; populate sibling CTAs --- .worklog/config.defaults.yaml | 4 + CONFIG.md | 32 +++++++ packages/shared/src/types.ts | 11 +++ src/config.ts | 45 ++++++++++ tests/unit/config-cta.test.ts | 156 ++++++++++++++++++++++++++++++++++ 5 files changed, 248 insertions(+) create mode 100644 tests/unit/config-cta.test.ts diff --git a/.worklog/config.defaults.yaml b/.worklog/config.defaults.yaml index 6abf0559d..1fd1f4ecf 100644 --- a/.worklog/config.defaults.yaml +++ b/.worklog/config.defaults.yaml @@ -36,3 +36,7 @@ statusStageCompatibility: blocked: [idea, intake_complete, plan_complete] completed: [in_review, done] deleted: [idea, intake_complete, plan_complete, done] +# Optional per-project call-to-action (CTA) surfaced in release/report output. +# Free-form Markdown — quote the value so link syntax survives the YAML +# round-trip. Absent = no behaviour change. See CONFIG.md for details. +# cta: "[Play the alpha release](https://example.github.io/my-project/). Provide feedback in [Discord](https://discord.gg/gUKQTFkzQ4)" diff --git a/CONFIG.md b/CONFIG.md index 3b5124daa..aeaf6556d 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -104,6 +104,38 @@ The system loads configuration in this order: If no configuration exists at all, the system defaults to using `WI` as the prefix. +## Project CTA (Call-to-Action) + +The optional `cta` field declares a project-specific call-to-action — a +free-form **Markdown** string that report consumers (for example the ship +skill's CHANGELOG section and release notifications) can surface. It is +typically used by game projects to invite players to try a build and give +feedback. + +```yaml +# .worklog/config.yaml +cta: "[Play the alpha release](https://example.github.io/my-project/). Provide feedback in [Discord](https://discord.gg/gUKQTFkzQ4)" +``` + +- **Optional.** When absent, the resolver returns `null` and report output is + unchanged (no behaviour change). +- **Markdown / YAML-safe.** The value is a Markdown string; **quote it** in + YAML (as above) so link syntax such as `[label](url)` survives the + round-trip. A present non-string value fails config validation. +- **Two-tier override.** Like every other field, a `cta` in + `.worklog/config.yaml` overrides a `cta` in `.worklog/config.defaults.yaml`. + +Consumers read the merged value through the exported `resolveProjectCta()` +resolver in `src/config.ts`, or directly from the loaded `WorklogConfig` +(`config.cta`): + +```ts +import { loadConfig, resolveProjectCta } from './config.js'; + +const config = loadConfig(); +const cta = resolveProjectCta(config); // string | null +``` + ## GitHub Settings Optional GitHub settings (edit `.worklog/config.yaml` manually): diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index cfe078f2b..63f68b4ed 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -354,6 +354,17 @@ export interface WorklogConfig { * extraction). See {@link InterviewConfig}. */ interview?: InterviewConfig; + /** + * Optional per-project call-to-action (CTA) — a free-form Markdown string + * surfaced in release/report output (e.g. the ship skill's CHANGELOG and + * release notifications). Read via {@link resolveProjectCta}. + * + * Example: + * ```yaml + * cta: "[Play the alpha release](https://example.github.io/project/). Provide feedback in [Discord](https://discord.gg/gUKQTFkzQ4)" + * ``` + */ + cta?: string; } /** diff --git a/src/config.ts b/src/config.ts index fadd81203..95921ed48 100644 --- a/src/config.ts +++ b/src/config.ts @@ -205,6 +205,14 @@ export function loadConfig(): WorklogConfig | null { return null; } + // Validate the optional `cta` (call-to-action) field before it reaches + // report consumers. Absent field → no behaviour change. + const ctaError = validateCtaConfig(config); + if (ctaError) { + console.error(ctaError); + return null; + } + // Resolve LLM config section (config → env vars → defaults). config.llm = resolveLlmConfig(config); @@ -361,6 +369,24 @@ export function validateInterviewConfig(config: WorklogConfig): string | null { return null; } +/** + * Validate the optional `cta` (call-to-action) config field. + * + * Returns an error message string when `cta` is present but not a string, or + * `null` when it is absent or valid. A present non-string value (for example a + * nested mapping produced by an unquoted YAML value) is rejected so a + * malformed CTA cannot silently reach report consumers. Absent is valid and + * leaves existing behaviour unchanged. + */ +export function validateCtaConfig(config: WorklogConfig): string | null { + const cta = (config as { cta?: unknown }).cta; + if (cta === undefined || cta === null) return null; + if (typeof cta !== 'string') { + return 'Invalid config: cta must be a string (quote Markdown values in YAML)'; + } + return null; +} + /** * Whether LLM-assisted question extraction is enabled via the * `interview.intelligent: true` config opt-in. @@ -509,6 +535,25 @@ export function resolveLlmConfig(config: WorklogConfig): LlmConfig | undefined { return { baseUrl, model, apiKey, timeoutMs }; } +/** + * Resolve the per-project call-to-action (CTA). + * + * Returns the configured `cta` Markdown string, or `null` when it is absent or + * blank so report consumers can skip rendering without a behaviour change. + * Mirrors the resolver style of {@link resolveLlmConfig}; accepts a possibly + * `null`/`undefined` config so callers that tolerate a missing config can read + * the CTA directly. The `cta` value is read defensively (runtime type guard) + * because config objects may come from unvalidated sources such as + * `loadConfigRelaxed()`. + */ +export function resolveProjectCta( + config: WorklogConfig | null | undefined, +): string | null { + const cta = (config as { cta?: unknown } | null | undefined)?.cta; + if (typeof cta !== 'string' || cta.trim() === '') return null; + return cta; +} + /** * Save configuration to file */ diff --git a/tests/unit/config-cta.test.ts b/tests/unit/config-cta.test.ts new file mode 100644 index 000000000..e1a6ad339 --- /dev/null +++ b/tests/unit/config-cta.test.ts @@ -0,0 +1,156 @@ +/** + * Tests for the optional `cta` (call-to-action) configuration field and the + * `resolveProjectCta` resolver. + * + * The `cta` field is a free-form Markdown string surfaced in release/report + * output. It is optional: an absent value leaves `loadConfig()` unchanged and + * resolves to `null`, so report consumers see no behaviour change. The two-tier + * config system merges `config.defaults.yaml` under `config.yaml`, so a + * `config.yaml` value overrides the team default (WL-0MUWCGF670087GAS). + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import { + getConfigDir, + getConfigDefaultsPath, + getConfigPath, + loadConfig, + resolveProjectCta, +} from '../../src/config.js'; +import type { WorklogConfig } from '../../src/types.js'; +import { createTempDir, cleanupTempDir } from '../test-utils.js'; + +/** Sample Markdown CTA used across the tests. */ +const SAMPLE_CTA = + '[Play the alpha release](https://thewizardscode.github.io/Tableau-Card-Engine/). Provide feedback in [Discord](https://discord.gg/gUKQTFkzQ4)'; + +/** Minimal valid WorklogConfig used for direct resolveProjectCta tests. */ +function baseConfig(overrides: Partial<WorklogConfig> = {}): WorklogConfig { + return { + projectName: 'Test', + prefix: 'TST', + statuses: [ + { value: 'open', label: 'Open' }, + { value: 'completed', label: 'Completed' }, + ], + stages: [ + { value: 'idea', label: 'Idea' }, + { value: 'done', label: 'Done' }, + ], + statusStageCompatibility: { + open: ['idea'], + completed: ['done'], + }, + ...overrides, + }; +} + +describe('CTA configuration', () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(() => { + tempDir = createTempDir(); + originalCwd = process.cwd(); + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(originalCwd); + cleanupTempDir(tempDir); + }); + + /** Write `.worklog/<file>` with the given body. */ + function writeConfigFile(file: string, body: string): void { + const configDir = getConfigDir(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(file === 'config.yaml' ? getConfigPath() : getConfigDefaultsPath(), body, 'utf-8'); + } + + /** A valid config.yaml body without a CTA. */ + function minimalConfigBody(extra: string[] = []): string { + return [ + 'projectName: Test Project', + 'prefix: TST', + ...extra, + ].join('\n'); + } + + describe('loadConfig()', () => { + it('returns the configured cta string', () => { + writeConfigFile('config.yaml', minimalConfigBody([`cta: "${SAMPLE_CTA}"`])); + + const config = loadConfig(); + expect(config).not.toBeNull(); + expect(config?.cta).toBe(SAMPLE_CTA); + }); + + it('leaves cta undefined when absent (no behaviour change)', () => { + writeConfigFile('config.yaml', minimalConfigBody()); + + const config = loadConfig(); + expect(config).not.toBeNull(); + expect(config?.cta).toBeUndefined(); + }); + + it('preserves Markdown link syntax through the YAML round-trip', () => { + writeConfigFile('config.yaml', minimalConfigBody([`cta: "${SAMPLE_CTA}"`])); + + const config = loadConfig(); + expect(config?.cta).toContain('[Play the alpha release]('); + expect(config?.cta).toContain('[Discord](https://discord.gg/gUKQTFkzQ4)'); + }); + + it('merges cta from config.defaults.yaml when config.yaml omits it', () => { + const defaultsCta = '[Play the defaults release](https://example.github.io/default/).'; + writeConfigFile('config.defaults.yaml', minimalConfigBody([`cta: "${defaultsCta}"`])); + writeConfigFile('config.yaml', minimalConfigBody()); + + const config = loadConfig(); + expect(config?.cta).toBe(defaultsCta); + }); + + it('overrides the defaults cta with the config.yaml cta', () => { + writeConfigFile( + 'config.defaults.yaml', + minimalConfigBody(['cta: "[Play the defaults release](https://example.github.io/default/)."']), + ); + writeConfigFile('config.yaml', minimalConfigBody([`cta: "${SAMPLE_CTA}"`])); + + const config = loadConfig(); + expect(config?.cta).toBe(SAMPLE_CTA); + }); + + it('rejects a non-string cta', () => { + writeConfigFile('config.yaml', minimalConfigBody(['cta:', ' play: the alpha'])); + + expect(loadConfig()).toBeNull(); + }); + }); + + describe('resolveProjectCta()', () => { + it('returns the configured cta string', () => { + expect(resolveProjectCta(baseConfig({ cta: SAMPLE_CTA }))).toBe(SAMPLE_CTA); + }); + + it('returns null when the cta is absent', () => { + expect(resolveProjectCta(baseConfig())).toBeNull(); + }); + + it('returns null for a null or undefined config', () => { + expect(resolveProjectCta(null)).toBeNull(); + expect(resolveProjectCta(undefined)).toBeNull(); + }); + + it('returns null for a blank cta', () => { + expect(resolveProjectCta(baseConfig({ cta: '' }))).toBeNull(); + expect(resolveProjectCta(baseConfig({ cta: ' ' }))).toBeNull(); + }); + + it('reflects a merged defaults/override config object', () => { + const merged = { ...baseConfig(), cta: SAMPLE_CTA }; + expect(resolveProjectCta(merged)).toBe(SAMPLE_CTA); + }); + }); +}); From 8983702cb33a2b24ec9d9d24aba5e89d7d09d8bd Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 15:47:02 +0100 Subject: [PATCH 26/27] Bump version to v1.0.19 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7919f1656..6418e884e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "worklog", - "version": "1.0.18", + "version": "1.0.19", "description": "A simple experimental issue tracker for AI agents", "main": "dist/index.js", "type": "module", From 55ca14c6f08514d2d2a6d9947d0f5712844e54bd Mon Sep 17 00:00:00 2001 From: pi <pi@local> Date: Tue, 6 Oct 2026 15:47:17 +0100 Subject: [PATCH 27/27] Update CHANGELOG.md for v1.0.19 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 809b4d43c..69921ab7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v1.0.19 (2026-10-06) +> **Release focus:** This release focuses on reliability and workflow polish across task handling, sessions, and audits, fixing issues like endless retries, lost progress from connection errors, and sync conflicts that dropped completed work. It also improves visibility and speed with priority icons, clearer flag explanations, quick-create options, and more accurate reporting from the latest worklog data. +### Features +- Flagged items now explain why they need review, not just how to clear the flag. (WL-0MUUAMP7M008Z4GK) +- Installing in a worktree no longer breaks your global Pi extension. (WL-0MUSTNEQW003V3KO) +- Refactor and standup now use the latest worklog data, so reports and findings stay accurate. (WL-0MUAD8U24001ZK5K) +- Stops stuck tasks from retrying endlessly and flags them for review. (WL-0MUKYEXMK0033MFK) +- Interview now finds questions even when they're written in plain prose. (WL-0MUH7ACKJ0024VGF) +- Release notes now include a clear link to play the latest build and share feedback. (WL-0MUWCGF670087GAS) +- Spot urgent work at a glance with a new priority icon in the list. (WL-0MTQYTA20009YXBT) +- Active sessions now get priority for freed slots before new work starts. (WL-0MUV0VEFW007ZBB7) +- Create tasks faster with new quick-create options. (WL-0MTCLB50D0026YA7) +### Bug Fixes +- Completed work and comments no longer get lost when multiple sessions sync at once. (WL-0MUV2U9QF002S9J1) +- The finish step now runs your project's full test suite, so pushes are properly validated. (WL-0MUL6LCE00042MB0) +- Downtime work now only starts on items visible in your sprint view, so nothing hidden gets picked up. (WL-0MUNS8X97007C9H9) +- Transient connection errors now retry automatically instead of losing your progress. (WL-0MUQO8AEE003G1Z2) +- Panes no longer close while you're still working or waiting to respond. (WL-0MUMCZAC40058HCC) +- Passed audits no longer appear stale after minor edits, so you won't see false warnings or rerun audits. (WL-0MUN7QWFP0010EQC) +- Audits no longer crash on large codebases, so your results save reliably. (WL-0MSS55LFU00973S2) +- Downtime tabs no longer show an empty pane alongside your dispatch output. (WL-0MU2EOHK900425VU) +- Fixed duplicate error messages when viewing a non-existent wishlist. (WL-0MTGDZVLV0055T4C) +- Background updates now pause while you type, so no more dropped keystrokes. (WL-0MTV67MZU003H7SH) +### Other +- Longer explanations can now be shown without being cut off. (WL-0MUV4B3QZ007THLW) + ## v1.0.18 (2026-10-04) ### Features - Recent dispatches are now grouped by time of day, so you can see when work happened. (WL-0MUMM9NED009TLL3)