timmy-talking-turd/tests/agent-turn-timeout.test.js
Timmy 5dcaaae4d6 feat: queue agent turns with cancellation and orphan-free timeout kills
Chat turns now flow through the bounded inference queue: FIFO with
configurable depth (TIMMY_AGENT_MAX_QUEUE_DEPTH) and concurrency
(TIMMY_AGENT_MAX_CONCURRENT_TURNS), sanitized overload/timeout
fallbacks, browser-disconnect cancellation via AbortSignal, and child
process SIGTERM/SIGKILL teardown so no Hermes subprocess survives a
cancelled turn.
2026-08-22 22:04:17 +00:00

110 lines
4.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { constants as fsConstants } from 'node:fs';
import { AgentGatewayError, createHermesAgentService, resolveHermesAgentConfig, runHermesCliTurn } from '../src/hermes-agent-service.js';
const origin = 'http://127.0.0.1:4173';
const accessCode = 'test-agent-access-code-2026';
const configured = () => resolveHermesAgentConfig({
TIMMY_AGENT_ENABLED: 'true',
TIMMY_AGENT_ACCESS_TOKEN: accessCode,
TIMMY_PUBLIC_ORIGIN: origin,
TIMMY_AGENT_WORKDIR: '/tmp/timmy-agent-workspace',
});
async function waitUntil(check, timeoutMs = 3_000, stepMs = 20) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return true;
await new Promise(resolve => setTimeout(resolve, stepMs));
}
return false;
}
function isAlive(pid) {
try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
}
test('aborting an in-flight Hermes turn kills the child promptly and leaves no orphan process', async () => {
const dir = await mkdir(join(tmpdir(), `timmy-orphan-${Date.now()}-`), { recursive: true });
const pidFile = join(dir, 'child.pid');
const fixture = join(dir, 'slow-hermes.sh');
// Prints valid Hermes output immediately, then idles long enough that only
// an explicit kill can end it. `exec` makes the recorded PID the sleeper.
await writeFile(fixture, `#!/usr/bin/env bash\nprintf '%s\\n' $$ > '${pidFile}'\necho "session_id: fixture_kill_001"\necho "thinking"\nexec sleep 60\n`, { mode: fsConstants.S_IRWXU });
const controller = new AbortController();
const turn = runHermesCliTurn({
prompt: 'hello',
hermesSessionId: null,
config: { ...configured(), command: fixture, workdir: dir },
signal: controller.signal,
env: { TMPDIR: dir },
});
const pidAppeared = await waitUntil(async () => {
try { Number(await (await import('node:fs/promises')).readFile(pidFile, 'utf8')); return true; } catch { return false; }
}, 4_000);
assert.ok(pidAppeared, 'fixture child started');
const pid = Number(await (await import('node:fs/promises')).readFile(pidFile, 'utf8'));
assert.ok(isAlive(pid), 'child is running before cancellation');
const startedAt = Date.now();
controller.abort(new Error('client disconnected'));
await assert.rejects(() => Promise.race([
turn,
new Promise((_, reject) => setTimeout(() => reject(new Error('turn was not cancelled')), 5_000)),
]), error => /disconnect|abort|killed|terminated/i.test(String(error?.message || error?.code || '')));
const cancelMs = Date.now() - startedAt;
assert.ok(cancelMs < 4_000, `cancellation returned promptly (took ${cancelMs}ms)`);
const reaped = await waitUntil(() => !isAlive(pid), 4_000);
assert.ok(reaped, 'child process was actually killed — no orphan subprocess remains');
await rm(dir, { recursive: true, force: true });
}, { timeout: 20_000 });
test('a queued chat past the queue deadline is cleaned up with a sanitized timeout response and zero extra Hermes calls', async () => {
let clock = 0;
let started = 0;
let releaseActive;
const service = createHermesAgentService({
config: { ...configured(), timeoutMs: 50 },
randomToken: () => 'deadline-cookie',
now: () => clock,
runTurn: async () => {
started += 1;
return new Promise(resolve => { releaseActive = resolve; });
},
});
await service.unlock({ origin, accessCode });
const active = service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'wedge', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(started, 1);
const queued = service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'waiting', ledger: [] } });
await new Promise(resolve => setImmediate(resolve));
assert.equal(started, 1);
clock += 200; // past the 50ms queue deadline
// Attach the rejection expectation before the sweep runs so the expired
// request never counts as an unhandled rejection.
const expiryExpectation = assert.rejects(() => queued, error => {
assert.ok(error instanceof AgentGatewayError);
assert.equal(error.status, 503);
assert.match(error.message, /timed out|busy/i);
assert.doesNotMatch(error.message, /spawn|hermes|session|token|workdir|auth/i);
return true;
});
service.chat({ origin, cookieToken: 'deadline-cookie', payload: { message: 'sweep trigger', ledger: [] } }).catch(() => {});
await expiryExpectation;
assert.equal(started, 1, 'expired request never reached Hermes');
releaseActive({ reply: 'late but valid', sessionId: 'deadline_session_01' });
assert.match((await active).reply, /late but valid/);
});