Repository Public
1/** The worker process end to end: fail-fast configuration, start-up, a mission without its provider key, graceful shutdown. */2import { execFileSync, spawn, type ChildProcess } from 'node:child_process';3import { randomBytes } from 'node:crypto';4import fs from 'node:fs';5import os from 'node:os';6import path from 'node:path';7import { fileURLToPath } from 'node:url';8import { createKernel, createMission, type Kernel } from '@aos/kernel';9import { afterAll, describe, expect, it } from 'vitest';10import { ConfigError, loadConfig } from '../src/config.js';1112const MAIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'main.ts');13const FAKE_KEY = 'sk-ant-test-0000-not-a-real-key';14const children = new Set<ChildProcess>();15const cleanups: (() => Promise<void> | void)[] = [];16afterAll(async () => {17 for (const c of children) c.kill('SIGKILL');18 for (const fn of cleanups.reverse()) await fn();19});2021interface Proc {22 child: ChildProcess;23 out: () => string;24 exit: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;25}26function runMain(env: Record<string, string>): Proc {27 // A clean environment: the developer's own keys must not leak into the test.28 const base = { PATH: process.env.PATH ?? '', HOME: os.homedir(), USER: os.userInfo().username, LANG: 'C' };29 const child = spawn(process.execPath, ['--import', 'tsx', MAIN], { env: { ...base, ...env }, stdio: ['ignore', 'pipe', 'pipe'] });30 children.add(child);31 let buf = '';32 child.stdout?.on('data', (d: Buffer) => (buf += d.toString()));33 child.stderr?.on('data', (d: Buffer) => (buf += d.toString()));34 const exit = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => child.once('exit', (code, signal) => {35 children.delete(child);36 resolve({ code, signal });37 }));38 return { child, out: () => buf, exit };39}40async function waitFor<T>(fn: () => Promise<T | null | undefined | false>, what: string, timeoutMs = 30_000): Promise<T> {41 const end = Date.now() + timeoutMs;42 for (;;) {43 const v = await fn();44 if (v) return v;45 if (Date.now() > end) throw new Error(`timeout waiting for ${what}`);46 await new Promise((r) => setTimeout(r, 50));47 }48}4950describe('configuration', () => {51 const ok = { DATABASE_URL: 'postgres:///x', AOS_DATA_DIR: '/tmp/aos-data', ANTHROPIC_API_KEY: FAKE_KEY };52 it('parses a valid environment', () => {53 const c = loadConfig({ ...ok, AOS_MAX_CONCURRENT_RUNS: '2', AOS_WORKER_ID: 'w1', AOS_WORKSPACE_ROOT: '/srv/ws' });54 expect(c).toMatchObject({ concurrency: 2, workerId: 'w1', workspaceRoot: '/srv/ws', providers: { anthropic: true, openai: false }, logLevel: 'info' });55 expect(JSON.stringify(c)).not.toContain(FAKE_KEY);56 expect(loadConfig(ok).concurrency).toBe(4);57 });58 it('refuses to start without a provider key, a database or a data dir — one clear message', () => {59 const err = (() => {60 try {61 loadConfig({ AOS_MAX_CONCURRENT_RUNS: 'many', ANTHROPIC_API_KEY: ' ' });62 } catch (e) {63 return e;64 }65 })();66 expect(err).toBeInstanceOf(ConfigError);67 const msg = (err as Error).message;68 expect(msg).toMatch(/DATABASE_URL is not set/);69 expect(msg).toMatch(/AOS_DATA_DIR is not set/);70 expect(msg).toMatch(/No model provider key is configured: set ANTHROPIC_API_KEY and\/or OPENAI_API_KEY/);71 expect(msg).toMatch(/AOS_MAX_CONCURRENT_RUNS must be an integer/);72 });73});7475describe('worker process', () => {76 it('exits 1 at once, with the reason on stderr, when no provider key is configured', async () => {77 const p = runMain({ DATABASE_URL: 'postgres:///aos_nonexistent?host=/tmp', AOS_DATA_DIR: '/tmp/aos-data-unused' });78 const { code } = await p.exit;79 expect(code).toBe(1);80 expect(p.out()).toMatch(/Agent OS worker cannot start:\n\s+- No model provider key is configured/);81 });8283 it('migrates, heartbeats, blocks a mission whose provider key is missing, and stops gracefully on SIGTERM', async () => {84 const name = `aos_test_${randomBytes(6).toString('hex')}`;85 execFileSync('createdb', [name], { stdio: 'pipe' });86 const url = `postgres:///${name}?host=/tmp`;87 const tmp = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'aos-worker-test-'));88 let k: Kernel | null = null;89 cleanups.push(async () => {90 await k?.close().catch(() => undefined);91 try {92 execFileSync('dropdb', ['--force', '--if-exists', name], { stdio: 'pipe' });93 } catch {94 /* best effort */95 }96 fs.rmSync(tmp, { recursive: true, force: true });97 });98 const ws = path.join(tmp, 'root', 'p1');99 fs.mkdirSync(ws, { recursive: true });100101 const p = runMain({102 DATABASE_URL: url, AOS_DATA_DIR: path.join(tmp, 'data'), AOS_WORKSPACE_ROOT: path.join(tmp, 'root'), ANTHROPIC_API_KEY: FAKE_KEY,103 AOS_WORKER_ID: 'test-worker', AOS_MAX_CONCURRENT_RUNS: '2', AOS_LOG_LEVEL: 'info',104 });105 k = createKernel({ databaseUrl: url, dataDir: path.join(tmp, 'data'), poolMax: 3 });106 const kernel = k;107 // the worker applies the migrations itself, seeds prices and registers its heartbeat108 await waitFor(async () => {109 try {110 return (await kernel.pool.query(`SELECT id FROM workers WHERE id = 'test-worker'`)).rows.length === 1;111 } catch {112 return false;113 }114 }, 'the worker heartbeat');115 expect(Number((await kernel.pool.query('SELECT count(*) AS n FROM model_prices')).rows[0].n)).toBeGreaterThan(0);116117 // OpenAI key absent: the mission ends blocked, the worker stays up118 const owner = (await kernel.pool.query(`INSERT INTO users (username, password_hash) VALUES ('owner', 'x') RETURNING id`)).rows[0].id as string;119 const { rootRunId } = await createMission({ ownerId: owner, objective: 'Say hello', doneCriteria: ['hello said'], provider: 'openai', model: 'gpt-6-luna', budgetUsd: 0.05, workspaceAbs: ws }, kernel);120 const run = await waitFor(async () => {121 const r = (await kernel.pool.query('SELECT status, result FROM runs WHERE id = $1', [rootRunId])).rows[0] as { status: string; result: { summary: string } | null };122 return r.status === 'blocked' || r.status === 'failed' ? r : null;123 }, 'the mission to end');124 expect(run.status).toBe('blocked');125 expect(run.result?.summary).toMatch(/OPENAI_API_KEY\) is not configured on the worker/);126 expect(p.child.exitCode).toBeNull();127128 p.child.kill('SIGTERM');129 const { code, signal } = await p.exit;130 expect({ code, signal }).toEqual({ code: 0, signal: null });131 const out = p.out();132 expect(out).toMatch(/"msg":"worker starting"/);133 expect(out).toMatch(/"msg":"worker stopped"/);134 expect(out).toMatch(/provider key missing/);135 expect(out).not.toContain(FAKE_KEY); // never a key in the logs136 expect(out).not.toContain('Say hello'); // nor the mission text137 expect(out).not.toContain(name); // nor the connection string138 });139});140