gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Branchmain3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmremain/tests/e2e-agent.mjs

34.2 KB

  1. // tests/e2e-agent.mjs — mission 035 (antcolony#14): the scheduler on its own host + an AGENT per host, against the SAME kind
  2. // of own tickets + ident as tests/e2e.mjs (never the live ones), FAKE claude only.
  3. // COLONY_E2E_PORT_BASE=8710 node tests/e2e-agent.mjs (ident = base+1, tickets = base+2, scheduler = base+3, agent pool base+4 … base+9)
  4. // Checks: no agent → nothing starts; a wrong token is refused; agent connects → a ticket is worked THERE and posted; the brief
  5. // carries the agent's port range and the scheduler's assembly; the scheduler dies mid-session → the session goes on, its end
  6. // is delivered after the reconnect; the quota is the agent's (no start above the reserve); an agent that stops is OFFLINE.
  7. import { spawn, spawnSync } from 'node:child_process';
  8. import { cpSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
  9. import { dirname, join, resolve } from 'node:path';
  10. import { fileURLToPath } from 'node:url';
  11. import { createServer } from 'node:http';
  12. process.env.COLONY_SANDBOX = process.env.COLONY_SANDBOX || 'off'; // antcolony#18: the fake claude lives outside any box (tests/e2e-sandbox.mjs tests the box)
  13. const HERE = dirname(fileURLToPath(import.meta.url));
  14. const APP = resolve(HERE, '..');
  15. const WORK = join(APP, '.scratch', 'e2e-agent');
  16. const PB = Number(process.env.COLONY_E2E_PORT_BASE || 8710);
  17. const IDENT_PORT = PB + 1, TICKETS_PORT = PB + 2;
  18. if (PB < 8700 || TICKETS_PORT > 8799) throw new Error('ports must stay in 8700-8799');
  19. const TICKETS_DIR = process.env.COLONY_E2E_TICKETS_DIR || '/media/STORAGE/projects/tickets.worldapi.org';
  20. const IDENT_DIR = process.env.COLONY_E2E_IDENT_DIR || '/media/STORAGE/projects/ident.worldapi.org';
  21. const BASE = `http://127.0.0.1:${TICKETS_PORT}`;
  22. const J = JSON.stringify;
  23. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  24. let passes = 0, failures = 0;
  25. function check(label, ok, detail = '') {
  26. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : '\n ' + String(detail).split('\n').join('\n ')}`);
  27. if (ok) passes++; else failures++;
  28. }
  29. // mission 023 (README "Ticket texts are written for the creator", hard limit: at most 5 short lines): the lines
  30. // the creator reads = the non-empty lines of a text without the machine lines (`colony-…: …`)
  31. // mission 027: the Test steps are a numbered Markdown list ("1. …") under ONE "**Test:**" line — the header counts as one
  32. // line (as the one-line "**Test:** 1) … 2) …" did before), the 2–4 step lines are not counted
  33. const readableLines = (t) => String(t || '').split('\n').filter(l => l.trim() !== '' && !/^colony-[a-z-]+: /.test(l) && !/^\d+\. /.test(l)).length;
  34. // ---- copies ----------------------------------------------------------------------------------
  35. rmSync(WORK, { recursive: true, force: true });
  36. mkdirSync(WORK, { recursive: true });
  37. // mission 030 (ticket antcolony#1): TEST ISOLATION. The Hybriel interpreter loads the `.env` BESIDE THE ENTRY SCRIPT
  38. // (scheduler.hl — not the cwd; the real environment wins, an EMPTY variable counts as set): run from this folder, every
  39. // `./colony` call of the e2e got the LIVE settings (max turns, week limit, token file, CLAUDE_CONFIG_DIR …) → 6 failures.
  40. // So the scheduler under test runs from a COPY of this folder WITHOUT `.env` (and without the live runs / librarian /
  41. // registry / logs): whatever the live `.env` holds — today or tomorrow — cannot reach a test. On top, the environment
  42. // every call gets is DEFINED: no inherited COLONY_* / FAKE_* variable of the caller's shell (except the COLONY_E2E_*
  43. // controls above), and — unless COLONY_E2E_REAL=1 — a `claude` guard first on PATH that refuses and logs (fake only).
  44. const CODE = join(WORK, 'app');
  45. const CODE_SKIP = new Set(['.env', '.scratch', 'runs', 'sessions', 'briefs', 'logs', 'librarian', 'projects', 'mcp-needs-auth-cache.json', '.git']);
  46. // entry by entry (node refuses to copy a folder into its own subfolder; .scratch is skipped anyway)
  47. mkdirSync(CODE, { recursive: true });
  48. for (const e of readdirSync(APP)) if (!CODE_SKIP.has(e)) cpSync(join(APP, e), join(CODE, e), { recursive: true });
  49. if (existsSync(join(CODE, '.env'))) throw new Error('a .env was copied into the scheduler under test — refusing to start');
  50. const REAL = process.env.COLONY_E2E_REAL === '1';
  51. const GUARD_DIR = join(WORK, 'claude-guard'), GUARD_LOG = join(WORK, 'claude-guard.log');
  52. mkdirSync(GUARD_DIR, { recursive: true });
  53. writeFileSync(join(GUARD_DIR, 'claude'), `#!/bin/sh\necho "$(date -u +%FT%TZ) cwd=$(pwd) args=$*" >> '${GUARD_LOG}'\necho 'e2e guard: the REAL claude was called — refused (fake claude only; COLONY_E2E_REAL=1 allows it)' >&2\nexit 97\n`, { mode: 0o755 });
  54. const BASE_ENV = { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !(/^(COLONY_|FAKE_)/.test(k) && !k.startsWith('COLONY_E2E_')))), COLONY_SANDBOX: 'off' };
  55. if (!REAL) BASE_ENV.PATH = GUARD_DIR + ':' + (process.env.PATH || '');
  56. const SKIP = new Set(['.env', 'storage', '.sessions', '.scratch', 'server.log', 'server.pid', 'testapp', '.git']);
  57. const TCODE = join(WORK, 'tickets-code');
  58. cpSync(TICKETS_DIR, TCODE, { recursive: true, filter: (src) => {
  59. const rel = src.slice(TICKETS_DIR.length).replace(/^\/+/, '');
  60. return rel === '' || !SKIP.has(rel.split('/')[0]);
  61. } });
  62. if (existsSync(join(TCODE, '.env'))) throw new Error('a .env was copied — refusing to start');
  63. const { startIdent } = await import(join(TCODE, 'tests', 'identkit.mjs'));
  64. let ident = null, tickets = null, tlog = '';
  65. const daemons = []; // mission 020: `colony run` processes (wrapper + scheduler pid) — killed at the end if still there
  66. const fakePids = () => { try { return readdirSync(join(WORK, 'fake-calls')).map(f => Number(f.split('-')[1])); } catch { return []; } };
  67. // a pid is killed only while its command line is still ours (pids get reused)
  68. const isOurs = (pid, needle) => { try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8').includes(needle); } catch { return false; } };
  69. const killOurs = (pid, needle) => { if (pid && isOurs(pid, needle)) { try { process.kill(pid, 'SIGKILL'); return true; } catch {} } return false; };
  70. // mission 026: servers the FAKE sessions leave behind (FAKE_SERVER_LOG) + the e2e's own "pre-existing" server — the ones the
  71. // scheduler must NOT stop are killed here by the test (only while their command line is still ours)
  72. const SERVER_LOG = join(WORK, 'fake-servers.jsonl');
  73. const ownServerPids = [];
  74. const serverRows = () => { try { return readFileSync(SERVER_LOG, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)); } catch { return []; } };
  75. const killTestServers = () => {
  76. let n = 0;
  77. for (const r of serverRows()) if (killOurs(r.pid, 'fake-left-server')) n++;
  78. for (const pid of ownServerPids) if (killOurs(pid, 'e2e-preexisting-server')) n++;
  79. return n;
  80. };
  81. const cleanup = async () => {
  82. const ks = killTestServers(); if (ks) console.log('cleanup: killed ' + ks + ' test server(s) left over');
  83. for (const d of daemons) { if (d.proc.exitCode === null && d.proc.signalCode === null) { try { d.proc.kill('SIGKILL'); } catch {} } killOurs(d.schedPid, 'scheduler.hl'); }
  84. for (const pid of fakePids()) { if (killOurs(pid, 'fake-claude.mjs')) console.log('cleanup: killed a leftover fake claude pid ' + pid); }
  85. if (tickets) { try { tickets.kill('SIGTERM'); } catch {} await new Promise(r => { if (tickets.exitCode !== null || tickets.signalCode !== null) return r(); tickets.once('exit', r); setTimeout(r, 3000); }); }
  86. if (ident) await ident.stop();
  87. writeFileSync(join(WORK, 'tickets.log'), tlog);
  88. if (ident) writeFileSync(join(WORK, 'ident.log'), ident.log());
  89. };
  90. // ---- helpers ---------------------------------------------------------------------------------
  91. let TOKEN = null;
  92. // antcolony#31: tickets speaks the new states (progress / review / pending / done / reopened); this suite's expectations are
  93. // written in the scheduler's own vocabulary (in progress / awaiting creator / on hold / confirmed / rejected — lib/tickets.hl
  94. // translates the same way), so every JSON read is translated back: a "Question…" ticket that is pending = awaiting creator.
  95. const OLD = { progress: 'in progress', review: 'awaiting creator', pending: 'on hold', done: 'confirmed', reopened: 'rejected' };
  96. const oldNames = (v) => {
  97. if (Array.isArray(v)) { v.forEach(oldNames); return v; }
  98. if (v === null || typeof v !== 'object') return v;
  99. if (typeof v.state === 'string' && OLD[v.state]) v.state = (v.state === 'pending' && /^Question\b/.test(v.subject || '')) ? 'awaiting creator' : OLD[v.state];
  100. // the server adds an `assign` event when a ticket goes to review / pending with nobody assigned (tickets#20): not part of these checks
  101. if (Array.isArray(v.events)) v.events = v.events.filter(e => e.kind !== 'assign');
  102. if (v.kind === 'state') { if (OLD[v.from]) v.from = OLD[v.from]; if (OLD[v.to]) v.to = OLD[v.to]; }
  103. for (const k of Object.keys(v)) if (v[k] && typeof v[k] === 'object') oldNames(v[k]);
  104. return v;
  105. };
  106. const api = async (method, path, body, accept) => {
  107. const headers = { 'user-agent': 'colony-e2e' };
  108. if (body) headers['content-type'] = 'application/json';
  109. if (accept) headers.accept = accept;
  110. if (method !== 'GET') headers.authorization = 'Bearer ' + TOKEN;
  111. const r = await fetch(BASE + path, { method, headers, body: body ? J(body) : undefined });
  112. const text = await r.text();
  113. let json = null; try { json = oldNames(JSON.parse(text)); } catch {}
  114. if (method !== 'GET' && !(r.status === 200 || r.status === 201)) throw new Error(`${method} ${path} → ${r.status} ${text}`);
  115. return { status: r.status, json, text, ct: r.headers.get('content-type') };
  116. };
  117. let emitI = 0;
  118. const temit = async (event, payload, cookie) => {
  119. const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: ++emitI, event, payload }) });
  120. const raw = await r.text(); let j = null; try { j = JSON.parse(raw); } catch {}
  121. return { value: j && j.value, raw };
  122. };
  123. const REG = join(WORK, 'projects');
  124. const TOKFILE = join(WORK, 'colony-token');
  125. // mission 029: the librarian's folder and the global decisions file always point into .scratch/e2e — no e2e call (`run`,
  126. // `brief`, `work`, `cycle` run the librarian first) ever writes the real librarian/ or templates/
  127. const colonyEnv = (extra = {}) => ({ ...BASE_ENV, COLONY_TICKETS_URL: BASE, COLONY_PROJECTS_DIR: REG, COLONY_TOKEN_FILE: TOKFILE, COLONY_USER_AGENT: 'colony-e2e',
  128. COLONY_LIBRARIAN_DIR: join(WORK, 'librarian'), COLONY_DECISIONS_GLOBAL: join(WORK, 'decisions-global.md'), ...extra });
  129. const colony = (args, extra, timeout = 60000) => {
  130. const r = spawnSync(join(CODE, 'colony'), args, { cwd: WORK, env: colonyEnv(extra), encoding: 'utf8', timeout });
  131. return (r.stdout || '') + (r.stderr || '');
  132. };
  133. const snapshot = async () => {
  134. const all = (await api('GET', '/api/tickets')).json.tickets;
  135. const out = [];
  136. for (const t of all) {
  137. const d = (await api('GET', `/api/projects/${t.project}/tickets/${t.number}`)).json;
  138. out.push({ ref: t.project + t.ref, state: t.state, updatedMs: t.updatedMs, events: d.events.map(e => [e.seq, e.kind, e.author, e.text]) });
  139. }
  140. out.sort((a, b) => a.ref < b.ref ? -1 : 1);
  141. return J(out);
  142. };
  143. const ticket = async (p, n) => (await api('GET', `/api/projects/${p}/tickets/${n}`)).json;
  144. let CTOKEN = null; // mission 022: the creator's API token
  145. const capi = async (method, path, body) => { const save = TOKEN; TOKEN = CTOKEN; try { return await api(method, path, body); } finally { TOKEN = save; } };
  146. try {
  147. // ---- ident + tickets (own instances) --------------------------------------------------------
  148. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(WORK, 'ident'), port: IDENT_PORT });
  149. const colonyAcct = await ident.signIn('[email protected]');
  150. const app = await ident.registerApp(colonyAcct, 'tickets (colony e2e)', [BASE]);
  151. // mission 022: a CREATOR (only the creator may reject / confirm) — its per-app id is TICKETS_CREATOR_IDENTITY
  152. const creatorAcct = await ident.signIn('[email protected]');
  153. const CREATOR_ID = await ident.exchange(app, await ident.selectorCode(creatorAcct, app, BASE));
  154. check('own ident runs from a copy without .env, tickets registered in it', /^pk_/.test(app.key) && !existsSync(join(WORK, 'ident', 'ident-code', '.env')));
  155. const store = join(WORK, 'tickets-data');
  156. tickets = spawn(join(TCODE, 'bin/hybriel'), ['project.hl'], { cwd: TCODE, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env,
  157. TICKETS_PORT: String(TICKETS_PORT), TICKETS_STORAGE: join(store, 'mpackdb'), TICKETS_SESSIONS: join(store, 'sessions'), HL_HOST: '127.0.0.1', TICKETS_WATCH: '0',
  158. IDENT_URL: ident.base, IDENT_EXCHANGE_URL: ident.base, TICKETS_PUBLIC_URL: BASE, IDENT_API_KEY: app.key, IDENT_API_SECRET: app.secret, TICKETS_CREATOR_IDENTITY: CREATOR_ID } });
  159. tickets.stdout.on('data', d => tlog += d); tickets.stderr.on('data', d => tlog += d);
  160. let up = false;
  161. for (let i = 0; i < 80 && !up; i++) { try { up = (await fetch(BASE + '/')).ok; } catch {} if (!up) await sleep(250); }
  162. if (!up) throw new Error('tickets did not come up\n' + tlog);
  163. // the user "Colony": login button's server half → display name → API token (as the creator will do on /you)
  164. const first = await fetch(BASE + '/');
  165. const cookie = (first.headers.get('set-cookie') || '').split(';')[0];
  166. const cb = await fetch(BASE + '/login/callback?ident_code=' + await ident.selectorCode(colonyAcct, app, BASE), { headers: { cookie }, redirect: 'manual' });
  167. await temit('saveDisplayName', ['Colony'], cookie);
  168. const tk = await temit('tokenCreate', ['scheduler e2e'], cookie);
  169. TOKEN = tk.value && tk.value.token;
  170. check('tickets (own copy) runs; user "Colony" logged in via ident and made an API token', cb.status === 302 && /^tkt_[0-9a-f]{48}$/.test(TOKEN || ''), cb.status + ' ' + tk.raw);
  171. writeFileSync(TOKFILE, TOKEN + '\n', { mode: 0o600 });
  172. // antcolony#31: projects are records with members — the creator (admin) opens a project the first time a ticket is made in it
  173. // and makes Colony a member with the role `edit`
  174. const ccookie = (await fetch(BASE + '/')).headers.get('set-cookie').split(';')[0];
  175. await fetch(BASE + '/login/callback?ident_code=' + await ident.selectorCode(creatorAcct, app, BASE), { headers: { cookie: ccookie }, redirect: 'manual' });
  176. await temit('saveDisplayName', ['Creator'], ccookie);
  177. const CTOKEN = (await temit('tokenCreate', ['creator e2e'], ccookie)).value.token;
  178. const opened = new Set();
  179. const openProject = async (slug) => {
  180. if (opened.has(slug)) return;
  181. opened.add(slug);
  182. for (const [path, body] of [['/api/projects', { title: slug, slug }], [`/api/projects/${slug}/members`, { user: 'Colony', role: 'edit' }]]) {
  183. const r = await fetch(BASE + path, { method: 'POST', headers: { 'user-agent': 'colony-e2e', 'content-type': 'application/json', authorization: 'Bearer ' + CTOKEN }, body: J(body) });
  184. if (r.status > 201) throw new Error(`${path} → ${r.status} ${await r.text()}`);
  185. }
  186. };
  187. // ---- registry + the dev folder of the agent's host ------------------------------------------------------------
  188. const REGA = join(WORK, 'projects-agent');
  189. const AGDEV = join(WORK, 'agdev');
  190. mkdirSync(REGA, { recursive: true }); mkdirSync(AGDEV, { recursive: true });
  191. writeFileSync(join(AGDEV, 'CONCEPT.md'), '# ag\nA test project of the agent split: every ticket is a tiny file task in this folder.\n');
  192. writeFileSync(join(AGDEV, 'README.md'), '# ag\nTest project. Nothing else to read.\n');
  193. writeFileSync(join(REGA, 'ag.json'), J({ name: 'ag', dev: { host: 'e2e-host', folder: AGDEV }, concept: AGDEV + '/CONCEPT.md', dependsOn: [], needs: ['Chrome'] }, null, 1));
  194. writeFileSync(join(REGA, 'gp.json'), J({ name: 'gp', dev: { host: 'e2e-host', folder: join(WORK, 'gpdev') }, concept: AGDEV + '/CONCEPT.md', dependsOn: [], needs: ['chrome', 'gpu'] }, null, 1));
  195. writeFileSync(join(REGA, 'sh.json'), J({ name: 'sh', dev: { host: 'e2e-b', folder: join(WORK, 'shdev') }, concept: AGDEV + '/CONCEPT.md', dependsOn: [] }, null, 1));
  196. writeFileSync(join(REGA, 'sc.json'), J({ name: 'sc', dev: { host: 'e2e-c', folder: join(WORK, 'scdev') }, concept: AGDEV + '/CONCEPT.md', dependsOn: [] }, null, 1));
  197. mkdirSync(join(WORK, 'shdev'), { recursive: true }); mkdirSync(join(WORK, 'scdev'), { recursive: true });
  198. writeFileSync(join(REGA, 'sd.json'), J({ name: 'sd', dev: { host: 'e2e-c', folder: join(WORK, 'sddev') }, concept: AGDEV + '/CONCEPT.md', dependsOn: [] }, null, 1));
  199. mkdirSync(join(WORK, 'sddev'), { recursive: true });
  200. await openProject('antcolony'); // the digest / question inbox of the scheduler
  201. const mk = async (project, subject, summary) => { await openProject(project); const t = (await api('POST', `/api/projects/${project}/tickets`, { subject, summary })).json.ticket; await sleep(30); return t; };
  202. const FAKE = join(CODE, 'tests', 'fake-claude.mjs');
  203. const RUNS_S = join(WORK, 'runs-scheduler'), RUNS_A = join(WORK, 'runs-agent');
  204. const FLD = join(WORK, 'fake-calls'), FLOG = join(WORK, 'fake-claude.json');
  205. const UF = join(WORK, 'usage-percent'), WF = join(WORK, 'week-percent');
  206. writeFileSync(UF, '5\n'); writeFileSync(WF, '41\n');
  207. const AGTOKEN = join(WORK, 'agent-token');
  208. writeFileSync(AGTOKEN, 'agent-secret-e2e\n', { mode: 0o600 });
  209. const SPORT = PB + 3, SURL = `http://127.0.0.1:${SPORT}`;
  210. const sEnv = (extra = {}) => ({ COLONY_HOST: 'sched-host', COLONY_RUNS_DIR: RUNS_S, COLONY_PROJECTS_DIR: REGA, COLONY_AGENT_TOKEN_FILE: AGTOKEN, COLONY_LIBRARIAN: 'off',
  211. COLONY_CLAUDE: FAKE, FAKE_USAGE_FILE: UF, FAKE_WEEK_FILE: WF, FAKE_LOG_DIR: join(WORK, 'fake-calls-scheduler'), ...extra });
  212. const aEnv = (extra = {}) => ({ COLONY_HOST: 'e2e-host', COLONY_CAPABILITIES: 'chrome, Deploy', COLONY_RUNS_DIR: RUNS_A, COLONY_PROJECTS_DIR: REGA, COLONY_AGENT_TOKEN_FILE: AGTOKEN, COLONY_SCHEDULER_URL: SURL,
  213. COLONY_CLAUDE: FAKE, FAKE_CLAUDE_MODE: 'good', FAKE_CONTROLLER_MODE: 'pass', FAKE_USAGE_FILE: UF, FAKE_WEEK_FILE: WF, FAKE_LOG_DIR: FLD, FAKE_CLAUDE_LOG: FLOG, FAKE_CONTROLLER_LOG: '',
  214. COLONY_PORT_POOL: `${PB + 4}-${PB + 9}`, COLONY_PORTS_PER_SESSION: '3', COLONY_AGENT_POLL: '1', FAKE_CLAUDE_SLEEP: '4', COLONY_AGENT_QUOTA_EVERY: '2', ...extra });
  215. const start = (args, env) => {
  216. const proc = spawn(join(CODE, 'colony'), args, { cwd: WORK, env: colonyEnv(env), stdio: ['ignore', 'pipe', 'pipe'] });
  217. const d = { proc, text: '', schedPid: null, exited: null };
  218. d.exited = new Promise(r => proc.once('exit', (code, sig) => r({ code, sig })));
  219. const add = (b) => { d.text += b; const m = d.text.match(/scheduler pid (\d+)/); if (m) d.schedPid = Number(m[1]); };
  220. proc.stdout.on('data', add); proc.stderr.on('data', add);
  221. d.waitFor = async (re, ms = 30000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { const m = d.text.match(re); if (m) return m; await sleep(100); } return null; };
  222. d.kill = () => { try { proc.kill('SIGKILL'); } catch {} killOurs(d.schedPid, 'scheduler.hl'); };
  223. d.term = () => { try { proc.kill('SIGTERM'); } catch {} };
  224. daemons.push(d);
  225. return d;
  226. };
  227. const waitState = async (p, n, st, ms = 60000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if ((await ticket(p, n)).ticket.state === st) return true; await sleep(300); } return false; };
  228. const post = async (path, token, method = 'POST', body = '{"host":"x","parallel":1,"running":[],"events":[]}') => { const r = await fetch(SURL + path, { method, headers: token ? { authorization: 'Bearer ' + token } : {}, body: method === 'POST' ? body : undefined }); return { status: r.status, text: await r.text() }; };
  229. const workerCalls = (dir) => { try { return readdirSync(dir).filter(f => !/-usage\.json$/.test(f)).length; } catch { return 0; } };
  230. // ---- 1. the scheduler alone: no agent → nothing starts, the ticket waits --------------------------------------------
  231. const t1 = await mk('ag', 'agent task one', 'Create a file `one.txt` with the content `one`. Verify it.');
  232. const tg = await mk('gp', 'needs a gpu', 'Create a file `gpu.txt`.');
  233. const sched = start(['run', '--serve', String(SPORT), '--interval', '2', '--agent-ttl', '4'], sEnv());
  234. check('scheduler: RUN line names the agent door and that it starts no worker itself', !!(await sched.waitFor(/RUN: started on sched-host · SCHEDULER for agents on 127\.0\.0\.1:\d+ \(starts no worker itself/, 15000)), sched.text);
  235. check('scheduler alone: the ticket waits — "no agent online on e2e-host"; still open; no Claude call', !!(await sched.waitFor(new RegExp(`ag#${t1.number} waits — no agent online on e2e-host`, 'm'), 15000)) && (await ticket('ag', t1.number)).ticket.state === 'open' && workerCalls(join(WORK, 'fake-calls-scheduler')) === 0, sched.text);
  236. // ---- 2. the door: wrong / missing token, wrong path -------------------------------------------------------------------
  237. let r = await post('/agent/sync', 'wrong');
  238. check('the door refuses a wrong token (401)', r.status === 401, J(r));
  239. r = await post('/agent/sync', null);
  240. check('the door refuses a request without a token (401)', r.status === 401, J(r));
  241. r = await post('/nope', 'agent-secret-e2e');
  242. check('the door answers 404 for any other path', r.status === 404, J(r));
  243. r = await post('/agent/sync', 'agent-secret-e2e', 'POST', '{"host":');
  244. check('the door answers 400 for a body that is not JSON', r.status === 400, J(r));
  245. const noRefuse = !/AGENT: x connected/.test(sched.text);
  246. check('a refused request registered no agent', noRefuse, sched.text);
  247. // ---- 3. an agent connects → the ticket is worked ON ITS HOST and posted ---------------------------------------------------
  248. const agent = start(['agent', '--poll', '1'], aEnv());
  249. check('agent: AGENT line (host, scheduler, poll, quota rule, ports)', !!(await agent.waitFor(/AGENT: started on e2e-host → scheduler http:\/\/127\.0\.0\.1:\d+ · poll 1 s · parallel 1 · offers chrome, deploy · quota reserve 70%/, 15000)), agent.text);
  250. check('agent: connects, and reads the quota → accepting work', !!(await agent.waitFor(/AGENT: connected to /, 20000)) && !!(await agent.waitFor(/AGENT: quota ok \(5% of 5 h, week 41%\) → accepting work/, 20000)), agent.text);
  251. check('capabilities: the scheduler shows what the agent offers, and gp (needs gpu) waits naming what is missing', !!(await sched.waitFor(/AGENT: e2e-host connected · offers chrome, deploy · /, 15000)) && !!(await sched.waitFor(new RegExp(`gp#${tg.number} waits — e2e-host does not offer: gpu`), 15000)) && (await ticket('gp', tg.number)).ticket.state === 'open', sched.text);
  252. check('scheduler: "AGENT: e2e-host connected"', !!(await sched.waitFor(/AGENT: e2e-host connected · offers chrome, deploy · parallel 1/, 15000)), sched.text);
  253. check('the ticket is worked by the agent: → in progress → awaiting creator (report + controller pass)', await waitState('ag', t1.number, 'awaiting creator', 90000), agent.text + '\n----\n' + sched.text);
  254. const d1 = await ticket('ag', t1.number);
  255. const rep1 = d1.events.filter(e => e.kind === 'comment').map(e => e.text).join('\n');
  256. check('the comment carries the report marker + "controller pass"; the lease was posted by the agent\'s host', /colony-report: s-\d{8}T\d{4}-[0-9a-f]{6} · sha256 [0-9a-f]{12} · controller pass/.test(rep1) && d1.events.some(e => /colony-lease: s-\S+ · host e2e-host · started/.test(e.text || '')), J(d1.events.map(e => e.text)));
  257. const s1 = (rep1.match(/colony-report: (s-\d{8}T\d{4}-[0-9a-f]{6})/) || [])[1];
  258. check('the worker ran in the AGENT\'s runs folder, not the scheduler\'s', !!s1 && existsSync(join(RUNS_A, s1, 'report.json')) && !existsSync(join(RUNS_S, s1)), s1);
  259. const brief1 = s1 && existsSync(join(RUNS_A, s1, 'brief.md')) ? readFileSync(join(RUNS_A, s1, 'brief.md'), 'utf8') : '';
  260. check('the brief the scheduler assembled carries THIS agent\'s port range (no marker left), the ticket, the conventions', new RegExp(`Ports for everything you start: ${PB + 4}–${PB + 6} only`).test(brief1) && !/@PORT_(FROM|TO)@|@AGENT@/.test(brief1) && brief1.includes('agent task one') && brief1.includes('## Conventions for all apps') && brief1.includes(`session \`${s1}\``), brief1.slice(0, 600));
  261. check('the SCHEDULER started no Claude (worker / controller) itself', workerCalls(join(WORK, 'fake-calls-scheduler')) === 0, '');
  262. check('the FAKE worker + controller of the agent ran with the agent\'s port env', existsSync(FLD) && readdirSync(FLD).some(f => f.endsWith('.json') && !/-usage\.json$/.test(f)), '');
  263. check('scheduler + agent both print SESSION END (posted); the scheduler names the host', !!(await agent.waitFor(new RegExp(`SESSION END ag#${t1.number} ${s1} · posted`))) && !!(await sched.waitFor(new RegExp(`SESSION END ag#${t1.number} ${s1} · posted[^\\n]* · on e2e-host`))), agent.text + '\n----\n' + sched.text);
  264. check('the scheduler holds no state of its own: no run folder for the session, no file written beyond its probe folder', !existsSync(join(RUNS_S, s1 || 'none')), '');
  265. // ---- 4. the scheduler dies mid-session: the work goes on, the end is delivered after the reconnect -------------------------
  266. const t2 = await mk('ag', 'agent task two', 'Create a file `two.txt` with the content `two`. Verify it.');
  267. agent.text += '';
  268. const mark2 = agent.text.length;
  269. // a slow fake worker (5 s): started by the NEXT command — restart nothing, the agent's env is fixed, so this ticket runs at the normal speed;
  270. // the scheduler is killed right after the start and comes back only after the session ended
  271. const t2started = await waitState('ag', t2.number, 'in progress', 60000);
  272. check('second ticket: the agent started it (in progress)', t2started, agent.text);
  273. sched.kill();
  274. const t2done = await waitState('ag', t2.number, 'awaiting creator', 90000);
  275. check('scheduler killed while the session ran: the worker finished and posted ON ITS OWN (→ awaiting creator)', t2done, agent.text);
  276. check('agent: "scheduler … not reachable — 1 session(s) keep running / N event(s) buffered"', !!(await agent.waitFor(/AGENT: scheduler http:\/\/127\.0\.0\.1:\d+ not reachable \(no answer\) — \d session\(s\) keep running, \d event\(s\) buffered/, 20000)), agent.text);
  277. check('agent: the SESSION END event is buffered while the scheduler is away', !!(await agent.waitFor(/SESSION END ag#\d+ s-\S+ · posted/, 30000)) && /1 event\(s\) buffered|\d event\(s\) buffered/.test(agent.text.slice(mark2)), agent.text.slice(mark2));
  278. const sched2 = start(['run', '--serve', String(SPORT), '--interval', '2', '--agent-ttl', '4', '--account-settle', '3'], sEnv());
  279. check('scheduler restarted: the agent reconnects on its own ("connected … again · 1 buffered event(s) delivered")', !!(await agent.waitFor(/AGENT: connected to http:\/\/127\.0\.0\.1:\d+ again · [1-9]\d* buffered event\(s\) delivered/, 30000)), agent.text.slice(mark2));
  280. check('scheduler restarted: it learns the agent + prints the session end it missed, exactly once', !!(await sched2.waitFor(/AGENT: e2e-host connected/, 15000)) && !!(await sched2.waitFor(new RegExp(`SESSION END ag#${t2.number} s-\\S+ · posted`), 15000)) && (sched2.text.match(new RegExp(`SESSION END ag#${t2.number} `, 'g')) || []).length === 1, sched2.text);
  281. check('nothing was run twice: ag#2 has exactly one lease start and one report', (await ticket('ag', t2.number)).events.filter(e => /colony-lease: \S+ · host e2e-host · started/.test(e.text || '')).length === 1 && (await ticket('ag', t2.number)).events.filter(e => /colony-report:/.test(e.text || '')).length === 1, '');
  282. // ---- 5. the quota is the agent's: above the reserve nothing is accepted, a start waits ------------------------------------
  283. writeFileSync(UF, '80\n');
  284. check('quota 80% ≥ reserve 70% (agent side, read every 2 s here): "not accepting work — …"', !!(await agent.waitFor(/AGENT: not accepting work — quota 80% of 5 h.* ≥ reserve 70%/, 30000)), agent.text.slice(-500));
  285. const t3 = await mk('ag', 'agent task three', 'Create a file `three.txt` with the content `three`. Verify it.');
  286. check('…the scheduler says the start waits (the quota is the agent\'s call)', !!(await sched2.waitFor(new RegExp(`ag#${t3.number} waits — agent e2e-host is not accepting work \\(quota 80%`), 30000)), agent.text.slice(-600) + '\n----\n' + sched2.text.slice(-800));
  287. check('…and the ticket stays open, no worker started', (await ticket('ag', t3.number)).ticket.state === 'open', '');
  288. writeFileSync(UF, '5\n');
  289. check('quota back to 5%: the agent accepts again and the ticket is worked', !!(await agent.waitFor(/AGENT: quota ok \(5%/, 120000)) && await waitState('ag', t3.number, 'awaiting creator', 90000), agent.text.slice(-600));
  290. // ---- 5b. antcolony#16: the quota belongs to the ACCOUNT — two hosts (e2e-b, e2e-c: hand-made syncs) share the account "shared" --------
  291. const fakeRunning = { 'e2e-b': [], 'e2e-c': [] };
  292. const beat = async (host, extra) => {
  293. const r = await post('/agent/sync', 'agent-secret-e2e', 'POST', J({ host, account: 'shared', parallel: 3, accepting: true, why: null, capabilities: [], running: fakeRunning[host], events: [], ...extra }));
  294. const cmds = r.status === 200 ? JSON.parse(r.text).commands : [];
  295. for (const c of cmds) fakeRunning[host].push({ ref: c.project + '#' + c.number, session: c.session, folder: c.folder, phase: 'worker', ports: '1-2' });
  296. return cmds;
  297. };
  298. const rd = (percent, at, weekly = 20) => ({ percent, weekly, resetsMs: null, at });
  299. // both hosts sync every second for ms; bx / cx = what each reports (a function, evaluated at every beat); returns { b, c } commands received
  300. const rounds = async (ms, bx, cx, until) => { const got = { b: [], c: [] }; const t0 = Date.now(); while (Date.now() - t0 < ms) { got.b.push(...await beat('e2e-b', bx())); got.c.push(...await beat('e2e-c', cx())); if (until && until(got)) break; await sleep(1000); } return got; };
  301. const tb = await mk('sh', 'share task b', 'Create a file `b.txt`.');
  302. const tc = await mk('sc', 'share task c', 'Create a file `c.txt`.');
  303. const at0 = Date.now();
  304. let g = await rounds(20000, () => ({ quota: rd(10, at0), limitUntil: null }), () => ({ quota: null, limitUntil: null }), (x) => x.b.length > 0);
  305. check('account share: host b starts its ticket (reading 10% of the account, own account name "shared")', g.b.length === 1 && g.b[0].kind === 'start' && g.b[0].project === 'sh' && !!(await sched2.waitFor(/AGENT: e2e-b connected · .* · Claude account shared · /, 5000)), J(g) + sched2.text.slice(-600));
  306. g = await rounds(6000, () => ({ quota: rd(10, at0), limitUntil: null }), () => ({ quota: null, limitUntil: null }));
  307. check('account share: host c (never read the quota itself) waits — the start on host b is not in a reading yet', g.c.length === 0 && new RegExp(`sc#${tc.number} waits — Claude account shared: a start on it \\(\\S+\\) is not in the quota reading yet — shared by e2e-b, e2e-c`).test(sched2.text), J(g) + sched2.text.slice(-800));
  308. g = await rounds(20000, () => ({ quota: rd(12, Date.now()), limitUntil: null }), () => ({ quota: null, limitUntil: null }), (x) => x.c.length > 0);
  309. check('account share: a reading taken after the start (12%) → host c starts, on host b\'s reading (its own was empty)', g.c.length === 1 && g.c[0].kind === 'start' && g.c[0].project === 'sc', J(g) + sched2.text.slice(-600));
  310. const tc2 = await mk('sd', 'share task c2', 'Create a file `c2.txt`.');
  311. g = await rounds(6000, () => ({ quota: rd(12, Date.now()), limitUntil: Date.now() + 60000 }), () => ({ quota: null, limitUntil: null }));
  312. check('account share: a usage limit hit on host b stops starts on host c too', g.c.length === 0 && new RegExp(`sd#${tc2.number} waits — Claude account shared: usage limit reached until \\S+ \\(hit on e2e-b\\)`).test(sched2.text), J(g) + sched2.text.slice(-800));
  313. g = await rounds(6000, () => ({ quota: rd(80, Date.now()), limitUntil: null }), () => ({ quota: null, limitUntil: null }));
  314. check('account share: 80% on the account (read by host b) ≥ reserve 70% holds host c as well', g.c.length === 0 && new RegExp(`sd#${tc2.number} waits — Claude account shared: quota 5 h 80% ≥ reserve 70%`).test(sched2.text), J(g) + sched2.text.slice(-800));
  315. check('the ITER line names the account with its reading and its hosts', /agents: .*account shared 80% week 20% \[e2e-b\+e2e-c\]/.test(sched2.text), sched2.text.slice(-800));
  316. // ---- 6. the agent stops (first signal: finish) → the scheduler sees it OFFLINE --------------------------------------------
  317. agent.term();
  318. check('agent: stop requested → nothing more accepted, "AGENT STOPPED"', !!(await agent.waitFor(/AGENT: stop requested/, 20000)) && !!(await agent.waitFor(/AGENT STOPPED — stop requested \(finish\), no session running/, 20000)), agent.text.slice(-500));
  319. check('scheduler: "AGENT: e2e-host is OFFLINE (no sync for 4 s)"', !!(await sched2.waitFor(/AGENT: e2e-host is OFFLINE \(no sync for 4 s\)/, 30000)), sched2.text.slice(-500));
  320. sched2.term();
  321. check('scheduler: SIGTERM → RUN STOPPED, nothing left running', !!(await sched2.waitFor(/RUN STOPPED — stop requested \(finish\)/, 20000)), sched2.text.slice(-400));
  322. // ---- refusals of the agent -----------------------------------------------------------------------------------------------
  323. const bad = (args, env) => spawnSync(join(CODE, 'colony'), args, { cwd: WORK, env: colonyEnv(env), encoding: 'utf8', timeout: 30000 });
  324. let o = bad(['agent'], aEnv({ COLONY_SCHEDULER_URL: '' }));
  325. check('agent without a scheduler URL: REFUSED', /^agent: REFUSED — --scheduler URL/m.test(o.stdout), o.stdout + o.stderr);
  326. o = bad(['agent'], aEnv({ COLONY_AGENT_TOKEN_FILE: join(WORK, 'nope') }));
  327. check('agent without its token file: REFUSED', /^agent: REFUSED — COLONY_AGENT_TOKEN_FILE: no such file/m.test(o.stdout), o.stdout + o.stderr);
  328. o = bad(['run', '--serve', '8713', '--once'], sEnv());
  329. check('run --serve --once: REFUSED', /^run: REFUSED — --serve and --once do not go together/m.test(o.stdout), o.stdout + o.stderr);
  330. o = bad(['run', '--serve', '8713'], sEnv({ COLONY_AGENT_TOKEN_FILE: '' }));
  331. check('run --serve without an agent token: REFUSED', /^run: REFUSED — COLONY_AGENT_TOKEN_FILE is not set/m.test(o.stdout), o.stdout + o.stderr);
  332. writeFileSync(join(WORK, 'sched-1.txt'), sched.text); writeFileSync(join(WORK, 'sched-2.txt'), sched2.text); writeFileSync(join(WORK, 'agent.txt'), agent.text);
  333. if (!REAL) check('the REAL claude was never called (the PATH guard logged nothing)', !existsSync(GUARD_LOG), existsSync(GUARD_LOG) ? readFileSync(GUARD_LOG, 'utf8') : '');
  334. } catch (e) {
  335. check('e2e-agent ran to the end', false, e.stack || String(e));
  336. } finally {
  337. await cleanup();
  338. }
  339. console.log(`\n${passes} passed, ${failures} failed (ports ${IDENT_PORT}/${TICKETS_PORT}/${PB + 3}; logs ${WORK}/)`);
  340. process.exit(failures ? 1 : 0);

Branches

  • mainmain branch

Latest commits

  • 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
  • a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
  • c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
  • 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
  • 7f9660eeState of 2026-09-27, before the move to gitoriamre