From 2e28ab2e0a49b105a9335d1443b34dfd381a79ad Mon Sep 17 00:00:00 2001 From: Baker B Date: Wed, 6 May 2026 19:29:07 -0400 Subject: [PATCH] feat(wave): wave_wait_for_signal MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sanctioned idle-wait tool for wave-pattern Orchestrators (and Primes) blocking on filesystem-bus completion artifacts. Polls signal_path every 5s until min_count matches exist or timeout_sec elapses. Accepts literal paths or Bun.Glob patterns. Returns matched paths + elapsed_sec on success, or timed_out + partial_matches on timeout. Replaces ad-hoc Bash(sleep) loops and the agent-anxiety failure mode where idle loops are exited prematurely (pattern_sanctioned_fidget_tool). - handlers/wave_wait_for_signal.ts — handler with __runWithDeps test seam - tests/wave_wait_for_signal.test.ts — schema + matchSignal + loop semantics - tests/integration/orchestrator-wait-on-flights.test.ts — real-fs scenario - docs/tools.md — per-tool reference (seeded) - docs/wave-pattern-orchestration.md — canonical Orchestrator-wait example Implementation uses Bun.Glob (not node:fs) to remain immune to mock.module('fs') leakage from sibling tests (lesson_bun_native_apis.md). Closes #414 --- docs/tools.md | 35 +++ docs/wave-pattern-orchestration.md | 71 +++++ handlers/wave_wait_for_signal.ts | 160 +++++++++++ .../orchestrator-wait-on-flights.test.ts | 94 +++++++ tests/wave_wait_for_signal.test.ts | 259 ++++++++++++++++++ 5 files changed, 619 insertions(+) create mode 100644 docs/tools.md create mode 100644 docs/wave-pattern-orchestration.md create mode 100644 handlers/wave_wait_for_signal.ts create mode 100644 tests/integration/orchestrator-wait-on-flights.test.ts create mode 100644 tests/wave_wait_for_signal.test.ts diff --git a/docs/tools.md b/docs/tools.md new file mode 100644 index 0000000..8c9c519 --- /dev/null +++ b/docs/tools.md @@ -0,0 +1,35 @@ +# MCP Tools + +Tool reference for the `sdlc-server` MCP. The authoritative list is the registered handlers in `handlers/_registry.ts` (auto-generated from `handlers/*.ts` on build/test/validate). This document records prose summaries; for complete schemas use `ListTools` against the running server. + +Tools below are listed in alphabetic order. To add a tool: drop a file in `handlers/.ts`; the next CI run regenerates the registry and exposes it. + +## wave_wait_for_signal + +**Purpose.** Sanctioned idle-wait for wave-pattern Orchestrators (and Primes) that have dispatched work and need to block until filesystem-bus artifacts appear. Replaces ad-hoc polling loops, `Bash(sleep)` invocations, and the agent-anxiety failure mode where idle loops are exited prematurely with non-canonical lines like `"Sleep is still running. Let me wait for the notification."` See `pattern_sanctioned_fidget_tool.md` (cc-workflow memory) for the design rationale. + +**Inputs.** + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `signal_path` | string | (required) | Absolute path or glob pattern (Bun.Glob syntax). Examples: `/wavebus/wave-4a/flights/*.done`, `wavebus//flight-1.done`. | +| `timeout_sec` | number | `1800` | Maximum wall-clock wait, in seconds. | +| `min_count` | number | `1` | Minimum match count required to satisfy the signal. | + +**Behavior.** Polls every 5 seconds. Returns immediately if `min_count` matches already exist (no opening sleep). Glob patterns and literal paths are both accepted; relative patterns scan from `CLAUDE_PROJECT_DIR` (or `cwd()`). Matches are sorted alphabetically. + +**Returns (success).** + +```json +{ "ok": true, "matched": ["...absolute paths..."], "elapsed_sec": 7 } +``` + +**Returns (timeout).** + +```json +{ "ok": true, "timed_out": true, "elapsed_sec": 1800, "partial_matches": ["...subset paths..."] } +``` + +`partial_matches` is the snapshot from the final poll before timeout — empty array if no matches ever appeared. + +**See also.** `docs/wave-pattern-orchestration.md` for the canonical Orchestrator-wait-on-Flights example. diff --git a/docs/wave-pattern-orchestration.md b/docs/wave-pattern-orchestration.md new file mode 100644 index 0000000..2bd1666 --- /dev/null +++ b/docs/wave-pattern-orchestration.md @@ -0,0 +1,71 @@ +# Wave-Pattern Orchestration + +How sdlc-server tools support the wave-pattern execution model (Orchestrator + Prime + Flight, filesystem-bus signaling). + +## Idle-Waiting on Flight Completion: `wave_wait_for_signal` + +When the Orchestrator has dispatched N parallel Flights and must wait for their completion artifacts to appear, it has nothing legitimate to call. Without a sanctioned idle-wait tool, anxious agents invent polling loops, hallucinate completions, or exit prematurely with non-canonical lines like `"Sleep is still running. Let me wait for the notification."` The `wave_wait_for_signal` tool exists so the model has something legitimate to call while it waits. + +### Canonical Example: Orchestrator Waiting on Flights + +The Orchestrator dispatches three Flight sub-agents, each of which writes `flight-.done` to the wave's filesystem bus when finished: + +``` +wavebus/ +└── wave-4a/ + └── flights/ + ├── flight-1.done ← written by Flight 1 on completion + ├── flight-2.done ← written by Flight 2 on completion + └── flight-3.done ← written by Flight 3 on completion +``` + +After dispatch, the Orchestrator calls: + +```jsonc +{ + "tool": "wave_wait_for_signal", + "args": { + "signal_path": "wavebus/wave-4a/flights/*.done", + "timeout_sec": 1800, + "min_count": 3 + } +} +``` + +The tool polls every 5 seconds. As soon as all three artifacts exist, it returns: + +```json +{ + "ok": true, + "matched": [ + "/abs/path/wavebus/wave-4a/flights/flight-1.done", + "/abs/path/wavebus/wave-4a/flights/flight-2.done", + "/abs/path/wavebus/wave-4a/flights/flight-3.done" + ], + "elapsed_sec": 142 +} +``` + +If the timeout expires before all three Flights complete, the response carries `timed_out: true` plus `partial_matches` containing whatever subset did finish. The Orchestrator can then make an informed decision (extend, fail the wave, etc.) instead of guessing. + +### Why This Tool Replaces Inline Polling + +Without `wave_wait_for_signal`, an Orchestrator wanting the same behavior must either: + +1. Call `Bash(sleep ...)` repeatedly with a check between each call — slow, clutters transcripts, and the model frequently exits the loop body early. +2. Use `Bash(while ...)` — large `run:` blocks that violate the project's "no procedural logic in CI/CD YAML" rule when ported and that the model perceives as "I'm not really doing anything" (the anxiety failure mode). +3. Skip waiting entirely and assume Flights are done — produces incorrect verdicts. + +`wave_wait_for_signal` collapses all three into one tool call whose entire purpose is to sit still on the agent's behalf. The tool's existence is the mitigation: the model sees a legitimate thing to call and calls it instead of inventing a polling loop or exiting prematurely. + +### Configuration Tips + +- **`signal_path`.** Use glob patterns (`*.done`, `flight-?.done`) when waiting on N artifacts; use literal paths when waiting on a specific marker file. Relative patterns scan from `CLAUDE_PROJECT_DIR` (or `process.cwd()`). Absolute patterns scan from the filesystem root. +- **`timeout_sec`.** Default 1800 (30 minutes). Should comfortably exceed the longest expected Flight runtime; the Orchestrator can recover from `timed_out` but not from "I gave up too soon." +- **`min_count`.** Set to the number of Flights dispatched. The tool will not return early on a partial set — it sits until either the threshold is met or the timeout fires. + +### Related Tools + +- `wave_flight_done` — written by Flights to mark completion. +- `wave_flight_plan` — written by Prime(pre-flight) to record the dispatch list the Orchestrator is waiting on. +- `wave_complete` — terminal state transition the Orchestrator drives after `wave_wait_for_signal` returns successfully. diff --git a/handlers/wave_wait_for_signal.ts b/handlers/wave_wait_for_signal.ts new file mode 100644 index 0000000..413669f --- /dev/null +++ b/handlers/wave_wait_for_signal.ts @@ -0,0 +1,160 @@ +// Sanctioned anxiety outlet for idle agents (issue #414). +// +// When a wave-pattern Orchestrator (or Prime) has dispatched work and is +// supposed to wait for filesystem-bus artifacts to appear, it has nothing +// to call. Idle loops without a sanctioned tool drive anxious agents to +// invent polling, hallucinate completions, or exit prematurely. This tool +// exists so the model has something legitimate to call while it waits. +// +// Behavior: poll `signal_path` (literal path or glob) every 5s until at +// least `min_count` matches exist, or `timeout_sec` elapses. On match, +// return `{ matched: [...paths], elapsed_sec: N }`. On timeout, return +// `{ timed_out: true, elapsed_sec: timeout_sec, partial_matches: [...] }`. + +import { Glob } from 'bun'; +import { z } from 'zod'; +import type { HandlerDef } from '../types.js'; + +export const POLL_INTERVAL_SEC = 5; + +const inputSchema = z + .object({ + signal_path: z.string().min(1, 'signal_path must be a non-empty string'), + timeout_sec: z.number().int().positive().optional().default(1800), + min_count: z.number().int().positive().optional().default(1), + }) + .strict(); + +type Input = z.infer; + +function envelope(payload: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(payload) }] }; +} + +function projectDir(): string { + return process.env.CLAUDE_PROJECT_DIR ?? process.cwd(); +} + +/** + * Resolve `signal_path` to a list of currently-matching filesystem paths. + * + * Two modes: + * - If the path contains a glob metacharacter (`*`, `?`, `[`), use Bun.Glob + * against the project root and return matching files. + * - Otherwise treat as a literal path; return [path] if it exists, else []. + * + * Glob patterns are rooted at projectDir() so that callers can pass relative + * patterns like `wavebus//flights/*.done`. + */ +export function matchSignal(signalPath: string, cwd: string = projectDir()): string[] { + // We use Bun.Glob exclusively (not node:fs) because other tests in this + // codebase call `mock.module('fs', ...)` and the mock leaks across the + // shared Bun test-runner module space, leaving `existsSync` etc. + // undefined for this handler. Bun.Glob handles both literal paths + // (no metacharacters → matches iff the file exists) and patterns + // uniformly. + const isAbsolute = signalPath.startsWith('/'); + const scanCwd = isAbsolute ? '/' : cwd; + const pattern = isAbsolute ? signalPath.slice(1) : signalPath; + const results: string[] = []; + for (const match of new Glob(pattern).scanSync({ + cwd: scanCwd, + absolute: true, + onlyFiles: false, + })) { + results.push(match); + } + return results.sort(); +} + +export interface WaitDeps { + matchFn: (signalPath: string) => string[]; + sleepFn: (ms: number) => Promise; + nowFn: () => number; +} + +export interface WaitResult { + ok: true; + matched?: string[]; + elapsed_sec?: number; + timed_out?: true; + partial_matches?: string[]; +} + +/** + * Test seam — drives the polling loop with injected dependencies so unit + * tests can avoid real wall-clock waits and real filesystem state. + */ +export async function __runWithDeps(rawArgs: unknown, deps: Partial): Promise { + const args = inputSchema.parse(rawArgs) as Input; + const fullDeps: WaitDeps = { + matchFn: deps.matchFn ?? ((p) => matchSignal(p)), + sleepFn: deps.sleepFn ?? ((ms) => new Promise((r) => setTimeout(r, ms))), + nowFn: deps.nowFn ?? (() => Date.now()), + }; + return runWaitLoop(args, fullDeps); +} + +async function runWaitLoop(args: Input, deps: WaitDeps): Promise { + const startMs = deps.nowFn(); + const timeoutMs = args.timeout_sec * 1000; + const intervalMs = POLL_INTERVAL_SEC * 1000; + + // Check immediately so callers whose artifacts already exist return + // without an opening 5s sleep. + let matches = deps.matchFn(args.signal_path); + if (matches.length >= args.min_count) { + return { + ok: true, + matched: matches, + elapsed_sec: Math.round((deps.nowFn() - startMs) / 1000), + }; + } + + while (deps.nowFn() - startMs < timeoutMs) { + await deps.sleepFn(intervalMs); + matches = deps.matchFn(args.signal_path); + if (matches.length >= args.min_count) { + return { + ok: true, + matched: matches, + elapsed_sec: Math.round((deps.nowFn() - startMs) / 1000), + }; + } + } + + return { + ok: true, + timed_out: true, + elapsed_sec: args.timeout_sec, + partial_matches: matches, + }; +} + +const waveWaitForSignalHandler: HandlerDef = { + name: 'wave_wait_for_signal', + description: + 'Block until min_count filesystem artifacts matching signal_path exist, or timeout_sec elapses. Sanctioned idle-wait for wave-pattern Orchestrators waiting on Flight completion artifacts.', + inputSchema, + async execute(rawArgs: unknown) { + let args: Input; + try { + args = inputSchema.parse(rawArgs) as Input; + } catch (err) { + return envelope({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + + try { + const result = await runWaitLoop(args, { + matchFn: (p) => matchSignal(p), + sleepFn: (ms) => new Promise((r) => setTimeout(r, ms)), + nowFn: () => Date.now(), + }); + return envelope(result); + } catch (err) { + return envelope({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + }, +}; + +export default waveWaitForSignalHandler; diff --git a/tests/integration/orchestrator-wait-on-flights.test.ts b/tests/integration/orchestrator-wait-on-flights.test.ts new file mode 100644 index 0000000..2131434 --- /dev/null +++ b/tests/integration/orchestrator-wait-on-flights.test.ts @@ -0,0 +1,94 @@ +/** + * Integration test for wave_wait_for_signal — the canonical Orchestrator + * scenario where the tool replaces an inline polling loop. + * + * Scenario: an Orchestrator dispatches three Flights, each writing a + * `flight-N.done` artifact to a shared wavebus directory. The Orchestrator + * calls wave_wait_for_signal with `min_count: 3` and waits. We verify the + * tool returns once all three artifacts exist, with `matched` populated and + * no `timed_out` flag. + * + * This is a real-filesystem test (no fs mocks). It uses Bun-native APIs + * (Bun.write, Bun.spawnSync) instead of node:fs to stay immune to the + * `mock.module('fs')` leakage from sibling test files. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { join } from 'path'; + +import handler from '../../handlers/wave_wait_for_signal.ts'; + +function parseResult(result: { content: Array<{ type: string; text: string }> }) { + return JSON.parse(result.content[0].text); +} + +async function makeTmpDir(prefix: string): Promise { + const path = `/tmp/${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + await Bun.write(`${path}/.tmp-marker`, ''); + return path; +} + +function cleanupTmpDir(path: string): void { + Bun.spawnSync(['rm', '-rf', path]); +} + +describe('orchestrator-wait-on-flights integration', () => { + let waveDir: string; + let flightsDir: string; + + beforeEach(async () => { + waveDir = await makeTmpDir('orchestrator-wait'); + flightsDir = join(waveDir, 'flights'); + await Bun.write(`${flightsDir}/.keep`, ''); + }); + + afterEach(() => { + cleanupTmpDir(waveDir); + }); + + test('Orchestrator waits on Flight artifacts and returns matched paths', async () => { + // Pre-stage all three artifacts so the tool returns on the first + // immediate check (no real wall-clock 5s sleep). The polling-loop + // semantics are exercised by the unit tests with mocked sleep. + await Bun.write(join(flightsDir, 'flight-1.done'), ''); + await Bun.write(join(flightsDir, 'flight-2.done'), ''); + await Bun.write(join(flightsDir, 'flight-3.done'), ''); + + const pattern = join(flightsDir, '*.done'); + const result = await handler.execute({ + signal_path: pattern, + timeout_sec: 10, + min_count: 3, + }); + const parsed = parseResult(result); + + expect(parsed.ok).toBe(true); + expect(parsed.timed_out).toBeUndefined(); + expect(parsed.matched).toBeDefined(); + expect(parsed.matched).toHaveLength(3); + expect(parsed.matched.sort()).toEqual([ + join(flightsDir, 'flight-1.done'), + join(flightsDir, 'flight-2.done'), + join(flightsDir, 'flight-3.done'), + ]); + // elapsed_sec ≤ 1 because all artifacts existed at call time. + expect(parsed.elapsed_sec).toBeLessThanOrEqual(1); + }); + + test('partial pre-stage: caller can detect under-min via matched.length', async () => { + // Real-filesystem variant: the immediate check returns < min_count, so + // the loop would sleep. We verify the immediate-check classification + // against a real glob without paying for the real 5s sleep by calling + // the matchSignal helper directly here. End-to-end timeout/partial_matches + // semantics are covered by the unit test suite with a mocked clock; this + // test asserts the glob plumbing (real Bun.Glob against real files) is + // wired correctly. + const { matchSignal } = await import('../../handlers/wave_wait_for_signal.ts'); + await Bun.write(join(flightsDir, 'flight-1.done'), ''); + + const pattern = join(flightsDir, '*.done'); + const matches = matchSignal(pattern); + expect(matches).toHaveLength(1); + expect(matches[0]).toBe(join(flightsDir, 'flight-1.done')); + }); +}); diff --git a/tests/wave_wait_for_signal.test.ts b/tests/wave_wait_for_signal.test.ts new file mode 100644 index 0000000..ee145b0 --- /dev/null +++ b/tests/wave_wait_for_signal.test.ts @@ -0,0 +1,259 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { join } from 'path'; + +import handler, { + __runWithDeps, + matchSignal, + POLL_INTERVAL_SEC, +} from '../handlers/wave_wait_for_signal.ts'; + +// File operations use Bun.write/Bun.spawnSync('rm') instead of node:fs to +// avoid leakage from `mock.module('fs', ...)` calls in sibling test files +// (lesson_bun_native_apis.md). +async function makeTmpDir(prefix: string): Promise { + const path = `/tmp/${prefix}-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; + // Bun.write creates parent directories on demand; make a marker then + // delete it, leaving the directory in place. + await Bun.write(`${path}/.tmp-marker`, ''); + return path; +} + +async function cleanupTmpDir(path: string): Promise { + Bun.spawnSync(['rm', '-rf', path]); +} + +function parseResult(result: { content: Array<{ type: string; text: string }> }) { + return JSON.parse(result.content[0].text); +} + +describe('wave_wait_for_signal handler', () => { + test('handler exports valid HandlerDef shape', () => { + expect(handler.name).toBe('wave_wait_for_signal'); + expect(typeof handler.execute).toBe('function'); + expect(handler.description).toContain('signal_path'); + }); + + describe('schema validation', () => { + test('rejects missing signal_path', async () => { + const result = await handler.execute({}); + const parsed = parseResult(result); + expect(parsed.ok).toBe(false); + }); + + test('rejects empty signal_path', async () => { + const result = await handler.execute({ signal_path: '' }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(false); + }); + + test('rejects non-positive timeout_sec', async () => { + const result = await handler.execute({ signal_path: 'x', timeout_sec: 0 }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(false); + }); + + test('rejects non-positive min_count', async () => { + const result = await handler.execute({ signal_path: 'x', min_count: 0 }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(false); + }); + + test('applies defaults: timeout_sec=1800, min_count=1', async () => { + // Uses __runWithDeps so we don't hit the real 1800s timeout. We + // verify defaults indirectly: the loop returns immediately because + // matchFn returns one path, and min_count default is 1. + const result = await __runWithDeps( + { signal_path: 'wavebus/x.done' }, + { + matchFn: () => ['/tmp/x.done'], + sleepFn: async () => { + throw new Error('should not sleep on immediate match'); + }, + nowFn: () => 0, + }, + ); + expect(result.ok).toBe(true); + expect(result.matched).toEqual(['/tmp/x.done']); + }); + }); + + describe('matchSignal — real filesystem', () => { + let tmpDir: string; + beforeEach(async () => { + tmpDir = await makeTmpDir('wave-wait-test'); + }); + afterEach(async () => { + await cleanupTmpDir(tmpDir); + }); + + test('literal path: returns [path] when file exists, [] otherwise', async () => { + const filePath = join(tmpDir, 'flight-1.done'); + expect(matchSignal(filePath)).toEqual([]); + await Bun.write(filePath, ''); + expect(matchSignal(filePath)).toEqual([filePath]); + }); + + test('glob pattern: returns sorted absolute paths of matching files', async () => { + await Bun.write(join(tmpDir, 'flights', 'flight-2.done'), ''); + await Bun.write(join(tmpDir, 'flights', 'flight-1.done'), ''); + await Bun.write(join(tmpDir, 'flights', 'flight-3.pending'), ''); + + const matches = matchSignal('flights/*.done', tmpDir); + expect(matches).toHaveLength(2); + expect(matches[0]).toContain('flight-1.done'); + expect(matches[1]).toContain('flight-2.done'); + // sorted + expect(matches[0] < matches[1]).toBe(true); + }); + + test('glob pattern: returns [] when no matches', () => { + const matches = matchSignal('nothing/*.done', tmpDir); + expect(matches).toEqual([]); + }); + }); + + describe('polling loop (via __runWithDeps)', () => { + test('test_wave_wait_for_signal_matches_immediately', async () => { + let sleeps = 0; + const result = await __runWithDeps( + { signal_path: 'foo.done', timeout_sec: 100, min_count: 1 }, + { + matchFn: () => ['/abs/foo.done'], + sleepFn: async () => { + sleeps += 1; + }, + nowFn: () => 0, + }, + ); + expect(result.ok).toBe(true); + expect(result.matched).toEqual(['/abs/foo.done']); + expect(result.elapsed_sec).toBe(0); + expect(result.timed_out).toBeUndefined(); + expect(sleeps).toBe(0); // never slept — matched on first check + }); + + test('test_wave_wait_for_signal_polls_until_match', async () => { + // Simulate: first 2 checks return 0 matches, third returns 2 matches + // (>= min_count of 2). Verify it sleeps twice and returns matched. + const matchSequence = [[], [], ['/a/1.done', '/a/2.done']]; + let checkCount = 0; + const sleepDurationsMs: number[] = []; + let virtualNow = 0; + + const result = await __runWithDeps( + { signal_path: 'a/*.done', timeout_sec: 100, min_count: 2 }, + { + matchFn: () => matchSequence[checkCount++] ?? [], + sleepFn: async (ms) => { + sleepDurationsMs.push(ms); + virtualNow += ms; + }, + nowFn: () => virtualNow, + }, + ); + + expect(result.ok).toBe(true); + expect(result.matched).toEqual(['/a/1.done', '/a/2.done']); + expect(result.timed_out).toBeUndefined(); + expect(sleepDurationsMs).toEqual([ + POLL_INTERVAL_SEC * 1000, + POLL_INTERVAL_SEC * 1000, + ]); + expect(checkCount).toBe(3); + expect(result.elapsed_sec).toBe(POLL_INTERVAL_SEC * 2); + }); + + test('test_wave_wait_for_signal_times_out', async () => { + // No matches ever. Verify timed_out=true, elapsed_sec === timeout_sec, + // partial_matches is empty. + let virtualNow = 0; + const result = await __runWithDeps( + { signal_path: 'never.done', timeout_sec: 30, min_count: 1 }, + { + matchFn: () => [], + sleepFn: async (ms) => { + virtualNow += ms; + }, + nowFn: () => virtualNow, + }, + ); + + expect(result.ok).toBe(true); + expect(result.timed_out).toBe(true); + expect(result.elapsed_sec).toBe(30); + expect(result.partial_matches).toEqual([]); + expect(result.matched).toBeUndefined(); + }); + + test('test_wave_wait_for_signal_partial_matches', async () => { + // min_count=3, but only 2 files ever appear before timeout. + // partial_matches should contain those 2 paths. + let virtualNow = 0; + let checkCount = 0; + const matchSequence: string[][] = [ + [], + ['/a/1.done'], + ['/a/1.done', '/a/2.done'], + ['/a/1.done', '/a/2.done'], + ['/a/1.done', '/a/2.done'], + ]; + const result = await __runWithDeps( + { signal_path: 'a/*.done', timeout_sec: 20, min_count: 3 }, + { + matchFn: () => matchSequence[Math.min(checkCount++, matchSequence.length - 1)], + sleepFn: async (ms) => { + virtualNow += ms; + }, + nowFn: () => virtualNow, + }, + ); + + expect(result.ok).toBe(true); + expect(result.timed_out).toBe(true); + expect(result.partial_matches).toEqual(['/a/1.done', '/a/2.done']); + expect(result.elapsed_sec).toBe(20); + }); + + test('does NOT return early when match count is below min_count', async () => { + // matchFn returns 2 matches, but min_count=5. Must wait for timeout. + let virtualNow = 0; + const result = await __runWithDeps( + { signal_path: 'a/*.done', timeout_sec: 25, min_count: 5 }, + { + matchFn: () => ['/a/1.done', '/a/2.done'], + sleepFn: async (ms) => { + virtualNow += ms; + }, + nowFn: () => virtualNow, + }, + ); + expect(result.timed_out).toBe(true); + expect(result.partial_matches).toEqual(['/a/1.done', '/a/2.done']); + }); + }); + + describe('execute (full handler entry point)', () => { + let tmpDir: string; + beforeEach(async () => { + tmpDir = await makeTmpDir('wave-wait-exec'); + }); + afterEach(async () => { + await cleanupTmpDir(tmpDir); + }); + + test('immediate match against real filesystem returns ok:true with matched paths', async () => { + const sigPath = join(tmpDir, 'ready.done'); + await Bun.write(sigPath, ''); + + const result = await handler.execute({ + signal_path: sigPath, + timeout_sec: 10, + min_count: 1, + }); + const parsed = parseResult(result); + expect(parsed.ok).toBe(true); + expect(parsed.matched).toEqual([sigPath]); + expect(parsed.timed_out).toBeUndefined(); + }); + }); +});