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.
110 lines
4.8 KiB
JavaScript
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/);
|
|
});
|