mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
chore: version packages
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -133,6 +133,25 @@ describe('refreshStaleCodemanHooks', () => {
|
||||
expect(after.hooks.CustomEvent).toEqual(customEvent);
|
||||
});
|
||||
|
||||
// A case can be current on the secret AND the background-wake hook and still carry
|
||||
// the `-k`-less curl shape, which exits 60 against a self-signed HTTPS API and is
|
||||
// swallowed by `|| true` — every hook event dead, silently. The refresh must treat
|
||||
// that as a third stale shape.
|
||||
it('heals a current-looking block whose hook curls lack -k (HTTPS self-signed installs)', async () => {
|
||||
const { generateHooksConfig } = await import('../src/hooks-config.js');
|
||||
const flagless = JSON.parse(JSON.stringify(generateHooksConfig()).replaceAll('curl -sk ', 'curl -s '));
|
||||
writeFileSync(settingsPath, JSON.stringify({ hooks: flagless.hooks }, null, 2));
|
||||
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
|
||||
const after = readFileSync(settingsPath, 'utf-8');
|
||||
expect(after).toContain('curl -sk -X POST');
|
||||
expect(after).not.toContain('curl -s -X POST');
|
||||
// and the pass is convergent: a second refresh must not rewrite
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(after);
|
||||
});
|
||||
|
||||
it('is a no-op when settings.local.json is absent (does not create one)', async () => {
|
||||
await refreshStaleCodemanHooks(dir);
|
||||
expect(existsSync(settingsPath)).toBe(false);
|
||||
|
||||
@@ -95,6 +95,17 @@ describe('generateHooksConfig', () => {
|
||||
expect(notifHooks[0].hooks[0].command).toContain('|| true');
|
||||
});
|
||||
|
||||
// On --https/tailscale installs CODEMAN_API_URL is HTTPS with a self-signed cert.
|
||||
// A `-k`-less hook curl exits 60 there, the `|| true` swallows it, and every hook
|
||||
// event (stop, permission_prompt, elicitation_dialog, idle_prompt, teammate_idle,
|
||||
// task_completed) dies silently — killing respawn's idle signals and the wait
|
||||
// endpoints' stop/blocked. The statusline exporter always carried -k; the hooks must too.
|
||||
it('every hook curl tolerates a self-signed HTTPS API (curl -sk)', () => {
|
||||
const serialized = JSON.stringify(generateHooksConfig());
|
||||
expect(serialized).toContain('curl -sk -X POST');
|
||||
expect(serialized).not.toContain('curl -s -X POST');
|
||||
});
|
||||
|
||||
it('should set timeout to 10 seconds (hook timeout fields are seconds)', () => {
|
||||
const config = generateHooksConfig();
|
||||
const notifHooks = config.hooks.Notification as Array<{ hooks: Array<{ timeout: number }> }>;
|
||||
|
||||
@@ -89,4 +89,97 @@ describe('Stable HTTP contract (live server)', () => {
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
/**
|
||||
* The agent wait primitives, through the REAL pipeline.
|
||||
*
|
||||
* Their own route tests hand-roll a partial copy of the preSerialization hook that
|
||||
* maps errorCode to status but does NOT wrap bare payloads — so nothing there
|
||||
* proves these routes emit a correct envelope, a correct status, or work through
|
||||
* the /api/v1 alias, and one assertion in them pins `{}` for a response no client
|
||||
* will ever receive. This is the file whose docstring already claims that scope.
|
||||
*/
|
||||
describe('agent wait primitives', () => {
|
||||
let sessionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await fetch(`${base}/api/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
sessionId = (await res.json()).data.session.id;
|
||||
expect(sessionId).toBeDefined();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fetch(`${base}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('answers a wait timeout as a 200 inside the envelope, on the /api/v1 alias', async () => {
|
||||
// A timeout is the long-poll SUCCEEDING at "did this happen within N ms?"; a
|
||||
// 4xx/5xx here would make every poll boundary indistinguishable from a failure.
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?until=working&timeout=1000`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('cache-control')).toBe('no-store');
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.sessionId).toBe(sessionId);
|
||||
// The one shape all three wait endpoints share.
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
expect(body.data.wait.signal).toBeNull();
|
||||
expect(body.data.wait.timeoutMs).toBe(1000);
|
||||
expect(body.data.wait.until).toEqual(['working']);
|
||||
});
|
||||
|
||||
it('returns a contract-shaped 400 for an unknown until token', async () => {
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?until=stpo`);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
expect(body.error).toContain('stpo');
|
||||
});
|
||||
|
||||
it('returns a contract-shaped 400 naming the bad query parameter', async () => {
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait?timeout=30s`);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
expect(body.error).toContain('timeout');
|
||||
});
|
||||
|
||||
it('wraps the non-wait input response as { success: true, data: {} }', async () => {
|
||||
// What a client actually receives on the fire-and-forget path — NOT the bare
|
||||
// `{}` the handler returns and the route tests assert.
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: 'hello' }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({ success: true, data: {} });
|
||||
});
|
||||
|
||||
it('serves wait-output through the same envelope', async () => {
|
||||
const res = await fetch(`${base}/api/v1/sessions/${sessionId}/wait-output?match=NEVER_APPEARS&timeout=1000`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.wait.matched).toBe(false);
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
expect(body.data.wait.match).toBe('NEVER_APPEARS');
|
||||
});
|
||||
|
||||
it('404s an unknown session on both new routes, with the error envelope', async () => {
|
||||
for (const path of ['wait?until=idle', 'wait-output?match=x']) {
|
||||
const res = await fetch(`${base}/api/v1/sessions/nonexistent/${path}`);
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('NOT_FOUND');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { vi } from 'vitest';
|
||||
import type { SessionStatus } from '../../src/types.js';
|
||||
|
||||
/**
|
||||
* Enhanced mock session for testing RespawnController.
|
||||
@@ -12,8 +13,15 @@ import { vi } from 'vitest';
|
||||
export class MockSession extends EventEmitter {
|
||||
id: string;
|
||||
workingDir: string = '/tmp/test-workdir';
|
||||
status: 'idle' | 'working' = 'idle';
|
||||
pid: number = 12345;
|
||||
/**
|
||||
* The REAL union, deliberately. This used to be `'idle' | 'working'`, and
|
||||
* `'working'` is not a `SessionStatus` at all — so `signalForStatus()` fell to its
|
||||
* `default: null` branch in every route test and the busy / stopped / error halves
|
||||
* of the immediate-resolve mapping had zero coverage while appearing to be tested.
|
||||
*/
|
||||
status: SessionStatus = 'idle';
|
||||
/** `null` once the PTY is gone (or before it has ever started) — see `pid` in Session. */
|
||||
pid: number | null = 12345;
|
||||
isWorking: boolean = false;
|
||||
private _activeChildProcesses: { pid: number; command: string }[] = [];
|
||||
ralphTracker: null = null;
|
||||
@@ -113,7 +121,9 @@ export class MockSession extends EventEmitter {
|
||||
/** Simulate working state with spinner */
|
||||
simulateWorking(text: string = 'Thinking'): void {
|
||||
this.simulateTerminalOutput(`${text}... \u280b`);
|
||||
this.status = 'working';
|
||||
// 'busy' is what the real Session sets while a turn is in flight; the old
|
||||
// 'working' here was the event name, not a status value.
|
||||
this.status = 'busy';
|
||||
this.emit('working');
|
||||
}
|
||||
|
||||
@@ -182,6 +192,14 @@ export class MockSession extends EventEmitter {
|
||||
return this._muxName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors `Session.usesMux`. True by default because that is the normal
|
||||
* configuration, and it is what makes a route's pane-liveness probe reachable:
|
||||
* `session.pid` is the tmux ATTACH CLIENT, so a mux-backed session's worker can be
|
||||
* dead while `pid` is still a live number.
|
||||
*/
|
||||
usesMux: boolean = true;
|
||||
|
||||
/** Check for active child processes (mock returns configurable list) */
|
||||
getActiveChildProcesses(): { pid: number; command: string }[] {
|
||||
return this._activeChildProcesses;
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* @fileoverview Route tests for the `wait` field on `POST /api/sessions/:id/input`.
|
||||
*
|
||||
* This endpoint exists to close a race a caller cannot close from outside: between
|
||||
* the write landing and the session flipping to `working`, a SEPARATE wait sees the
|
||||
* session still idle and returns instantly, reporting the previous turn as this one.
|
||||
* Registering the waiter before the write is the whole point, so that is what the
|
||||
* first test pins.
|
||||
*
|
||||
* It also pins that the historical fire-and-forget path is untouched when `wait` is
|
||||
* absent, that a capacity rejection gives the dedup seq back (otherwise the caller's
|
||||
* retry is refused as a duplicate and the input is lost by the very mechanism
|
||||
* reliable delivery exists for), and that `delivered` reports what actually happened
|
||||
* to the write rather than merely "this was not a duplicate".
|
||||
*
|
||||
* Plan: docs/agent-control-plan.md
|
||||
*/
|
||||
import { describe, it, expect, afterEach, beforeAll, afterAll, vi } from 'vitest';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { sessionWaits } from '../../src/web/session-wait-registry.js';
|
||||
import { MAX_WAIT_MS } from '../../src/config/agent-wait.js';
|
||||
|
||||
// Distinct per file on purpose: the three wait suites share the process-wide
|
||||
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
|
||||
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
|
||||
const SESSION_ID = 'input-wait-session';
|
||||
const URL = `/api/sessions/${SESSION_ID}/input`;
|
||||
|
||||
afterEach(() => {
|
||||
// Deliberately not `cancelEverything()`: it latches the registry's stopped flag,
|
||||
// which would leave every later test in this file talking to a dead registry.
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
_resetPaneLivenessState();
|
||||
});
|
||||
|
||||
async function harness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext; rawRequests: IncomingMessage[] }> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
const rawRequests: IncomingMessage[] = [];
|
||||
app.addHook('onRequest', async (req) => {
|
||||
rawRequests.push(req.raw);
|
||||
});
|
||||
|
||||
registerSessionRoutes(app, ctx as never);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
const p = payload as { success?: unknown; errorCode?: unknown } | null;
|
||||
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
|
||||
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, rawRequests };
|
||||
}
|
||||
|
||||
const send = (app: FastifyInstance, payload: Record<string, unknown>) =>
|
||||
app.inject({ method: 'POST', url: URL, payload });
|
||||
|
||||
describe('POST /api/sessions/:id/input without wait (unchanged behavior)', () => {
|
||||
it('returns the historical bare body and registers no waiter', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await send(app, { input: 'hello', useMux: true });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({});
|
||||
expect(sessionWaits.totalWaiterCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('still returns before the mux write settles', async () => {
|
||||
// The fire-and-forget property is why the response is fast; send-and-wait must
|
||||
// not have turned every input into an awaited tmux round-trip.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
let resolveWrite: (ok: boolean) => void = () => {};
|
||||
session.writeViaMux = () => new Promise<boolean>((resolve) => (resolveWrite = resolve));
|
||||
|
||||
const res = await send(app, { input: 'hello', useMux: true });
|
||||
expect(res.json()).toEqual({});
|
||||
resolveWrite(true);
|
||||
});
|
||||
|
||||
it('a tagged duplicate still returns the bare body', async () => {
|
||||
const { app } = await harness();
|
||||
await send(app, { input: 'first', clientId: 'c1', seq: 1 });
|
||||
const replay = await send(app, { input: 'first', clientId: 'c1', seq: 1 });
|
||||
|
||||
expect(replay.json()).toEqual({});
|
||||
expect(sessionWaits.totalWaiterCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('a failed direct write is still not an error response', async () => {
|
||||
// A session can legitimately have no PTY yet, and callers have always been able
|
||||
// to write to one without a 4xx. Only the `wait` path reports delivery.
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.failWrites = true;
|
||||
|
||||
const res = await send(app, { input: 'x' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/input with wait', () => {
|
||||
it('registers the waiter BEFORE the write, so the pre-existing idle state cannot satisfy it', async () => {
|
||||
// The mock session is idle. A naive send-then-wait would answer immediately with
|
||||
// that stale idle; this must block until a real transition.
|
||||
const { app } = await harness();
|
||||
const pending = send(app, { input: 'run the tests', useMux: true, wait: true });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
const body = (await pending).json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.delivered).toBe(true);
|
||||
expect(body.data.duplicate).toBe(false);
|
||||
expect(body.data.wait.signal).toBe('stop');
|
||||
expect(body.data.wait.immediate).toBe(false);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the same data.wait envelope as the two GET endpoints', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = send(app, { input: 'x', wait: 'stop' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
const { data } = (await pending).json();
|
||||
|
||||
expect(Object.keys(data).sort()).toEqual(['delivered', 'duplicate', 'limitPaused', 'status', 'wait']);
|
||||
expect(data.status).toBe('idle');
|
||||
expect(data.limitPaused).toBe(false);
|
||||
expect(data.wait.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('echoes the effective timeout after clamping', async () => {
|
||||
// The schema accepts up to 24h; the server caps at MAX_WAIT_MS. Without the echo
|
||||
// an agent reads the cap as "my 24h wait elapsed" and kills a healthy worker.
|
||||
const { app } = await harness();
|
||||
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 86_400_000 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
|
||||
expect((await pending).json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
|
||||
});
|
||||
|
||||
it('delivers the input before blocking', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
const pending = send(app, { input: 'echo hi', useMux: true, wait: 'stop' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
// The write happened while the request is still open.
|
||||
expect(session.writeBuffer.join('')).toContain('echo hi');
|
||||
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
await pending;
|
||||
});
|
||||
|
||||
it('wait: true uses the default signal set', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = send(app, { input: 'x', wait: true });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifySignal(SESSION_ID, 'idle');
|
||||
|
||||
expect((await pending).json().data.wait.until).toEqual(['stop', 'idle', 'exit']);
|
||||
});
|
||||
|
||||
it('accepts an explicit signal list', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = send(app, { input: 'x', wait: 'exit' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
// Not one of the requested signals: the wait must not resolve on it.
|
||||
expect(sessionWaits.notifySignal(SESSION_ID, 'idle')).toBe(0);
|
||||
sessionWaits.notifySignal(SESSION_ID, 'exit');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.signal).toBe('exit');
|
||||
expect(body.data.wait.until).toEqual(['exit']);
|
||||
});
|
||||
|
||||
it('accepts an array, the same grammar the query parameter takes', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = send(app, { input: 'x', wait: ['stop', 'exit'] });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifySignal(SESSION_ID, 'exit');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.until).toEqual(['stop', 'exit']);
|
||||
expect(body.data.wait.signal).toBe('exit');
|
||||
});
|
||||
|
||||
it('rejects an unknown wait signal without writing', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
const before = session.writeBuffer.length;
|
||||
|
||||
const res = await send(app, { input: 'x', wait: 'stpo' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
expect(session.writeBuffer.length).toBe(before);
|
||||
});
|
||||
|
||||
it('rejects a hook-only signal for external CLI modes', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
|
||||
|
||||
const res = await send(app, { input: 'x', wait: 'stop' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('codex');
|
||||
});
|
||||
|
||||
it('rejects a hook-only signal for a shell session too', async () => {
|
||||
// Not an external CLI, but a plain bash PTY installs no hooks either, so `stop`
|
||||
// could only ever time out.
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
|
||||
|
||||
const res = await send(app, { input: 'x', wait: 'stop' });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('shell');
|
||||
});
|
||||
|
||||
it('times out as a 200, like the standalone wait', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await send(app, { input: 'x', wait: 'blocked', waitTimeout: 1 });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.data.delivered).toBe(true);
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
expect(body.data.wait.signal).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves with ended when the session goes away mid-wait', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = send(app, { input: 'x', wait: 'stop' });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
|
||||
expect((await pending).json().data.wait.ended).toBe(true);
|
||||
});
|
||||
|
||||
it('a request-body close does NOT abort the wait', async () => {
|
||||
// The regression this pins: on a POST the request stream closes as soon as the
|
||||
// body has been read, well before the handler blocks. Treating that as a hang-up
|
||||
// aborted every send-and-wait instantly. Hang-up handling itself is proven over
|
||||
// real HTTP at the bottom of this file, because inject cannot produce a socket.
|
||||
const { app, rawRequests } = await harness();
|
||||
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 600_000 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
rawRequests[rawRequests.length - 1].emit('close');
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.aborted).toBe(false);
|
||||
expect(body.data.wait.signal).toBe('stop');
|
||||
});
|
||||
|
||||
it('a duplicate skips the write but answers from current state instead of hanging', async () => {
|
||||
// The original turn is long over, so requiring a fresh transition here would
|
||||
// block a redelivery until timeout for no reason.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
await send(app, { input: 'first', clientId: 'c1', seq: 1 });
|
||||
const before = session.writeBuffer.length;
|
||||
|
||||
const replay = await send(app, { input: 'first', clientId: 'c1', seq: 1, wait: 'idle' });
|
||||
const body = replay.json();
|
||||
|
||||
expect(body.data.duplicate).toBe(true);
|
||||
expect(body.data.delivered).toBe(false);
|
||||
expect(body.data.wait.immediate).toBe(true);
|
||||
expect(body.data.wait.signal).toBe('idle');
|
||||
expect(session.writeBuffer.length).toBe(before);
|
||||
});
|
||||
|
||||
it('a duplicate on a BUSY session answers working, not idle', async () => {
|
||||
// Previously unreachable: MockSession's status was 'working', which is not a
|
||||
// SessionStatus, so signalForStatus fell through to null and this combination
|
||||
// silently proved nothing.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
await send(app, { input: 'first', clientId: 'c2', seq: 1 });
|
||||
session.status = 'busy';
|
||||
|
||||
const replay = await send(app, { input: 'first', clientId: 'c2', seq: 1, wait: 'working' });
|
||||
const body = replay.json();
|
||||
|
||||
expect(body.data.duplicate).toBe(true);
|
||||
expect(body.data.wait.signal).toBe('working');
|
||||
expect(body.data.wait.immediate).toBe(true);
|
||||
expect(body.data.status).toBe('busy');
|
||||
});
|
||||
|
||||
it('gives the dedup seq back when a full waiter pool rejects the request', async () => {
|
||||
// Otherwise the caller's retry is refused as a duplicate and the input vanishes.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
const pendings = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
|
||||
|
||||
const rejected = await send(app, { input: 'x', clientId: 'c9', seq: 7, wait: true });
|
||||
expect(rejected.statusCode).toBe(409);
|
||||
expect(rejected.json().errorCode).toBe('SESSION_BUSY');
|
||||
// Nothing written...
|
||||
expect(session.writeBuffer.join('')).not.toContain('x');
|
||||
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
await Promise.all(pendings);
|
||||
|
||||
// ...and the same seq is accepted on retry rather than treated as a replay.
|
||||
const retry = await send(app, { input: 'x', clientId: 'c9', seq: 7 });
|
||||
expect(retry.json()).toEqual({});
|
||||
expect(session.writeBuffer.join('')).toContain('x');
|
||||
});
|
||||
|
||||
it('a null wait is treated as absent, not as a validation error', async () => {
|
||||
// Zod .optional() rejects null, and a third-party caller building the body with
|
||||
// JSON.stringify keeps an explicit null on the wire.
|
||||
const { app } = await harness();
|
||||
const res = await send(app, { input: 'x', wait: null, waitTimeout: null });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({});
|
||||
});
|
||||
|
||||
it('wait: false is treated as absent', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await send(app, { input: 'x', wait: false });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({});
|
||||
expect(sessionWaits.totalWaiterCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to a direct write when the mux write fails, and still waits', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
session.writeViaMux = async () => false;
|
||||
|
||||
const pending = send(app, { input: 'fallback me', useMux: true, wait: 'stop' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(session.writeBuffer.join('')).toContain('fallback me');
|
||||
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.signal).toBe('stop');
|
||||
// The fallback write succeeded, so the input really was delivered.
|
||||
expect(body.data.delivered).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/input: delivered reports the write, not just the dedup', () => {
|
||||
it('reports delivered:false when BOTH write paths fail, instead of claiming delivery', async () => {
|
||||
// A worker whose PTY has exited fails writeViaMux AND write. Reporting
|
||||
// "delivered, but it timed out" points the agent at waiting longer; the truth is
|
||||
// "restart the worker".
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
session.failWrites = true;
|
||||
|
||||
const res = await send(app, { input: 'run the tests', useMux: true, wait: 'stop', waitTimeout: 600_000 });
|
||||
const body = res.json();
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(body.data.delivered).toBe(false);
|
||||
expect(body.data.duplicate).toBe(false);
|
||||
});
|
||||
|
||||
it('does not block for the full timeout on an input it knows never landed', async () => {
|
||||
// The waiter has to be registered before the write, so it exists by the time the
|
||||
// failure is known; releasing it immediately is what keeps the caller from
|
||||
// waiting ten minutes for a turn that cannot start.
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.failWrites = true;
|
||||
|
||||
const started = Date.now();
|
||||
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(2_000);
|
||||
expect(body.data.delivered).toBe(false);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
|
||||
});
|
||||
|
||||
it('reports delivered:false for a failed direct (non-mux) write too', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.failWrites = true;
|
||||
|
||||
const body = (await send(app, { input: 'x', wait: 'stop', waitTimeout: 600_000 })).json();
|
||||
expect(body.data.delivered).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls the dedup seq back when the write failed, so a retry is not a duplicate', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
session.failWrites = true;
|
||||
|
||||
const first = (await send(app, { input: 'x', clientId: 'c3', seq: 4, wait: 'stop', waitTimeout: 600_000 })).json();
|
||||
expect(first.data.delivered).toBe(false);
|
||||
|
||||
session.failWrites = false;
|
||||
const retry = (await send(app, { input: 'x', clientId: 'c3', seq: 4, wait: 'stop', waitTimeout: 1 })).json();
|
||||
expect(retry.data.duplicate).toBe(false);
|
||||
expect(retry.data.delivered).toBe(true);
|
||||
expect(session.writeBuffer.join('')).toContain('x');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Client-hang-up handling, over REAL HTTP.
|
||||
*
|
||||
* `app.inject()` never emits a `close` event at all, so the entire abort path is
|
||||
* invisible to every other test in this file — and the failure it hides is not
|
||||
* subtle. On a POST, `req.raw` emits `'close'` as soon as the request BODY finishes
|
||||
* streaming, which happens before the handler blocks (+1ms, `aborted: false`) and is
|
||||
* indistinguishable from a real hang-up at +0ms. A request-side abort listener
|
||||
* therefore cancels every send-and-wait instantly: `POST .../input {wait:"exit",
|
||||
* waitTimeout:10000}` came back in 23ms with `ended:true, aborted:true, waitedMs:6`,
|
||||
* i.e. the feature was dead while all 27 inject-based tests above stayed green.
|
||||
*
|
||||
* GET survives a request-side listener because it has no body to finish, which is
|
||||
* exactly why this regression needs a POST and a real socket to catch.
|
||||
*/
|
||||
describe('POST /api/sessions/:id/input over real HTTP: hang-up handling', () => {
|
||||
const PORT = 3181;
|
||||
const base = `http://127.0.0.1:${PORT}`;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = (await harness()).app;
|
||||
await app.listen({ port: PORT, host: '127.0.0.1' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// fetch keeps its sockets alive, and `app.close()` waits for idle connections,
|
||||
// so without this the teardown hook times out.
|
||||
app.server.closeAllConnections();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('a send-and-wait that is NOT aborted blocks for its full timeout', async () => {
|
||||
// The regression: this returned in ~20ms with aborted:true.
|
||||
const started = Date.now();
|
||||
const res = await fetch(`${base}/api/sessions/${SESSION_ID}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: 'run the tests', wait: 'exit', waitTimeout: 2000 }),
|
||||
});
|
||||
const elapsed = Date.now() - started;
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.data.wait.aborted).toBe(false);
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
expect(body.data.wait.waitedMs).toBeGreaterThan(1500);
|
||||
expect(elapsed).toBeGreaterThan(1500);
|
||||
expect(body.data.delivered).toBe(true);
|
||||
});
|
||||
|
||||
it('a send-and-wait aborted mid-flight frees its waiter', async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: 'x', wait: 'exit', waitTimeout: 600_000 }),
|
||||
signal: controller.signal,
|
||||
}).catch(() => 'aborted');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
// Still parked: the body finished streaming long ago, and that must not count.
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
controller.abort();
|
||||
expect(await pending).toBe('aborted');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
|
||||
});
|
||||
|
||||
it('a GET wait behaves the same way on both counts', async () => {
|
||||
const notAborted = await fetch(`${base}/api/sessions/${SESSION_ID}/wait?until=stop&timeout=1500`);
|
||||
const body = await notAborted.json();
|
||||
expect(body.data.wait.aborted).toBe(false);
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000`, {
|
||||
signal: controller.signal,
|
||||
}).catch(() => 'aborted');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
controller.abort();
|
||||
expect(await pending).toBe('aborted');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
|
||||
});
|
||||
|
||||
it('wait-output frees its waiter on hang-up too', async () => {
|
||||
const controller = new AbortController();
|
||||
const pending = fetch(`${base}/api/sessions/${SESSION_ID}/wait-output?match=NEVER&timeout=600000`, {
|
||||
signal: controller.signal,
|
||||
}).catch(() => 'aborted');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
controller.abort();
|
||||
expect(await pending).toBe('aborted');
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Send-and-wait against a tmux worker that has already died.
|
||||
*
|
||||
* `tmux send-keys` SUCCEEDS against a dead pane, so `writeViaMux` returns true and the
|
||||
* old `delivered` was true for bytes written into a corpse — with `timedOut: true`
|
||||
* alongside it, which tells an agent to wait longer when the truth is "restart the
|
||||
* worker". Live: `pane_dead=1 status=42`, Codeman `pid=309406 status=idle`,
|
||||
* `delivered: true`.
|
||||
*/
|
||||
describe('POST /api/sessions/:id/input: the pane is dead', () => {
|
||||
function setPaneDead(ctx: MockRouteContext, dead: boolean) {
|
||||
(ctx.mux as unknown as { isPaneDead: (n: string) => boolean }).isPaneDead = () => dead;
|
||||
}
|
||||
|
||||
it('reports delivered:false even though the mux write "succeeded"', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
setPaneDead(ctx, true);
|
||||
|
||||
const res = await send(app, { input: 'run the tests', useMux: true, wait: 'stop', waitTimeout: 600_000 });
|
||||
const body = res.json();
|
||||
|
||||
// The write itself did not fail — that is the whole trap.
|
||||
expect(session.writeBuffer.join('')).toContain('run the tests');
|
||||
expect(body.data.delivered).toBe(false);
|
||||
expect(body.data.duplicate).toBe(false);
|
||||
});
|
||||
|
||||
it('returns at once instead of blocking on a turn that cannot start', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
setPaneDead(ctx, true);
|
||||
|
||||
const started = Date.now();
|
||||
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(2_000);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
|
||||
});
|
||||
|
||||
it('rolls the dedup seq back, so a retry against a restarted worker is not a duplicate', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
setPaneDead(ctx, true);
|
||||
|
||||
const dead = (await send(app, { input: 'x', useMux: true, clientId: 'c7', seq: 3, wait: 'stop' })).json();
|
||||
expect(dead.data.delivered).toBe(false);
|
||||
|
||||
// Worker restarted.
|
||||
setPaneDead(ctx, false);
|
||||
_resetPaneLivenessState();
|
||||
const retry = (
|
||||
await send(app, { input: 'x', useMux: true, clientId: 'c7', seq: 3, wait: 'stop', waitTimeout: 1 })
|
||||
).json();
|
||||
expect(retry.data.duplicate).toBe(false);
|
||||
expect(retry.data.delivered).toBe(true);
|
||||
expect(session.writeBuffer.join('')).toContain('x');
|
||||
});
|
||||
|
||||
it('a live pane still reports delivered:true', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
setPaneDead(ctx, false);
|
||||
|
||||
const pending = send(app, { input: 'x', useMux: true, wait: 'stop' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
|
||||
expect((await pending).json().data.delivered).toBe(true);
|
||||
});
|
||||
|
||||
it('never probes tmux on the plain (non-wait) input path', async () => {
|
||||
// The browser sends thousands of these per session; they must not exec tmux.
|
||||
const { app, ctx } = await harness();
|
||||
const probe = vi.fn(() => false);
|
||||
(ctx.mux as unknown as { isPaneDead: (n: string) => boolean }).isPaneDead = probe as never;
|
||||
|
||||
await send(app, { input: 'hello', useMux: true });
|
||||
await send(app, { input: 'hello again', useMux: true, clientId: 'c1', seq: 1 });
|
||||
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/input: `aborted` stays a client-side fact', () => {
|
||||
it('reports aborted:false when the SERVER released the waiter after a failed delivery', async () => {
|
||||
// api-reference guarantees a client never sees `aborted: true`, because it means
|
||||
// "you hung up, nobody is reading this". The release below is the server giving up
|
||||
// on a write that failed — and the client IS reading the response, so reporting
|
||||
// `aborted: true` would both break that guarantee and hand an agent a second,
|
||||
// contradictory reason for an outcome `delivered: false` already explains.
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.failWrites = true;
|
||||
|
||||
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
|
||||
|
||||
expect(body.data.delivered).toBe(false);
|
||||
expect(body.data.wait.aborted).toBe(false);
|
||||
expect(body.data.wait.ended).toBe(true);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('the same holds for a dead pane', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => true;
|
||||
|
||||
const body = (await send(app, { input: 'x', useMux: true, wait: 'stop', waitTimeout: 600_000 })).json();
|
||||
expect(body.data.wait.aborted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/input: an oversized waitTimeout clamps', () => {
|
||||
it('accepts a value above the old schema ceiling and reports the clamp', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = send(app, { input: 'x', wait: 'stop', waitTimeout: 99_999_999_999 });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.timeoutMs).toBe(MAX_WAIT_MS);
|
||||
});
|
||||
|
||||
it('still rejects a non-integer or negative waitTimeout', async () => {
|
||||
const { app } = await harness();
|
||||
for (const value of [-1, 0, 1.5]) {
|
||||
const res = await send(app, { input: 'x', wait: 'stop', waitTimeout: value });
|
||||
expect(res.statusCode, `waitTimeout=${value}`).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* @fileoverview Route tests for `GET /api/sessions/:id/wait-output`.
|
||||
*
|
||||
* Same 200-on-timeout contract and same `data.wait` envelope as `/wait`. The
|
||||
* additional things pinned here:
|
||||
* - matching is LITERAL, and a `regex` parameter is rejected rather than ignored,
|
||||
* so an agent that assumed herdr's `--regex` cannot silently wait on the wrong thing;
|
||||
* - `from=buffer` scans what already scrolled past, bounded to a tail of the buffer,
|
||||
* and is charged against the waiter cap BEFORE it materializes that buffer;
|
||||
* - a chunk-straddling match still resolves, since PTY chunking is arbitrary;
|
||||
* - a client that hangs up frees its waiter instead of holding it to the timeout.
|
||||
*
|
||||
* Plan: docs/agent-control-plan.md
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import { registerSessionRoutes, _resetPaneLivenessState } from '../../src/web/routes/session-routes.js';
|
||||
import { createSessionListeners, attachSessionListeners } from '../../src/web/session-listener-wiring.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { sessionWaits } from '../../src/web/session-wait-registry.js';
|
||||
import { MAX_MATCH_LENGTH, MAX_BUFFER_SCAN_BYTES, MAX_WAIT_MS } from '../../src/config/agent-wait.js';
|
||||
|
||||
// Distinct per file on purpose: the three wait suites share the process-wide
|
||||
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
|
||||
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
|
||||
const SESSION_ID = 'wait-output-session';
|
||||
const URL = `/api/sessions/${SESSION_ID}/wait-output`;
|
||||
|
||||
afterEach(() => {
|
||||
// Deliberately not `cancelEverything()`: it latches the registry's stopped flag,
|
||||
// which would leave every later test in this file talking to a dead registry.
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
_resetPaneLivenessState();
|
||||
});
|
||||
|
||||
/** Mirrors production's errorCode-to-status mapping; without it negative cases pass vacuously. */
|
||||
async function harness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext; rawReplies: ServerResponse[] }> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
const rawReplies: ServerResponse[] = [];
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
// The RESPONSE, because that is what the handler's hang-up detection listens to.
|
||||
rawReplies.push(reply.raw);
|
||||
});
|
||||
|
||||
registerSessionRoutes(app, ctx as never);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
const p = payload as { success?: unknown; errorCode?: unknown } | null;
|
||||
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
|
||||
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, rawReplies };
|
||||
}
|
||||
|
||||
describe('GET /api/sessions/:id/wait-output', () => {
|
||||
it('resolves when the string appears on the stream', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
|
||||
sessionWaits.notifyOutput(SESSION_ID, 'running tests...\nBUILD OK\n');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.wait.matched).toBe(true);
|
||||
expect(body.data.wait.immediate).toBe(false);
|
||||
expect(body.data.wait.snippet).toContain('BUILD OK');
|
||||
expect(body.data.wait.match).toBe('BUILD OK');
|
||||
expect(body.data.sessionId).toBe(SESSION_ID);
|
||||
});
|
||||
|
||||
it('uses the same data.wait envelope as /wait, so one client helper reads both', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
|
||||
|
||||
const { data } = res.json();
|
||||
expect(Object.keys(data).sort()).toEqual(['limitPaused', 'sessionId', 'status', 'wait']);
|
||||
expect(data.matched).toBeUndefined();
|
||||
expect(data.wait.matched).toBe(false);
|
||||
expect(data.wait.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('sends Cache-Control: no-store', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
|
||||
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
it('echoes the effective timeout after clamping', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'BUILD OK\n';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&from=buffer&timeout=1800000` });
|
||||
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
|
||||
});
|
||||
|
||||
it('strips ANSI before matching, so colored output still matches', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifyOutput(SESSION_ID, '\x1b[32mBUILD\x1b[0m OK\n');
|
||||
|
||||
expect((await pending).json().data.wait.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('matches across a chunk boundary', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK` });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifyOutput(SESSION_ID, 'trailing text BUIL');
|
||||
sessionWaits.notifyOutput(SESSION_ID, 'D OK done');
|
||||
|
||||
expect((await pending).json().data.wait.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('is case-sensitive by default and honors nocase=1', async () => {
|
||||
const { app } = await harness();
|
||||
|
||||
const strict = app.inject({ method: 'GET', url: `${URL}?match=build%20ok&timeout=1` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifyOutput(SESSION_ID, 'BUILD OK');
|
||||
expect((await strict).json().data.wait.timedOut).toBe(true);
|
||||
|
||||
const loose = app.inject({ method: 'GET', url: `${URL}?match=build%20ok&nocase=1` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifyOutput(SESSION_ID, 'BUILD OK');
|
||||
const body = (await loose).json();
|
||||
expect(body.data.wait.matched).toBe(true);
|
||||
// Reported in the terminal's own casing, not the caller's.
|
||||
expect(body.data.wait.snippet).toContain('BUILD OK');
|
||||
});
|
||||
|
||||
it('from=buffer resolves immediately against output that already scrolled past', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'earlier output\n\x1b[32mBUILD OK\x1b[0m\n';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&from=buffer` });
|
||||
const body = res.json();
|
||||
expect(body.data.wait.matched).toBe(true);
|
||||
expect(body.data.wait.immediate).toBe(true);
|
||||
expect(body.data.wait.waitedMs).toBe(0);
|
||||
expect(sessionWaits.totalWaiterCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('from=buffer only scans a bounded tail', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
// Old marker pushed past the scan window by newer output.
|
||||
ctx.sessions.get(SESSION_ID)!.terminalBuffer = `ANCIENT${'x'.repeat(MAX_BUFFER_SCAN_BYTES + 1000)}`;
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=ANCIENT&from=buffer&timeout=1` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.wait.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to from=now, ignoring what is already in the buffer', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'BUILD OK happened before you asked\n';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=BUILD%20OK&timeout=1` });
|
||||
expect(res.json().data.wait.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('answers 200 with timedOut on timeout, never an error status', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=never&timeout=1` });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
expect(body.data.wait.matched).toBe(false);
|
||||
expect(body.data.wait.snippet).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves with ended when the session goes away mid-wait', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=never` });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.wait.ended).toBe(true);
|
||||
expect(body.data.wait.matched).toBe(false);
|
||||
});
|
||||
|
||||
it('frees its waiter when the RESPONSE socket closes early', async () => {
|
||||
// The injected response is genuinely destroyed by then, so the freed slot is what
|
||||
// this can assert; the wire-level behaviour is pinned over real HTTP in
|
||||
// session-input-wait.test.ts.
|
||||
const { app, rawReplies } = await harness();
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=never&timeout=600000` }).then(
|
||||
() => 'completed',
|
||||
() => 'destroyed'
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
rawReplies[rawReplies.length - 1].emit('close');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
|
||||
expect(await pending).toBe('destroyed');
|
||||
});
|
||||
|
||||
it('rejects a regex parameter instead of silently ignoring it', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=x®ex=%5EBUILD.*OK%24` });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
expect(body.error).toContain('match=');
|
||||
});
|
||||
|
||||
it('requires match', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: URL });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('rejects an empty or oversized match, and says which parameter was wrong', async () => {
|
||||
const { app } = await harness();
|
||||
|
||||
const empty = await app.inject({ method: 'GET', url: `${URL}?match=` });
|
||||
expect(empty.statusCode).toBe(400);
|
||||
expect(empty.json().error).toContain('match');
|
||||
|
||||
const huge = await app.inject({ method: 'GET', url: `${URL}?match=${'x'.repeat(MAX_MATCH_LENGTH + 1)}` });
|
||||
expect(huge.statusCode).toBe(400);
|
||||
expect(huge.json().error).toContain('match');
|
||||
});
|
||||
|
||||
it('rejects a non-numeric timeout', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=x&timeout=soon` });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
expect(res.json().error).toContain('timeout');
|
||||
});
|
||||
|
||||
it('rejects an unknown from value', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `${URL}?match=x&from=history` });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('404s an unknown session', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/wait-output?match=x' });
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it('output waiters share the session waiter cap with signal waiters', async () => {
|
||||
const { app } = await harness();
|
||||
const pendings = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
pendings.push(app.inject({ method: 'GET', url: `${URL}?match=never${i}` }));
|
||||
}
|
||||
for (let i = 0; i < 8; i++) {
|
||||
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
|
||||
|
||||
const overflow = await app.inject({ method: 'GET', url: `${URL}?match=one-too-many` });
|
||||
expect(overflow.statusCode).toBe(409);
|
||||
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
|
||||
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
await Promise.all(pendings);
|
||||
});
|
||||
|
||||
it('checks the cap BEFORE materializing the terminal buffer', async () => {
|
||||
// `session.terminalBuffer` joins the whole 32MB accumulator. Paying that for a
|
||||
// request that is about to be refused turns the cap into an amplifier: a caller
|
||||
// already at the limit can loop `from=buffer` at full speed and never register a
|
||||
// waiter, so nothing bounds the work.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
const bufferReads = vi.fn(() => 'nothing to see');
|
||||
Object.defineProperty(session, 'terminalBuffer', { get: bufferReads, configurable: true });
|
||||
|
||||
const pendings = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(16);
|
||||
|
||||
const overflow = await app.inject({ method: 'GET', url: `${URL}?match=x&from=buffer` });
|
||||
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
|
||||
expect(bufferReads).not.toHaveBeenCalled();
|
||||
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
await Promise.all(pendings);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The LIVE-STREAM half, wired the way production wires it.
|
||||
*
|
||||
* Everything above drives `sessionWaits.notifyOutput()` directly, which is the
|
||||
* registry's API, not the path a real session takes. Deleting the one line that
|
||||
* connects them — `sessionWaits.notifyOutput(session.id, data)` in the `terminal`
|
||||
* listener — left all four wait suites green and survived a full `test:ci` sweep, so
|
||||
* `wait-output?from=now` (the entire live mode, and the one the skill's recipes are
|
||||
* built on) could ship severed with nothing to show for it.
|
||||
*
|
||||
* These go through `createSessionListeners` so the wiring itself is what is pinned.
|
||||
*/
|
||||
describe('GET /api/sessions/:id/wait-output: fed by the real terminal listener', () => {
|
||||
function stubDeps() {
|
||||
return {
|
||||
broadcast: vi.fn(),
|
||||
batchTerminalData: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
sendPushNotifications: vi.fn(),
|
||||
persistSessionState: vi.fn(),
|
||||
getSessionStateWithRespawn: vi.fn(() => ({})),
|
||||
getRunSummaryTracker: vi.fn(() => undefined),
|
||||
stopTranscriptWatcher: vi.fn(),
|
||||
cleanupSessionBatches: vi.fn(),
|
||||
cancelPersistDebounce: vi.fn(),
|
||||
removeRunSummaryTracker: vi.fn(),
|
||||
removeSessionListenerRefs: vi.fn(),
|
||||
cleanupRespawnOnExit: vi.fn(),
|
||||
getStore: vi.fn(() => ({ updateRalphState: vi.fn() })),
|
||||
registerAttachment: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
it('matches output emitted by the session, not injected into the registry', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
const deps = stubDeps();
|
||||
attachSessionListeners(session as never, createSessionListeners(session as never, deps as never));
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=LIVE_STREAM_HIT` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
// What a PTY chunk actually does: the session emits `terminal`.
|
||||
session.simulateTerminalOutput('$ echo LIVE_STREAM_HIT\r\nLIVE_STREAM_HIT\r\n');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.matched).toBe(true);
|
||||
expect(body.data.wait.snippet).toContain('LIVE_STREAM_HIT');
|
||||
// The listener must still forward to the SSE batcher; the wait feed is additive.
|
||||
expect(deps.batchTerminalData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches across chunk boundaries through the listener', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=SPLIT_MARKER` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
session.simulateTerminalOutput('noise SPLIT_');
|
||||
session.simulateTerminalOutput('MARKER more noise');
|
||||
|
||||
expect((await pending).json().data.wait.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('strips ANSI on the way through the listener', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=COLORED%20HIT` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
session.simulateAnsiOutput('COLORED HIT');
|
||||
|
||||
expect((await pending).json().data.wait.matched).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait-output: a dead tmux worker', () => {
|
||||
it('releases an output waiter when the worker dies while it is parked', async () => {
|
||||
// The feed simply stops: no exit event, no further chunks, nothing to match.
|
||||
const { app, ctx } = await harness();
|
||||
let dead = false;
|
||||
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `${URL}?match=NEVER&timeout=600000` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
dead = true;
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.ended).toBe(true);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(0);
|
||||
}, 10_000);
|
||||
|
||||
it('an oversized timeout clamps rather than 400ing', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
// Resolve from the buffer so the assertion is about the clamp, not a real wait.
|
||||
ctx.sessions.get(SESSION_ID)!.terminalBuffer = 'ALREADY_THERE\n';
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `${URL}?match=ALREADY_THERE&from=buffer&timeout=99999999`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,811 @@
|
||||
/**
|
||||
* @fileoverview Route tests for `GET /api/sessions/:id/wait`.
|
||||
*
|
||||
* The contract this pins is the one an orchestrating agent depends on:
|
||||
* - a timeout is a 200 with `wait.timedOut: true`, never a 4xx, because callers loop
|
||||
* over short waits and every poll boundary would otherwise look like a failure;
|
||||
* - the result is nested under `data.wait` on ALL THREE wait endpoints, so a single
|
||||
* client helper reads any of them;
|
||||
* - the EFFECTIVE timeout is echoed, so a caller that asked for 30 minutes and was
|
||||
* clamped to 10 can tell a poll boundary from a wedged worker;
|
||||
* - an unknown `until` token is a 400 rather than a silent fallback to the default,
|
||||
* so a typo can never leave an agent believing it is waiting for something else,
|
||||
* and a schema 400 names the parameter it rejected;
|
||||
* - `stop`/`blocked` are rejected for modes that install no hooks when asked for
|
||||
* EXPLICITLY, but silently dropped from the DEFAULT set, so omitting `until` never
|
||||
* 400s — and `shell` counts as such a mode, even though it is not an external CLI;
|
||||
* - a client that hangs up frees its waiter immediately, or a loop of
|
||||
* `curl --max-time` calls wedges a process-wide cap nobody else can use;
|
||||
* - a session with no PTY answers `exit`, never `idle`.
|
||||
*
|
||||
* Plan: docs/agent-control-plan.md
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { ServerResponse } from 'node:http';
|
||||
import {
|
||||
registerSessionRoutes,
|
||||
_resetPaneLivenessState,
|
||||
_paneDeathWatcherCount,
|
||||
} from '../../src/web/routes/session-routes.js';
|
||||
import { createSessionListeners, attachSessionListeners } from '../../src/web/session-listener-wiring.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { sessionWaits } from '../../src/web/session-wait-registry.js';
|
||||
import { MAX_WAIT_MS, MIN_WAIT_MS, MAX_WAITERS_TOTAL, MAX_WAITERS_PER_OWNER } from '../../src/config/agent-wait.js';
|
||||
|
||||
// Distinct per file on purpose: the three wait suites share the process-wide
|
||||
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
|
||||
// by another's assertion. Failed only in a 5-file run, which is how CI runs them.
|
||||
const SESSION_ID = 'wait-routes-session';
|
||||
|
||||
/** Session ids a test parked filler waiters on, so cleanup can release them. */
|
||||
const fillerIds = new Set<string>();
|
||||
|
||||
/** Park a waiter directly on the shared registry (cap tests), tracked for cleanup. */
|
||||
function fillWaiter(id: string, owner?: string): Promise<unknown> {
|
||||
fillerIds.add(id);
|
||||
return sessionWaits.waitForSignal(id, { until: ['stop'], timeoutMs: 30_000, owner });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// The routes use the process-wide registry; never leak a waiter into the next test.
|
||||
// Deliberately NOT `cancelEverything()`: it latches the registry's stopped flag
|
||||
// (one-way by design, so a request landing mid-shutdown cannot register a waiter
|
||||
// nothing will ever cancel), which would leave every later test in this file
|
||||
// talking to a dead registry and passing vacuously.
|
||||
for (const id of [SESSION_ID, ...fillerIds]) sessionWaits.cancelAll(id);
|
||||
fillerIds.clear();
|
||||
// Pane-liveness state is module-level (one cache, one watcher per pane), so it has
|
||||
// to be reset or a cached probe leaks into the next test.
|
||||
_resetPaneLivenessState();
|
||||
delete process.env.CODEMAN_MULTIUSER;
|
||||
});
|
||||
|
||||
/**
|
||||
* The shared route harness returns handler payloads verbatim, so a `{success:false}`
|
||||
* body would still be HTTP 200. Production maps errorCode to status in server.ts, so
|
||||
* mirror that here or every negative case passes vacuously.
|
||||
*
|
||||
* `rawReplies` collects each request's `reply.raw` — the RESPONSE, which is what the
|
||||
* handler's hang-up detection listens on, and deliberately not `req.raw` (on a POST
|
||||
* that one closes as soon as the body has been read, so wiring an abort to it kills
|
||||
* every send-and-wait; see the real-HTTP suite in session-input-wait.test.ts).
|
||||
* Emitting the event by hand is the only way to simulate a hang-up here at all:
|
||||
* `app.inject()` never emits `close` on its own, verified.
|
||||
*/
|
||||
async function harness(options?: { authUser?: { username: string; role: 'admin' | 'user' } }): Promise<{
|
||||
app: FastifyInstance;
|
||||
ctx: MockRouteContext;
|
||||
rawReplies: ServerResponse[];
|
||||
}> {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
|
||||
const rawReplies: ServerResponse[] = [];
|
||||
|
||||
const authUser = options?.authUser;
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
// The RESPONSE, because that is what the handler's hang-up detection listens to.
|
||||
rawReplies.push(reply.raw);
|
||||
if (authUser) (req as unknown as { authUser: typeof authUser }).authUser = authUser;
|
||||
});
|
||||
|
||||
registerSessionRoutes(app, ctx as never);
|
||||
|
||||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||||
const p = payload as { success?: unknown; errorCode?: unknown } | null;
|
||||
if (p && typeof p === 'object' && p.success === false && reply.statusCode === 200) {
|
||||
if (typeof p.errorCode === 'string') reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||||
}
|
||||
return done(null, payload);
|
||||
});
|
||||
|
||||
installRouteErrorHandler(app);
|
||||
await app.ready();
|
||||
return { app, ctx, rawReplies };
|
||||
}
|
||||
|
||||
describe('GET /api/sessions/:id/wait', () => {
|
||||
it('resolves immediately when the session is already in a requested state', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.wait.signal).toBe('idle');
|
||||
expect(body.data.wait.immediate).toBe(true);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
expect(body.data.wait.aborted).toBe(false);
|
||||
expect(body.data.sessionId).toBe(SESSION_ID);
|
||||
expect(body.data.wait.until).toEqual(['idle']);
|
||||
});
|
||||
|
||||
it('nests the result under data.wait, so one client helper reads all three endpoints', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
|
||||
const { data } = res.json();
|
||||
// Session-level facts stay at the top; everything about the wait is inside it.
|
||||
expect(Object.keys(data).sort()).toEqual(['limitPaused', 'sessionId', 'status', 'wait']);
|
||||
expect(data.signal).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reports the post-wait status and the limit-pause hint', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
|
||||
const body = res.json();
|
||||
expect(body.data.status).toBe('idle');
|
||||
expect(body.data.limitPaused).toBe(false);
|
||||
});
|
||||
|
||||
it('sends Cache-Control: no-store, so a polled long-poll cannot be served from a cache', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
it('defaults to stop,idle,exit when until is omitted', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
|
||||
|
||||
const body = res.json();
|
||||
expect(body.data.wait.until).toEqual(['stop', 'idle', 'exit']);
|
||||
// The mock session is idle, so the default set resolves right away.
|
||||
expect(body.data.wait.signal).toBe('idle');
|
||||
});
|
||||
|
||||
it('rejects an unknown until token instead of falling back to the default', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stpo` });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe('INVALID_INPUT');
|
||||
expect(body.error).toContain('stpo');
|
||||
});
|
||||
|
||||
it('rejects a non-numeric timeout', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=soon` });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().errorCode).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('names the parameter it rejected, instead of a bare "invalid parameters"', async () => {
|
||||
// An agent driving this with no docs in context can only recover if the error
|
||||
// says WHICH parameter was wrong; the old message named none of them.
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=30s` });
|
||||
|
||||
const body = res.json();
|
||||
expect(body.error).toContain('timeout');
|
||||
expect(body.error).toContain('wait');
|
||||
});
|
||||
|
||||
it('404s an unknown session', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: '/api/sessions/nope/wait?until=idle' });
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().success).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves an in-flight wait when the signal arrives', async () => {
|
||||
const { app } = await harness();
|
||||
// `stop` is not the session's current signal, so this blocks.
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
sessionWaits.notifySignal(SESSION_ID, 'stop');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.signal).toBe('stop');
|
||||
expect(body.data.wait.immediate).toBe(false);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
expect(body.data.wait.waitedMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('fresh=1 waits for the next transition instead of answering from current state', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&fresh=1` });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
sessionWaits.notifySignal(SESSION_ID, 'idle');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.signal).toBe('idle');
|
||||
expect(body.data.wait.immediate).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves with ended when the session goes away mid-wait', async () => {
|
||||
const { app } = await harness();
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.wait.ended).toBe(true);
|
||||
expect(body.data.wait.signal).toBeNull();
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('answers 200 with timedOut on timeout, never an error status', async () => {
|
||||
const { app } = await harness();
|
||||
// Clamped up to the 1s floor, so this is the one deliberately slow case.
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=1` });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.wait.timedOut).toBe(true);
|
||||
expect(body.data.wait.signal).toBeNull();
|
||||
expect(body.data.wait.ended).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: the effective timeout is observable', () => {
|
||||
it('echoes the clamped-down value when the caller asks for more than the ceiling', async () => {
|
||||
// Asked for 30 minutes, got MAX_WAIT_MS. Without the echo the caller reads a
|
||||
// 10-minute timeout as "30 minutes elapsed with no stop" and kills a healthy worker.
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1800000` });
|
||||
|
||||
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
|
||||
});
|
||||
|
||||
it('echoes the clamped-up value at the floor too', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
|
||||
|
||||
expect(res.json().data.wait.timeoutMs).toBe(MIN_WAIT_MS);
|
||||
});
|
||||
|
||||
it('reports the applied default when timeout is omitted', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
|
||||
expect(res.json().data.wait.timeoutMs).toBeGreaterThanOrEqual(MIN_WAIT_MS);
|
||||
expect(res.json().data.wait.timeoutMs).toBeLessThanOrEqual(MAX_WAIT_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: repeated query parameters', () => {
|
||||
it('accepts ?until=stop&until=exit, the way most clients express a list', async () => {
|
||||
// Fastify delivers a repeated parameter as an array and parseWaitSignals has
|
||||
// always handled one; only the schema was rejecting it.
|
||||
const { app } = await harness();
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&until=exit` });
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
sessionWaits.notifySignal(SESSION_ID, 'exit');
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.until).toEqual(['stop', 'exit']);
|
||||
expect(body.data.wait.signal).toBe('exit');
|
||||
});
|
||||
|
||||
it('still reports an unknown token inside a repeated parameter', async () => {
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&until=stpo` });
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('stpo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: a client that hangs up frees its waiter', () => {
|
||||
it('removes the waiter when the RESPONSE socket closes early', async () => {
|
||||
// `curl --max-time 30 ".../wait?timeout=600000"` abandons a live waiter every
|
||||
// iteration of the documented loop; sixteen of those and an innocent session
|
||||
// reports busy.
|
||||
//
|
||||
// The response body is unreadable afterwards (the injected response really is
|
||||
// destroyed, exactly as a hung-up socket would be), so the freed slot is all this
|
||||
// can assert. The full behaviour, including `aborted: true` on the wire for the
|
||||
// caller that did NOT hang up, is pinned over real HTTP in session-input-wait.test.ts.
|
||||
const { app, rawReplies } = await harness();
|
||||
const pending = app
|
||||
.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` })
|
||||
.then(
|
||||
() => 'completed',
|
||||
() => 'destroyed'
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
rawReplies[rawReplies.length - 1].emit('close');
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
|
||||
expect(await pending).toBe('destroyed');
|
||||
});
|
||||
|
||||
it('a close AFTER the wait resolved changes nothing', async () => {
|
||||
const { app, rawReplies } = await harness();
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
|
||||
expect(res.json().data.wait.aborted).toBe(false);
|
||||
// Node fires `close` on every completed response too, not only on a hang-up;
|
||||
// `writableFinished` is what separates them.
|
||||
rawReplies[rawReplies.length - 1].emit('close');
|
||||
expect(sessionWaits.totalWaiterCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: capacity errors name the cap that was hit', () => {
|
||||
it('maps the per-session cap to SESSION_BUSY / 409', async () => {
|
||||
const { app } = await harness();
|
||||
const pendings = [];
|
||||
for (let i = 0; i < 16; i++) {
|
||||
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` }));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(16);
|
||||
|
||||
const overflow = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
|
||||
expect(overflow.statusCode).toBe(409);
|
||||
expect(overflow.json().errorCode).toBe('SESSION_BUSY');
|
||||
expect(overflow.json().error).toContain('session');
|
||||
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
await Promise.all(pendings);
|
||||
});
|
||||
|
||||
it('maps the process-wide cap to RATE_LIMITED / 429, because this session is not the problem', async () => {
|
||||
// Reported as SESSION_BUSY, an agent concludes the session it asked about is
|
||||
// busy, switches to another, and gets the identical error.
|
||||
const { app } = await harness();
|
||||
const others: Promise<unknown>[] = [];
|
||||
for (let i = 0; i < MAX_WAITERS_TOTAL; i++) others.push(fillWaiter(`unrelated-${i}`));
|
||||
expect(sessionWaits.totalWaiterCount()).toBe(MAX_WAITERS_TOTAL);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
|
||||
expect(res.statusCode).toBe(429);
|
||||
expect(res.json().errorCode).toBe('RATE_LIMITED');
|
||||
expect(res.json().error).toContain('total');
|
||||
|
||||
for (const id of fillerIds) sessionWaits.cancelAll(id);
|
||||
await Promise.all(others);
|
||||
});
|
||||
|
||||
it('maps the per-owner cap to RATE_LIMITED / 429 and charges the request to its user', async () => {
|
||||
// Also proves the route passes ownerFor(req): without it the owner cap can never
|
||||
// trip, and one user could hold the whole process-wide pool.
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
const { app } = await harness({ authUser: { username: 'alice', role: 'admin' } });
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.ownerWaiterCount('alice')).toBe(1);
|
||||
|
||||
const others: Promise<unknown>[] = [];
|
||||
while (sessionWaits.ownerWaiterCount('alice') < MAX_WAITERS_PER_OWNER) {
|
||||
others.push(fillWaiter(`alice-${others.length}`, 'alice'));
|
||||
}
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
|
||||
expect(res.statusCode).toBe(429);
|
||||
expect(res.json().error).toContain('owner');
|
||||
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
for (const id of fillerIds) sessionWaits.cancelAll(id);
|
||||
await Promise.all([pending, ...others]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: modes that install no hooks', () => {
|
||||
it('rejects an explicit stop, which no external CLI ever emits', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('codex');
|
||||
});
|
||||
|
||||
it('rejects an explicit blocked too', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'opencode';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects stop for a SHELL session, which is not an external CLI but installs no hooks either', async () => {
|
||||
// A plain bash PTY never POSTs a Stop hook, so this was a guaranteed ten-minute
|
||||
// hang dressed up as a timeout — the exact failure the guard exists to prevent.
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toContain('shell');
|
||||
});
|
||||
|
||||
it('silently drops hook-only signals from the DEFAULT set instead of 400ing', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'gemini';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
|
||||
});
|
||||
|
||||
it('drops them from the default set for shell too', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'shell';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
|
||||
});
|
||||
|
||||
it('still accepts idle and exit explicitly', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.mode = 'antigravity';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle,exit` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.wait.until).toEqual(['idle', 'exit']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: liveness beats the reported status', () => {
|
||||
it('answers exit for a session whose PTY is gone, not the idle its status claims', async () => {
|
||||
// Session parks a dead PTY at status 'idle' and the object survives in the map,
|
||||
// so the DEFAULT wait used to answer {signal:"idle", immediate:true} for a
|
||||
// crashed worker — 200, success, no error, and the agent prompts a corpse.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
session.pid = null;
|
||||
session.status = 'idle';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
|
||||
const body = res.json();
|
||||
expect(body.data.wait.signal).toBe('exit');
|
||||
expect(body.data.wait.immediate).toBe(true);
|
||||
// The raw status is still reported, so nothing is hidden from the caller.
|
||||
expect(body.data.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('resolves until=exit immediately for an already-exited session instead of blocking', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.pid = null;
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&timeout=1` });
|
||||
expect(res.json().data.wait.signal).toBe('exit');
|
||||
expect(res.json().data.wait.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('does not report idle for a dead session even when idle was asked for explicitly', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.pid = null;
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
|
||||
expect(res.json().data.wait.signal).toBeNull();
|
||||
expect(res.json().data.wait.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('a live busy session resolves until=working immediately', async () => {
|
||||
// Unreachable before: MockSession used 'working', which is not a SessionStatus,
|
||||
// so signalForStatus fell through to null and this branch had no coverage.
|
||||
const { app, ctx } = await harness();
|
||||
ctx.sessions.get(SESSION_ID)!.status = 'busy';
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=working` });
|
||||
expect(res.json().data.wait.signal).toBe('working');
|
||||
expect(res.json().data.wait.immediate).toBe(true);
|
||||
});
|
||||
|
||||
it('a live stopped/error session maps to exit', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
|
||||
session.status = 'stopped';
|
||||
const stopped = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit` });
|
||||
expect(stopped.json().data.wait.signal).toBe('exit');
|
||||
|
||||
session.status = 'error';
|
||||
const errored = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit` });
|
||||
expect(errored.json().data.wait.signal).toBe('exit');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of the wait contract, exercised through the real listener wiring
|
||||
* rather than a route: a PTY that dies must RELEASE every waiter, not only the ones
|
||||
* that asked for `exit`.
|
||||
*
|
||||
* It lives in this file because it pins the same promise the routes above make
|
||||
* ("never hang"), and because the failure is only visible from the caller's side:
|
||||
* the exit handler detaches the `terminal`, `idle` and `working` listeners moments
|
||||
* later, so anything still registered afterwards is waiting on feeds that no longer
|
||||
* exist and can only time out.
|
||||
*/
|
||||
describe('a PTY exit releases waiters that did not ask for exit', () => {
|
||||
/** Everything the exit handler touches; the wait release must not depend on any of it. */
|
||||
function stubDeps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
broadcast: vi.fn(),
|
||||
batchTerminalData: vi.fn(),
|
||||
batchTaskUpdate: vi.fn(),
|
||||
broadcastSessionStateDebounced: vi.fn(),
|
||||
sendPushNotifications: vi.fn(),
|
||||
persistSessionState: vi.fn(),
|
||||
getSessionStateWithRespawn: vi.fn(() => ({})),
|
||||
getRunSummaryTracker: vi.fn(() => undefined),
|
||||
stopTranscriptWatcher: vi.fn(),
|
||||
cleanupSessionBatches: vi.fn(),
|
||||
cancelPersistDebounce: vi.fn(),
|
||||
removeRunSummaryTracker: vi.fn(),
|
||||
removeSessionListenerRefs: vi.fn(),
|
||||
cleanupRespawnOnExit: vi.fn(),
|
||||
getStore: vi.fn(() => ({ updateRalphState: vi.fn() })),
|
||||
registerAttachment: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('answers an until=working waiter with ended instead of leaving it to time out', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=working&timeout=600000` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
session.emit('exit', 1);
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.ended).toBe(true);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
expect(body.data.wait.signal).toBeNull();
|
||||
});
|
||||
|
||||
it('still gives an until=exit waiter its signal, not a bare ended', async () => {
|
||||
// Ordering matters: notifySignal('exit') must run BEFORE cancelAll, or a caller
|
||||
// that asked the right question gets the generic answer.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&fresh=1` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
session.emit('exit', 0);
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.signal).toBe('exit');
|
||||
expect(body.data.wait.ended).toBe(false);
|
||||
});
|
||||
|
||||
it('releases output waiters too, whose only feed the exit handler is about to detach', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
|
||||
|
||||
const pending = app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SESSION_ID}/wait-output?match=DONE&timeout=600000`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(sessionWaits.outputWaiterCount(SESSION_ID)).toBe(1);
|
||||
|
||||
session.emit('exit', 1);
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.ended).toBe(true);
|
||||
expect(body.data.wait.matched).toBe(false);
|
||||
expect(sessionWaits.waiterCount(SESSION_ID)).toBe(0);
|
||||
});
|
||||
|
||||
it('releases them even when a later step of the exit handler throws', async () => {
|
||||
// Which is why the release is the first thing in the handler.
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
const deps = stubDeps({
|
||||
broadcast: vi.fn(() => {
|
||||
throw new Error('SSE is down');
|
||||
}),
|
||||
});
|
||||
attachSessionListeners(session as never, createSessionListeners(session as never, deps as never));
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
session.emit('exit', 1);
|
||||
|
||||
expect((await pending).json().data.wait.ended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Worker liveness for a tmux-backed session.
|
||||
*
|
||||
* `session.pid` is the local `tmux attach` CLIENT, not the worker. Codeman sets
|
||||
* `remain-on-exit on`, so when the command inside the pane exits tmux keeps the pane
|
||||
* (`pane_dead=1`), the tmux session survives, the attach client keeps running, `pid`
|
||||
* never goes null and NO exit event fires. Reproduced live on a shell worker killed
|
||||
* with `exit 42`: tmux said `pane_dead=1 status=42` while Codeman said
|
||||
* `pid=309406 status=idle` and the default wait answered
|
||||
* `{signal:"idle", immediate:true, waitedMs:0}` for a corpse.
|
||||
*
|
||||
* These cases could not exist before, because `MockSession.pid` is set by hand: the
|
||||
* `pid === null` branch is the one production never reaches.
|
||||
*/
|
||||
describe('GET /api/sessions/:id/wait: a dead tmux worker', () => {
|
||||
/** Mock ctx doubles carry no `isPaneDead`; the route treats that as "cannot tell". */
|
||||
function setPaneDead(ctx: MockRouteContext, dead: boolean): ReturnType<typeof vi.fn> {
|
||||
const probe = vi.fn(() => dead);
|
||||
(ctx.mux as unknown as { isPaneDead: (name: string) => boolean }).isPaneDead = probe as never;
|
||||
return probe;
|
||||
}
|
||||
|
||||
it('answers exit, not the idle the session still reports', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const session = ctx.sessions.get(SESSION_ID)!;
|
||||
setPaneDead(ctx, true);
|
||||
// Exactly the live state: attach client alive, status idle, worker gone.
|
||||
expect(session.pid).not.toBeNull();
|
||||
expect(session.status).toBe('idle');
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait` });
|
||||
const body = res.json();
|
||||
expect(body.data.wait.signal).toBe('exit');
|
||||
expect(body.data.wait.immediate).toBe(true);
|
||||
// The raw status is still reported, so nothing is hidden from the caller.
|
||||
expect(body.data.status).toBe('idle');
|
||||
});
|
||||
|
||||
it('resolves until=exit immediately instead of burning the whole timeout', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
setPaneDead(ctx, true);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=exit&timeout=1` });
|
||||
expect(res.json().data.wait.signal).toBe('exit');
|
||||
expect(res.json().data.wait.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('does not answer idle for a dead worker even when idle was asked for explicitly', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
setPaneDead(ctx, true);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=1` });
|
||||
expect(res.json().data.wait.signal).toBeNull();
|
||||
expect(res.json().data.wait.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('a live pane is unaffected', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
setPaneDead(ctx, false);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
expect(res.json().data.wait.signal).toBe('idle');
|
||||
});
|
||||
|
||||
it('caches the probe, so a poll loop cannot exec tmux once per request', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const probe = setPaneDead(ctx, false);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
}
|
||||
expect(probe.mock.calls.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('never probes a session that is not tmux-backed', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
const probe = setPaneDead(ctx, true);
|
||||
ctx.sessions.get(SESSION_ID)!.usesMux = false;
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
|
||||
expect(probe).not.toHaveBeenCalled();
|
||||
// Falls back to the pid rule, which is the right one for a direct PTY.
|
||||
expect(res.json().data.wait.signal).toBe('idle');
|
||||
});
|
||||
|
||||
it('releases a wait when the worker dies WHILE it is parked', async () => {
|
||||
// The common orchestration case, and the one a request-time probe cannot see: no
|
||||
// exit event, no output, nothing — the caller would block for its full timeout.
|
||||
const { app, ctx } = await harness();
|
||||
let dead = false;
|
||||
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
|
||||
expect(_paneDeathWatcherCount()).toBe(1);
|
||||
|
||||
dead = true;
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.ended).toBe(true);
|
||||
expect(body.data.wait.timedOut).toBe(false);
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(0);
|
||||
// ...and the watcher is torn down with the last waiter that needed it.
|
||||
expect(_paneDeathWatcherCount()).toBe(0);
|
||||
}, 10_000);
|
||||
|
||||
it('an until=exit caller parked when the worker dies gets its signal, not a bare ended', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
let dead = false;
|
||||
(ctx.mux as unknown as { isPaneDead: () => boolean }).isPaneDead = () => dead;
|
||||
|
||||
const pending = app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SESSION_ID}/wait?until=exit&fresh=1&timeout=600000`,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
dead = true;
|
||||
|
||||
const body = (await pending).json();
|
||||
expect(body.data.wait.signal).toBe('exit');
|
||||
expect(body.data.wait.ended).toBe(false);
|
||||
}, 10_000);
|
||||
|
||||
it('starts no watcher at all when the session is not tmux-backed', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
setPaneDead(ctx, false);
|
||||
ctx.sessions.get(SESSION_ID)!.usesMux = false;
|
||||
|
||||
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` });
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(_paneDeathWatcherCount()).toBe(0);
|
||||
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
await pending;
|
||||
});
|
||||
|
||||
it('shares ONE watcher across every wait parked on the same session', async () => {
|
||||
const { app, ctx } = await harness();
|
||||
setPaneDead(ctx, false);
|
||||
|
||||
const pendings = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
pendings.push(app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop&timeout=600000` }));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(5);
|
||||
expect(_paneDeathWatcherCount()).toBe(1);
|
||||
|
||||
sessionWaits.cancelAll(SESSION_ID);
|
||||
await Promise.all(pendings);
|
||||
expect(_paneDeathWatcherCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/sessions/:id/wait: an oversized timeout clamps, it does not 400', () => {
|
||||
it('accepts a value above the old schema ceiling and reports the clamp', async () => {
|
||||
// "Clamped to [1000, 600000]" has to mean it: `timeout=600001` clamping while
|
||||
// `timeout=99999999` 400s is the same documented rule producing two outcomes.
|
||||
const { app } = await harness();
|
||||
const res = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${SESSION_ID}/wait?until=idle&timeout=99999999`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().data.wait.timeoutMs).toBe(MAX_WAIT_MS);
|
||||
});
|
||||
|
||||
it('still rejects a non-finite or non-integer timeout', async () => {
|
||||
const { app } = await harness();
|
||||
for (const value of ['1e999', 'soon', '-1', '1.5']) {
|
||||
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?timeout=${value}` });
|
||||
expect(res.statusCode, `timeout=${value}`).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,3 +64,38 @@ describe('buildMuxAttachEnv', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawn env CODEMAN_API_URL (no fallback)', () => {
|
||||
const withApiUrl = (value: string | undefined, fn: () => void) => {
|
||||
const original = process.env.CODEMAN_API_URL;
|
||||
if (value === undefined) delete process.env.CODEMAN_API_URL;
|
||||
else process.env.CODEMAN_API_URL = value;
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.CODEMAN_API_URL;
|
||||
else process.env.CODEMAN_API_URL = original;
|
||||
}
|
||||
};
|
||||
|
||||
it('passes the server-stamped URL through verbatim', async () => {
|
||||
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
|
||||
withApiUrl('https://127.0.0.1:3199', () => {
|
||||
expect(buildClaudeEnv('test-session').CODEMAN_API_URL).toBe('https://127.0.0.1:3199');
|
||||
expect(buildShellEnv('test-session').CODEMAN_API_URL).toBe('https://127.0.0.1:3199');
|
||||
});
|
||||
});
|
||||
|
||||
// A hardcoded fallback was the wrong scheme on HTTPS installs. The key must be
|
||||
// genuinely ABSENT when unset: present-with-undefined would serialize through
|
||||
// node-pty as the literal string "CODEMAN_API_URL=undefined" (COD-115).
|
||||
it('leaves the key absent (not undefined, not a fallback) when the server has not stamped one', async () => {
|
||||
const { buildClaudeEnv, buildShellEnv } = await import('../src/session-cli-builder.js');
|
||||
withApiUrl(undefined, () => {
|
||||
for (const env of [buildClaudeEnv('test-session'), buildShellEnv('test-session')]) {
|
||||
expect('CODEMAN_API_URL' in env).toBe(false);
|
||||
expect(JSON.stringify(env)).not.toContain('localhost:3000');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -247,14 +247,41 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
|
||||
describe('environment exports', () => {
|
||||
it('keeps COLORTERM unset for OpenCode sessions', () => {
|
||||
const exports = (
|
||||
const callBuildEnvExports = (mode: string) =>
|
||||
(
|
||||
manager as unknown as {
|
||||
buildEnvExports(sessionId: string, muxName: string, mode: string): string[];
|
||||
}
|
||||
).buildEnvExports('session-1', 'codeman-abc12345', 'opencode');
|
||||
).buildEnvExports('session-1', 'codeman-abc12345', mode);
|
||||
|
||||
expect(exports).toContain('unset COLORTERM');
|
||||
it('keeps COLORTERM unset for OpenCode sessions', () => {
|
||||
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
|
||||
});
|
||||
|
||||
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
|
||||
const original = process.env.CODEMAN_API_URL;
|
||||
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';
|
||||
try {
|
||||
expect(callBuildEnvExports('claude')).toContain('export CODEMAN_API_URL=https://127.0.0.1:3199');
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.CODEMAN_API_URL;
|
||||
else process.env.CODEMAN_API_URL = original;
|
||||
}
|
||||
});
|
||||
|
||||
// A hardcoded fallback exported the wrong scheme on HTTPS installs; unset must
|
||||
// stay unset so in-session guards fail closed instead of curling a bad URL.
|
||||
it('exports no CODEMAN_API_URL at all when the server has not stamped one', () => {
|
||||
const original = process.env.CODEMAN_API_URL;
|
||||
delete process.env.CODEMAN_API_URL;
|
||||
try {
|
||||
const exports = callBuildEnvExports('claude');
|
||||
expect(exports.some((line) => line.startsWith('export CODEMAN_API_URL'))).toBe(false);
|
||||
expect(exports.join(' ')).not.toContain('localhost:3000');
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.CODEMAN_API_URL;
|
||||
else process.env.CODEMAN_API_URL = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user