Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
@@ -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/<name>.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/<wave_id>/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.
71 changes: 71 additions & 0 deletions docs/wave-pattern-orchestration.md
Original file line number Diff line number Diff line change
@@ -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-<id>.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.
160 changes: 160 additions & 0 deletions handlers/wave_wait_for_signal.ts
Original file line number Diff line number Diff line change
@@ -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<typeof inputSchema>;

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/<wave_id>/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<void>;
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<WaitDeps>): Promise<WaitResult> {
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<WaitResult> {
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;
94 changes: 94 additions & 0 deletions tests/integration/orchestrator-wait-on-flights.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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'));
});
});
Loading
Loading