Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
194 lines
6.5 KiB
JavaScript
194 lines
6.5 KiB
JavaScript
/**
|
|
* T-148528 (WP-B) — Tests for dispatcher.abortRun / abortRunByConversation.
|
|
*
|
|
* Strategy: spawn a real `sleep 30` child with `detached: true` so it has
|
|
* its own process group, inject the PID into the dispatcher's internal
|
|
* `_activeAttempts` map, and call `abortRun`. The child MUST be killed
|
|
* within the 5s SIGKILL grace window. DB writes are mocked.
|
|
*/
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../../database/connection.js', () => ({
|
|
dbGet: vi.fn(),
|
|
dbAll: vi.fn(),
|
|
dbRun: vi.fn(async () => ({ changes: 1 })),
|
|
isPostgres: vi.fn(() => true),
|
|
}));
|
|
|
|
vi.mock('../../../utils/logger.js', () => {
|
|
const stub = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
return {
|
|
apiLogger: {
|
|
...stub,
|
|
child: vi.fn(() => ({ ...stub })),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Skip the heavy buildRunPrompt graph — abortRun never invokes it but the
|
|
// dispatcher's top-level import chain pulls it in transitively.
|
|
vi.mock('../build-run-prompt.mjs', () => ({
|
|
buildRunPrompt: vi.fn(),
|
|
}));
|
|
|
|
// Skip workspace manager so the file-system side never fires.
|
|
vi.mock('../workspace-manager.js', () => ({
|
|
createWorkspace: vi.fn(),
|
|
destroyWorkspace: vi.fn(),
|
|
}));
|
|
|
|
// Heavy stream handler isn't needed for the abort path.
|
|
vi.mock('../run-stream-handler.mjs', () => ({
|
|
runStreamHandler: vi.fn(),
|
|
eventTranslator: vi.fn(),
|
|
warnLegacyOnce: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
abortRun,
|
|
abortRunByConversation,
|
|
_getActiveAttemptsForTest,
|
|
} from '../index.js';
|
|
import { dbGet, dbRun } from '../../../database/connection.js';
|
|
|
|
function isAlive(pid) {
|
|
try { process.kill(pid, 0); return true; }
|
|
catch (err) { return !(err && err.code === 'ESRCH'); }
|
|
}
|
|
|
|
async function waitForDeath(pid, timeoutMs = 6000, stepMs = 50) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (!isAlive(pid)) return true;
|
|
await new Promise(r => setTimeout(r, stepMs));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
describe('T-148528: abortRun / abortRunByConversation', () => {
|
|
/** @type {import('node:child_process').ChildProcess|null} */
|
|
let child = null;
|
|
|
|
beforeEach(() => {
|
|
dbRun.mockClear();
|
|
dbGet.mockReset();
|
|
_getActiveAttemptsForTest().clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (child && !child.killed) {
|
|
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
try { process.kill(child.pid, 'SIGKILL'); } catch { /* already gone */ }
|
|
}
|
|
child = null;
|
|
});
|
|
|
|
it('SIGTERMs the tracked PID, persists run_state=canceled, returns aborted:true', async () => {
|
|
child = spawn('sleep', ['30'], { detached: true, stdio: 'ignore' });
|
|
// Allow detached: true → kernel assigns a new PGID. setpgid is async on
|
|
// some platforms; give it a tick.
|
|
await new Promise(r => setTimeout(r, 50));
|
|
expect(isAlive(child.pid)).toBe(true);
|
|
|
|
const rowId = 999001;
|
|
_getActiveAttemptsForTest().set(rowId, {
|
|
agent_id: 1, claimedAt: Date.now(), workspacePath: '/tmp/x', pid: child.pid,
|
|
});
|
|
|
|
const result = await abortRun(rowId, { reason: 'user_stop' });
|
|
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.reason).toBe('user_stop');
|
|
expect(result.pid).toBe(child.pid);
|
|
|
|
// Active-attempts entry must be cleared synchronously.
|
|
expect(_getActiveAttemptsForTest().has(rowId)).toBe(false);
|
|
|
|
// run_state should have been persisted to canceled. We look for the
|
|
// characteristic UPDATE on table_rows with the JSONB merge.
|
|
const cancelCall = dbRun.mock.calls.find(call =>
|
|
typeof call[0] === 'string'
|
|
&& call[0].includes('UPDATE table_rows')
|
|
&& call[0].includes('run_state')
|
|
);
|
|
expect(cancelCall).toBeDefined();
|
|
// Reason must be propagated into the placeholder params.
|
|
expect(cancelCall[1]).toContain('user_stop');
|
|
|
|
// Child must actually die within the SIGKILL grace.
|
|
const died = await waitForDeath(child.pid);
|
|
expect(died).toBe(true);
|
|
});
|
|
|
|
it('is a no-op when rowId has no active attempt (still persists terminal state)', async () => {
|
|
const rowId = 999002;
|
|
expect(_getActiveAttemptsForTest().has(rowId)).toBe(false);
|
|
|
|
const result = await abortRun(rowId, { reason: 'user_stop' });
|
|
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.pid).toBeNull();
|
|
|
|
// We STILL flip the ticket so future ticks don't re-pick a stale row.
|
|
const cancelCall = dbRun.mock.calls.find(call =>
|
|
typeof call[0] === 'string' && call[0].includes('run_state')
|
|
);
|
|
expect(cancelCall).toBeDefined();
|
|
});
|
|
|
|
it('rejects invalid rowId without touching DB', async () => {
|
|
const result = await abortRun(null);
|
|
expect(result).toEqual({ aborted: false, reason: 'invalid_row_id' });
|
|
expect(dbRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('abortRunByConversation: walks bound_row_id and aborts the matching run', async () => {
|
|
child = spawn('sleep', ['30'], { detached: true, stdio: 'ignore' });
|
|
await new Promise(r => setTimeout(r, 50));
|
|
expect(isAlive(child.pid)).toBe(true);
|
|
|
|
const rowId = 999003;
|
|
const conversationId = 5555;
|
|
_getActiveAttemptsForTest().set(rowId, {
|
|
agent_id: 1, claimedAt: Date.now(), workspacePath: '/tmp/x', pid: child.pid,
|
|
});
|
|
dbGet.mockResolvedValueOnce({ bound_row_id: rowId, bound_table_id: 1708 });
|
|
|
|
const result = await abortRunByConversation(conversationId);
|
|
|
|
expect(result.aborted).toBe(true);
|
|
expect(result.ticket_id).toBe(rowId);
|
|
expect(result.reason).toBe('user_stop');
|
|
|
|
const died = await waitForDeath(child.pid);
|
|
expect(died).toBe(true);
|
|
});
|
|
|
|
it('abortRunByConversation: returns aborted=false when conversation has no bound ticket', async () => {
|
|
dbGet.mockResolvedValueOnce({ bound_row_id: null, bound_table_id: null });
|
|
|
|
const result = await abortRunByConversation(7777);
|
|
|
|
expect(result.aborted).toBe(false);
|
|
expect(result.reason).toBe('no_active_run');
|
|
expect(dbRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('abortRunByConversation: returns aborted=false when bound ticket has no active attempt', async () => {
|
|
dbGet.mockResolvedValueOnce({ bound_row_id: 12345, bound_table_id: 1708 });
|
|
|
|
const result = await abortRunByConversation(7777);
|
|
|
|
expect(result.aborted).toBe(false);
|
|
expect(result.reason).toBe('no_active_run');
|
|
expect(result.ticket_id).toBe(12345);
|
|
expect(dbRun).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('abortRunByConversation: rejects invalid conversation id', async () => {
|
|
const result = await abortRunByConversation(null);
|
|
expect(result).toEqual({ aborted: false, reason: 'invalid_conversation_id' });
|
|
});
|
|
});
|