gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commitc613d26bc613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mrec613d26b/tests/e2e.mjs

234.7 KB

  1. // tests/e2e.mjs — the scheduler's whole process against its OWN tickets + ident (never the live ones).
  2. //
  3. // node tests/e2e.mjs
  4. // COLONY_E2E_PORT_BASE default 8700 (ident = base+1, tickets = base+2; must stay in 8700–8799)
  5. // COLONY_E2E_REAL=1 also run the REAL `claude -p` (costs quota, see README): a `cycle` whose Sonnet worker
  6. // does a trivial task and whose Sonnet controller passes it; a fake worker claiming a
  7. // file that does not exist, checked by the REAL controller → fail
  8. // COLONY_E2E_REAL_MAXTURNS=1 also the mission-016 real run: Haiku --max-turns 1 → no report → open
  9. // COLONY_E2E_REAL_MODEL model of the real success run (default sonnet: the cheapest with auto mode)
  10. // COLONY_E2E_TICKETS_DIR tickets' code to run (default /media/STORAGE/projects/tickets.worldapi.org;
  11. // e.g. its .scratch/dev-m014 to test the Markdown read view)
  12. // COLONY_E2E_IDENT_DIR ident's code (default /media/STORAGE/projects/ident.worldapi.org)
  13. //
  14. // Both apps are COPIED (without .env / storage / sessions / .scratch) to .scratch/e2e/ and run from the
  15. // copies: other workers change those folders at the same time; we only read them. ident sends no mail
  16. // (tickets' identkit: IDENT_MAIL_SINK). A user "Colony" logs in to our tickets through ident and makes an
  17. // API token (the same path the creator will use for the real Colony identity); projects + tickets are
  18. // seeded over the API; then ./colony next / brief / report run as a user would run them and every
  19. // outcome is read back over the API.
  20. import { spawn, spawnSync } from 'node:child_process';
  21. import { cpSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
  22. import { dirname, join, resolve } from 'node:path';
  23. import { fileURLToPath } from 'node:url';
  24. import { createServer } from 'node:http';
  25. 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)
  26. const HERE = dirname(fileURLToPath(import.meta.url));
  27. const APP = resolve(HERE, '..');
  28. const WORK = join(APP, '.scratch', 'e2e');
  29. const PB = Number(process.env.COLONY_E2E_PORT_BASE || 8700);
  30. const IDENT_PORT = PB + 1, TICKETS_PORT = PB + 2;
  31. if (PB < 8700 || TICKETS_PORT > 8799) throw new Error('ports must stay in 8700-8799');
  32. const TICKETS_DIR = process.env.COLONY_E2E_TICKETS_DIR || '/media/STORAGE/projects/tickets.worldapi.org';
  33. const IDENT_DIR = process.env.COLONY_E2E_IDENT_DIR || '/media/STORAGE/projects/ident.worldapi.org';
  34. const BASE = `http://127.0.0.1:${TICKETS_PORT}`;
  35. const J = JSON.stringify;
  36. const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  37. let passes = 0, failures = 0;
  38. function check(label, ok, detail = '') {
  39. console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${ok ? '' : '\n ' + String(detail).split('\n').join('\n ')}`);
  40. if (ok) passes++; else failures++;
  41. }
  42. // mission 023 (README "Ticket texts are written for the creator", hard limit: at most 5 short lines): the lines
  43. // the creator reads = the non-empty lines of a text without the machine lines (`colony-…: …`)
  44. // mission 027: the Test steps are a numbered Markdown list ("1. …") under ONE "**Test:**" line — the header counts as one
  45. // line (as the one-line "**Test:** 1) … 2) …" did before), the 2–4 step lines are not counted
  46. const readableLines = (t) => String(t || '').split('\n').filter(l => l.trim() !== '' && !/^colony-[a-z-]+: /.test(l) && !/^\d+\. /.test(l)).length;
  47. // ---- copies ----------------------------------------------------------------------------------
  48. rmSync(WORK, { recursive: true, force: true });
  49. mkdirSync(WORK, { recursive: true });
  50. // mission 030 (ticket antcolony#1): TEST ISOLATION. The Hybriel interpreter loads the `.env` BESIDE THE ENTRY SCRIPT
  51. // (scheduler.hl — not the cwd; the real environment wins, an EMPTY variable counts as set): run from this folder, every
  52. // `./colony` call of the e2e got the LIVE settings (max turns, week limit, token file, CLAUDE_CONFIG_DIR …) → 6 failures.
  53. // So the scheduler under test runs from a COPY of this folder WITHOUT `.env` (and without the live runs / librarian /
  54. // registry / logs): whatever the live `.env` holds — today or tomorrow — cannot reach a test. On top, the environment
  55. // every call gets is DEFINED: no inherited COLONY_* / FAKE_* variable of the caller's shell (except the COLONY_E2E_*
  56. // controls above), and — unless COLONY_E2E_REAL=1 — a `claude` guard first on PATH that refuses and logs (fake only).
  57. const CODE = join(WORK, 'app');
  58. const CODE_SKIP = new Set(['.env', '.scratch', 'runs', 'sessions', 'briefs', 'logs', 'librarian', 'projects', 'mcp-needs-auth-cache.json', '.git']);
  59. // entry by entry (node refuses to copy a folder into its own subfolder; .scratch is skipped anyway)
  60. mkdirSync(CODE, { recursive: true });
  61. for (const e of readdirSync(APP)) if (!CODE_SKIP.has(e)) cpSync(join(APP, e), join(CODE, e), { recursive: true });
  62. if (existsSync(join(CODE, '.env'))) throw new Error('a .env was copied into the scheduler under test — refusing to start');
  63. const REAL = process.env.COLONY_E2E_REAL === '1';
  64. const GUARD_DIR = join(WORK, 'claude-guard'), GUARD_LOG = join(WORK, 'claude-guard.log');
  65. mkdirSync(GUARD_DIR, { recursive: true });
  66. 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 });
  67. const BASE_ENV = { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !(/^(COLONY_|FAKE_)/.test(k) && !k.startsWith('COLONY_E2E_')))), COLONY_SANDBOX: 'off', COLONY_DIGEST_HOUR: 'off', COLONY_LIBRARIAN: 'off' }; // antcolony#31: the creator is a project admin now, so its comments would start a real librarian call; the librarian section turns it on itself
  68. if (!REAL) BASE_ENV.PATH = GUARD_DIR + ':' + (process.env.PATH || '');
  69. const SKIP = new Set(['.env', 'storage', '.sessions', '.scratch', 'server.log', 'server.pid', 'testapp', '.git']);
  70. const TCODE = join(WORK, 'tickets-code');
  71. cpSync(TICKETS_DIR, TCODE, { recursive: true, filter: (src) => {
  72. const rel = src.slice(TICKETS_DIR.length).replace(/^\/+/, '');
  73. return rel === '' || !SKIP.has(rel.split('/')[0]);
  74. } });
  75. if (existsSync(join(TCODE, '.env'))) throw new Error('a .env was copied — refusing to start');
  76. const { startIdent } = await import(join(TCODE, 'tests', 'identkit.mjs'));
  77. let ident = null, tickets = null, tlog = '';
  78. const daemons = []; // mission 020: `colony run` processes (wrapper + scheduler pid) — killed at the end if still there
  79. const fakePids = () => { try { return readdirSync(join(WORK, 'fake-calls')).map(f => Number(f.split('-')[1])); } catch { return []; } };
  80. // a pid is killed only while its command line is still ours (pids get reused)
  81. const isOurs = (pid, needle) => { try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8').includes(needle); } catch { return false; } };
  82. const killOurs = (pid, needle) => { if (pid && isOurs(pid, needle)) { try { process.kill(pid, 'SIGKILL'); return true; } catch {} } return false; };
  83. // mission 026: servers the FAKE sessions leave behind (FAKE_SERVER_LOG) + the e2e's own "pre-existing" server — the ones the
  84. // scheduler must NOT stop are killed here by the test (only while their command line is still ours)
  85. const SERVER_LOG = join(WORK, 'fake-servers.jsonl');
  86. const ownServerPids = [];
  87. const serverRows = () => { try { return readFileSync(SERVER_LOG, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)); } catch { return []; } };
  88. const killTestServers = () => {
  89. let n = 0;
  90. for (const r of serverRows()) if (killOurs(r.pid, 'fake-left-server')) n++;
  91. for (const pid of ownServerPids) if (killOurs(pid, 'e2e-preexisting-server')) n++;
  92. return n;
  93. };
  94. const cleanup = async () => {
  95. const ks = killTestServers(); if (ks) console.log('cleanup: killed ' + ks + ' test server(s) left over');
  96. 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'); }
  97. for (const pid of fakePids()) { if (killOurs(pid, 'fake-claude.mjs')) console.log('cleanup: killed a leftover fake claude pid ' + pid); }
  98. 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); }); }
  99. if (ident) await ident.stop();
  100. writeFileSync(join(WORK, 'tickets.log'), tlog);
  101. if (ident) writeFileSync(join(WORK, 'ident.log'), ident.log());
  102. };
  103. // ---- helpers ---------------------------------------------------------------------------------
  104. let TOKEN = null;
  105. // antcolony#31: tickets speaks the new states (progress / review / pending / done / reopened); this suite's expectations are
  106. // written in the scheduler's own vocabulary (in progress / awaiting creator / on hold / confirmed / rejected — lib/tickets.hl
  107. // translates the same way), so every JSON read is translated back: a "Question…" ticket that is pending = awaiting creator.
  108. const OLD = { progress: 'in progress', review: 'awaiting creator', pending: 'on hold', done: 'confirmed', reopened: 'rejected' };
  109. const oldNames = (v) => {
  110. if (Array.isArray(v)) { v.forEach(oldNames); return v; }
  111. if (v === null || typeof v !== 'object') return v;
  112. if (typeof v.state === 'string' && OLD[v.state]) v.state = (v.state === 'pending' && /^Question\b/.test(v.subject || '')) ? 'awaiting creator' : OLD[v.state];
  113. // the server adds an `assign` event when a ticket goes to review / pending with nobody assigned (tickets#20): not part of these checks
  114. if (Array.isArray(v.events)) v.events = v.events.filter(e => e.kind !== 'assign');
  115. if (v.kind === 'state') { if (OLD[v.from]) v.from = OLD[v.from]; if (OLD[v.to]) v.to = OLD[v.to]; }
  116. for (const k of Object.keys(v)) if (v[k] && typeof v[k] === 'object') oldNames(v[k]);
  117. return v;
  118. };
  119. const api = async (method, path, body, accept) => {
  120. const mp = method === 'POST' && CTOKEN && /^\/api\/projects\/([^/]+)\/tickets$/.exec(path);
  121. if (mp) await openProject(mp[1]);
  122. const headers = { 'user-agent': 'colony-e2e' };
  123. if (body) headers['content-type'] = 'application/json';
  124. if (accept) headers.accept = accept;
  125. if (method !== 'GET') headers.authorization = 'Bearer ' + TOKEN;
  126. const r = await fetch(BASE + path, { method, headers, body: body ? J(body) : undefined });
  127. const text = await r.text();
  128. let json = null; try { json = oldNames(JSON.parse(text)); } catch {}
  129. if (method !== 'GET' && !(r.status === 200 || r.status === 201)) throw new Error(`${method} ${path} → ${r.status} ${text}`);
  130. return { status: r.status, json, text, ct: r.headers.get('content-type') };
  131. };
  132. let emitI = 0;
  133. const temit = async (event, payload, cookie) => {
  134. const r = await fetch(BASE + '/__hl/emit', { method: 'POST', headers: { 'content-type': 'application/json', cookie }, body: J({ t: 'emit', i: ++emitI, event, payload }) });
  135. const raw = await r.text(); let j = null; try { j = JSON.parse(raw); } catch {}
  136. return { value: j && j.value, raw };
  137. };
  138. const REG = join(WORK, 'projects');
  139. const TOKFILE = join(WORK, 'colony-token');
  140. // mission 029: the librarian's folder and the global decisions file always point into .scratch/e2e — no e2e call (`run`,
  141. // `brief`, `work`, `cycle` run the librarian first) ever writes the real librarian/ or templates/
  142. const colonyEnv = (extra = {}) => ({ ...BASE_ENV, COLONY_TICKETS_URL: BASE, COLONY_PROJECTS_DIR: REG, COLONY_TOKEN_FILE: TOKFILE, COLONY_USER_AGENT: 'colony-e2e',
  143. COLONY_LIBRARIAN_DIR: join(WORK, 'librarian'), COLONY_DECISIONS_GLOBAL: join(WORK, 'decisions-global.md'), ...extra });
  144. const colony = (args, extra, timeout = 60000) => {
  145. const r = spawnSync(join(CODE, 'colony'), args, { cwd: WORK, env: colonyEnv(extra), encoding: 'utf8', timeout });
  146. return (r.stdout || '') + (r.stderr || '');
  147. };
  148. const snapshot = async () => {
  149. const all = (await api('GET', '/api/tickets')).json.tickets;
  150. const out = [];
  151. for (const t of all) {
  152. const d = (await api('GET', `/api/projects/${t.project}/tickets/${t.number}`)).json;
  153. 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]) });
  154. }
  155. out.sort((a, b) => a.ref < b.ref ? -1 : 1);
  156. return J(out);
  157. };
  158. const ticket = async (p, n) => (await api('GET', `/api/projects/${p}/tickets/${n}`)).json;
  159. let CTOKEN = null; // mission 022: the creator's API token
  160. const capi = async (method, path, body) => { const save = TOKEN; TOKEN = CTOKEN; try { return await api(method, path, body); } finally { TOKEN = save; } };
  161. // antcolony#31: tickets keeps projects as records with members. The creator (an admin) opens a project the first time a
  162. // ticket is made in it and makes Colony a member with the role `edit` (the scheduler's role); every test project comes
  163. // into being this way, right before its first ticket.
  164. const opened = new Set();
  165. const openProject = async (slug) => {
  166. if (opened.has(slug)) return;
  167. opened.add(slug);
  168. await capi('POST', '/api/projects', { title: slug, slug });
  169. await capi('POST', `/api/projects/${slug}/members`, { user: 'Colony', role: 'edit' });
  170. };
  171. try {
  172. // ---- ident + tickets (own instances) --------------------------------------------------------
  173. ident = await startIdent({ identDir: IDENT_DIR, workDir: join(WORK, 'ident'), port: IDENT_PORT });
  174. const colonyAcct = await ident.signIn('[email protected]');
  175. const app = await ident.registerApp(colonyAcct, 'tickets (colony e2e)', [BASE]);
  176. // mission 022: a CREATOR (only the creator may reject / confirm) — its per-app id is TICKETS_CREATOR_IDENTITY
  177. const creatorAcct = await ident.signIn('[email protected]');
  178. const CREATOR_ID = await ident.exchange(app, await ident.selectorCode(creatorAcct, app, BASE));
  179. check('own ident runs from a copy without .env, tickets registered in it', /^pk_/.test(app.key) && !existsSync(join(WORK, 'ident', 'ident-code', '.env')));
  180. const store = join(WORK, 'tickets-data');
  181. tickets = spawn(join(TCODE, 'bin/hybriel'), ['project.hl'], { cwd: TCODE, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env,
  182. TICKETS_PORT: String(TICKETS_PORT), TICKETS_STORAGE: join(store, 'mpackdb'), TICKETS_SESSIONS: join(store, 'sessions'), HL_HOST: '127.0.0.1', TICKETS_WATCH: '0',
  183. 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 } });
  184. tickets.stdout.on('data', d => tlog += d); tickets.stderr.on('data', d => tlog += d);
  185. let up = false;
  186. for (let i = 0; i < 80 && !up; i++) { try { up = (await fetch(BASE + '/')).ok; } catch {} if (!up) await sleep(250); }
  187. if (!up) throw new Error('tickets did not come up\n' + tlog);
  188. // the user "Colony": login button's server half → display name → API token (as the creator will do on /you)
  189. const first = await fetch(BASE + '/');
  190. const cookie = (first.headers.get('set-cookie') || '').split(';')[0];
  191. const cb = await fetch(BASE + '/login/callback?ident_code=' + await ident.selectorCode(colonyAcct, app, BASE), { headers: { cookie }, redirect: 'manual' });
  192. await temit('saveDisplayName', ['Colony'], cookie);
  193. const tk = await temit('tokenCreate', ['scheduler e2e'], cookie);
  194. TOKEN = tk.value && tk.value.token;
  195. 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);
  196. writeFileSync(TOKFILE, TOKEN + '\n', { mode: 0o600 });
  197. // antcolony#31: the creator logs in too (as the real creator does); it opens the projects and is their admin
  198. const cfirst = await fetch(BASE + '/');
  199. const ccookie = (cfirst.headers.get('set-cookie') || '').split(';')[0];
  200. const ccb = await fetch(BASE + '/login/callback?ident_code=' + await ident.selectorCode(creatorAcct, app, BASE), { headers: { cookie: ccookie }, redirect: 'manual' });
  201. await temit('saveDisplayName', ['Creator'], ccookie);
  202. const ctk = await temit('tokenCreate', ['creator e2e'], ccookie);
  203. CTOKEN = ctk.value && ctk.value.token;
  204. // ---- registry + seed ------------------------------------------------------------------------
  205. mkdirSync(REG, { recursive: true });
  206. const meta = (name, concept, dependsOn, extra = {}) => writeFileSync(join(REG, name + '.json'), J({ name, dev: { host: 'loreana', folder: '/srv/' + name }, concept, dependsOn, ...extra }, null, 1));
  207. meta('alpha', 'loreana:/srv/alpha/CONCEPT.md', ['hybriel', 'beta'], { live: { host: 'byrodin', folder: '/CONTAINERS/projects/alpha', url: 'https://alpha.example' }, deploy: './deploy.sh (architect)' });
  208. meta('beta', '', ['hybriel']); // metadata, NO concept
  209. meta('hybriel', 'loreana:/srv/hybriel/SPEC.md', []);
  210. meta('delta', 'loreana:/srv/delta/CONCEPT.md', []); // no hybriel → no Hybriel block
  211. meta('antcolony', 'byrodin:/CONTAINERS/projects/antcolony/README.md', ['hybriel']);
  212. // gamma: NO metadata file
  213. await openProject('antcolony'); // antcolony#31: the inbox project exists (empty) before the scheduler files questions in it
  214. const mk = async (project, subject, summary, state) => {
  215. const t = (await api('POST', `/api/projects/${project}/tickets`, { subject, summary })).json.ticket;
  216. if (state) await api('POST', `/api/projects/${project}/tickets/${t.number}/state`, { state, text: 'seed' });
  217. await sleep(30);
  218. return t;
  219. };
  220. await mk('beta', 'beta oldest open', 'b'); // beta#1 no concept
  221. await mk('gamma', 'gamma open', 'g'); // gamma#1 no metadata
  222. await mk('alpha', 'alpha leased', 'a1', 'in progress'); // alpha#1
  223. await mk('alpha', 'alpha held', 'a2', 'on hold'); // alpha#2
  224. await mk('alpha', 'alpha the one', 'Build **the thing**.\n\n- point one\n- point two'); // alpha#3 ← NEXT
  225. await api('POST', '/api/projects/alpha/tickets/3/comments', { text: 'Creator: please keep it `small`.' });
  226. await mk('hybriel', 'known hybriel bug', 'h'); // hybriel#1 (open, eligible, a dependency's open ticket)
  227. await mk('alpha', 'alpha later', 'a4'); // alpha#4
  228. await mk('alpha', 'alpha done', 'a5', 'awaiting creator'); // alpha#5
  229. await mk('delta', 'delta task', 'd'); // delta#1
  230. await mk('hybriel', 'hybriel waiting', 'h2', 'awaiting creator'); // hybriel#2 (not closed → still listed as known)
  231. // mission 022: does this tickets serve relations (tickets#4, mission 017)? A tickets without them (e.g. dev-m012) skips those checks
  232. const REL = Array.isArray((await ticket('alpha', 1)).ticket.children);
  233. console.log('tickets serves relations (parent / children / blockedBy): ' + REL);
  234. // mission 030: the scheduler under test is the copy without `.env`; the folder's own `.env` (if any) is never loaded
  235. console.log(`m030 isolation: this folder ${existsSync(join(APP, '.env')) ? 'HAS a .env (the live settings) — ignored' : 'has no .env'}; the scheduler under test runs from ${CODE} (no .env); inherited COLONY_*/FAKE_* dropped; ${REAL ? 'REAL claude allowed' : 'claude guard on PATH'}`);
  236. check('m030 isolation: the scheduler under test is a copy without .env (and without runs / librarian / projects / logs)', existsSync(join(CODE, 'scheduler.hl')) && ['.env', 'runs', 'librarian', 'projects', 'logs', '.scratch'].every(f => !existsSync(join(CODE, f))) && !Object.keys(colonyEnv()).some(k => /^FAKE_/.test(k) || (/^COLONY_/.test(k) && !/^COLONY_(E2E_\w+|DIGEST_HOUR|LIBRARIAN|SANDBOX|SANDBOX_RO|TICKETS_URL|PROJECTS_DIR|TOKEN_FILE|USER_AGENT|LIBRARIAN_DIR|DECISIONS_GLOBAL)$/.test(k))));
  237. // ---- next -----------------------------------------------------------------------------------
  238. let out = colony(['next']);
  239. const lines = out.split('\n');
  240. check('next: picks alpha#3 (oldest eligible: older ones are no-concept / no-metadata / leased / on hold)', /^NEXT: alpha#3 "alpha the one" — the oldest eligible open ticket/m.test(out), out);
  241. const order = ['hybriel#1', 'alpha#4', 'delta#1'].map(r => lines.findIndex(l => l.startsWith(' ' + r + ' ')));
  242. check('next: the other eligible ones listed oldest first (hybriel#1, alpha#4, delta#1)', order.every(i => i > 0) && order[0] < order[1] && order[1] < order[2], out);
  243. check('next: beta#1 → "has no concept → question for the creator"', /^ beta#1 "beta oldest open" — project beta has no concept → question for the creator$/m.test(out), out);
  244. check('next: gamma#1 → "has no metadata … → question for the creator"', /^ gamma#1 "gamma open" — project gamma has no metadata \(.*gamma\.json\) → question for the creator$/m.test(out), out);
  245. check('next: alpha#1 → leased (in progress), alpha#2 → on hold', /^ alpha#1 .* — leased: in progress/m.test(out) && /^ alpha#2 .* — on hold$/m.test(out), out);
  246. check('next: awaiting creator only counted (2)', /Not considered: 2 awaiting creator \(inbox\), 0 confirmed, 0 rejected/.test(out), out);
  247. check('next (no --post): lists the 2 questions, files nothing', /Questions for the creator \(2; `next --post` files them/.test(out) && (await api('GET', '/api/projects/antcolony/tickets')).json.tickets.length === 0, out);
  248. console.log('---- next output ----\n' + out.trim() + '\n---------------------');
  249. out = colony(['next', '--post'], { COLONY_TOKEN_FILE: '' });
  250. check('next --post without COLONY_TOKEN_FILE → says so, writes nothing', /next --post: COLONY_TOKEN_FILE is not set/.test(out) && (await api('GET', '/api/projects/antcolony/tickets')).json.tickets.length === 0, out);
  251. out = colony(['next', '--post']);
  252. let inbox = (await api('GET', '/api/projects/antcolony/tickets')).json.tickets;
  253. check('next --post: 2 creator questions filed in antcolony, both "awaiting creator"', /FILED antcolony#\d .*gamma/.test(out) && /FILED antcolony#\d .*beta/.test(out) && inbox.length === 2 && inbox.every(t => t.state === 'awaiting creator'), out + J(inbox.map(t => [t.ref, t.state, t.subject])));
  254. const q = inbox.find(t => /beta/.test(t.subject));
  255. check('next --post: the question names the waiting tickets with links (m023: subject "Question: …", what to do)', q && q.subject === 'Question: project beta has no written concept yet — can we write one?' && /\*\*What to do:\*\* write the concept/.test(q.summary) && /- Waiting: \[beta #1\]\(http:\/\/127\.0\.0\.1:\d+\/projects\/beta\/1\)$/.test(q.summary), q && q.subject + '\n' + q.summary);
  256. let snap = await snapshot();
  257. out = colony(['next', '--post']);
  258. check('next --post again: "already asked", nothing written (whole store identical)', (out.match(/HAVE antcolony#/g) || []).length === 2 && !/FILED/.test(out) && await snapshot() === snap, out);
  259. // ---- brief ----------------------------------------------------------------------------------
  260. out = colony(['brief', 'alpha', '3', '--out', 'brief-alpha-3.md', '--ports', '8710-8719', '--session', 'sess-e2e-1']);
  261. const bf = join(WORK, 'brief-alpha-3.md');
  262. check('brief alpha 3: written to --out (relative to the cwd)', existsSync(bf) && /brief: alpha#3 → .*brief-alpha-3\.md \(session sess-e2e-1, ports 8710-8719/.test(out), out);
  263. const brief = existsSync(bf) ? readFileSync(bf, 'utf8') : '';
  264. const md = /ticket via Markdown read view/.test(out);
  265. const parts = [
  266. ['title', '# Brief — alpha#3: alpha the one'],
  267. ['concept path', '**Concept: `loreana:/srv/alpha/CONCEPT.md`**'],
  268. ['README/STATUS of the dev folder', '`loreana:/srv/alpha/README.md` and `STATUS.md`'],
  269. ['antcolony README (conventions)', 'byrodin:/CONTAINERS/projects/antcolony/README.md'],
  270. ['live + deploy note', 'Live (never touch): `byrodin:/CONTAINERS/projects/alpha` — https://alpha.example'],
  271. ['port range', '**Ports for everything you start: 8710–8719 only**'],
  272. // mission 029: templates/hybriel-block.md now names the Loreana copy of the guide (the architect changed it after mission 027)
  273. ['Hybriel block', 'Read `/media/STORAGE/projects/antcolony-docs/docs/hybriel-for-app-workers.md` first'],
  274. ['dependency hybriel: its open tickets', '### hybriel\n- hybriel #2 [awaiting creator] hybriel waiting'],
  275. ['dependency hybriel: open bug', '- hybriel #1 [open] known hybriel bug'],
  276. ['dependency beta: its open ticket', '### beta\n- beta #1 [open] beta oldest open'],
  277. ['rules', '## Rules\n- No git.'],
  278. ['report schema with ticket + session', '"ticket": "alpha#3",\n "session": "sess-e2e-1",'],
  279. ['report rule: command AND output', 'every entry needs `claim`, `command` AND `output`'],
  280. ['known projects for issues', '(alpha, antcolony, beta, delta, hybriel)'],
  281. ['the ticket summary', 'Build **the thing**.'],
  282. ['the history: the comment', 'Creator: please keep it `small`.'],
  283. // mission 027: the conventions as TEXT (workers on Loreana cannot read Byrodin), the project's own tickets, where to test
  284. ['m027 conventions as text', '## Conventions for all apps — already decided by the creator, don\'t ask about them'],
  285. ['m027 the shared-components decision', '**Shared components** (editor, differ, comments, design tokens) live in **layouts.worldapi.org**'],
  286. ['m027 tickets of the project', '## Tickets of alpha — decisions may already be there (check before you ask a question)'],
  287. ['m027 the project\'s other tickets (alpha#5 awaiting creator)', '- alpha #5 [awaiting creator] alpha done — '],
  288. ['m027 test steps for the live site', 'for the creator on the live site https://alpha.example (after the deploy'],
  289. ['m027 test step limits', '**2–4 steps, each one short line ≤ 90 characters**'],
  290. ['m027 decided in the report schema', '"decided": ["a small choice you made yourself, one line"],'],
  291. ['m027 questions only when they change what gets built', '**`questions`: only when the answer changes what gets built** — one question = one decision (no count limit;'],
  292. ];
  293. for (const [what, needle] of parts) check(`brief contains: ${what}`, brief.includes(needle), needle);
  294. check('m027 brief: the project\'s tickets are listed NEWEST first (alpha #5 before alpha #4)', brief.indexOf('- alpha #5 [') > 0 && brief.indexOf('- alpha #5 [') < brief.indexOf('- alpha #4 ['), brief.slice(brief.indexOf('## Tickets of alpha'), brief.indexOf('## Tickets of alpha') + 500));
  295. check('m027 brief: the ticket list of the project leaves out the ticket itself; conventions come right after "Read first"', !brief.includes('- alpha #3 [') && brief.indexOf('## Conventions for all apps') > brief.indexOf('## Read first') && brief.indexOf('## Conventions for all apps') < brief.indexOf('## Where'), brief.slice(brief.indexOf('## Tickets of alpha'), brief.indexOf('## Tickets of alpha') + 500));
  296. check(`brief: the ticket section says how it was read (${md ? 'Markdown read view' : 'rendered from JSON — no read view in this tickets'})`, md ? /## Ticket \(Markdown read view\)/.test(brief) : /## Ticket \(rendered from JSON: tickets served no Markdown read view\)\n\n# alpha #3: alpha the one\n\nstate: \*\*open\*\*/.test(brief), brief.slice(brief.indexOf('## Ticket')));
  297. if (md) {
  298. const want = (await api('GET', '/api/projects/alpha/tickets/3', null, 'text/markdown')).text.trim();
  299. check('brief: the ticket section IS tickets\' Markdown read view, verbatim', brief.includes(want), want);
  300. }
  301. out = colony(['brief', 'delta', '1', '--out', 'brief-delta-1.md']);
  302. const bd = existsSync(join(WORK, 'brief-delta-1.md')) ? readFileSync(join(WORK, 'brief-delta-1.md'), 'utf8') : '';
  303. check('brief delta 1 (no hybriel dependency): no Hybriel block, no dependency section, default ports 8700–8749', bd.includes('# Brief — delta#1') && !bd.includes('hybriel-for-app-workers') && !bd.includes('## Known open tickets') && bd.includes('8700–8749 only') && /"session": "s-\d{8}T\d{4}-[0-9a-f]{6}"/.test(bd), out + bd.slice(0, 600));
  304. check('m027 brief delta 1 (no live site): the test steps are for the real result, never a work copy; the conventions are there too', bd.includes('for the creator on the real result (this project has no live site') && bd.includes('## Conventions for all apps') && bd.includes('## Tickets of delta') && bd.includes('- _(no other tickets)_'), bd.slice(bd.indexOf('## Tickets of delta'), bd.indexOf('## Tickets of delta') + 200));
  305. out = colony(['brief', 'beta', '1']);
  306. check('brief beta 1 → REFUSED: no concept → question for the creator', /brief: REFUSED — project beta has no concept → question for the creator/.test(out), out);
  307. out = colony(['brief', 'gamma', '1']);
  308. check('brief gamma 1 → REFUSED: no metadata', /brief: REFUSED — project gamma has no metadata/.test(out), out);
  309. out = colony(['brief', 'alpha', '99']);
  310. check('brief alpha 99 → 404 named', /brief: ticket alpha#99 → 404/.test(out), out);
  311. out = colony(['brief', 'alpha', '3', '--ports', '8750-8700']);
  312. check('brief --ports 8750-8700 → refused', /--ports must be A-B/.test(out), out);
  313. check('brief writes nothing to tickets', await snapshot() === snap);
  314. // ---- report: validation ---------------------------------------------------------------------
  315. const good = () => ({
  316. ticket: 'alpha#3', session: 'sess-e2e-1',
  317. result: 'The thing is built and answers.',
  318. complete: true,
  319. test: ['open https://alpha.example/x — it says ok', 'reload — it still says ok'],
  320. done: ['Built **the thing**'],
  321. verified: [{ claim: 'the thing answers', command: 'curl -s localhost:8711/x', output: 'ok\n```\nnested fence\n```' }],
  322. open: ['the admin page'],
  323. issues: [
  324. { project: 'hybriel', subject: 'widget crashes', repro: '`hybriel w.hl`', observed: 'crash', expected: 'no crash', blocks: true },
  325. { project: 'zeta', subject: 'zeta thing broken', repro: 'r', observed: 'o', expected: 'e', blocks: false },
  326. ],
  327. questions: [{ title: 'Is it small enough?', text: 'Is small enough?' }],
  328. decided: ['Kept the default port of the thing.'],
  329. running: [{ what: 'dev server', url: 'http://127.0.0.1:8711', pid: 4242, log: '.scratch/dev.log' }],
  330. refused: [],
  331. });
  332. const rf = (name, content) => { const p = join(WORK, name); writeFileSync(p, typeof content === 'string' ? content : J(content, null, 1)); return name; };
  333. const bad = [
  334. ['invalid JSON', rf('r-badjson.json', '{"ticket": "alpha#3",'), /REPORT REFUSED — .*r-badjson\.json: invalid JSON at character \d+/],
  335. ['not an object', rf('r-list.json', [good()]), /REPORT REFUSED — 1 problem:\n the report must be one JSON object, not a list/],
  336. ['missing field', rf('r-missing.json', (() => { const g = good(); delete g.refused; return g; })()), /REPORT REFUSED — 1 problem:\n \(top\): field 'refused' is missing/],
  337. ['unknown top field', rf('r-unknown.json', { ...good(), notes: 'x' }), /REPORT REFUSED — 1 problem:\n \(top\): unknown field 'notes' \(allowed: ticket, session, result, complete, test, done, verified, open, issues, questions, decided, running, refused\)/],
  338. // mission 026: `complete` is required and a boolean
  339. ['m026 no complete', rf('r-nocomplete.json', (() => { const g = good(); delete g.complete; return g; })()), /REPORT REFUSED — 1 problem:\n \(top\): field 'complete' is missing/],
  340. ['m026 complete not a boolean', rf('r-complete-str.json', { ...good(), complete: 'yes' }), /REPORT REFUSED — 1 problem:\n complete: must be true or false, not a string/],
  341. // mission 023: result + test are the ticket comment — one line each, short, 1–4 steps
  342. ['result on two lines', rf('r-res2.json', { ...good(), result: 'Done.\nReally.' }), /REPORT REFUSED — 1 problem:\n result: must be ONE line \(no line breaks\) \(ONE plain sentence: what is done\)/],
  343. ['m027 result too long (121 > 120)', rf('r-reslong.json', { ...good(), result: 'x'.repeat(121) }), /REPORT REFUSED — 1 problem:\n result: is 121 characters long, at most 120/],
  344. ['no result', rf('r-nores.json', (() => { const g = good(); delete g.result; return g; })()), /REPORT REFUSED — 1 problem:\n \(top\): field 'result' is missing/],
  345. ['test with 5 steps', rf('r-test5.json', { ...good(), test: ['a', 'b', 'c', 'd', 'e'] }), /REPORT REFUSED — 1 problem:\n test: must have 2–4 steps, has 5/],
  346. ['test empty', rf('r-test0.json', { ...good(), test: [] }), /REPORT REFUSED — 1 problem:\n test: must have 2–4 steps, has 0/],
  347. // mission 027: 2–4 steps of ≤ 90 characters, never a work copy, on a project with `live` never a local address
  348. ['m027 test with 1 step', rf('r-test1.json', { ...good(), test: ['open it'] }), /REPORT REFUSED — 1 problem:\n test: must have 2–4 steps, has 1/],
  349. ['m027 a test step of 91 characters', rf('r-step91.json', { ...good(), test: ['open it', 'y'.repeat(91)] }), /REPORT REFUSED — 1 problem:\n test\[1\]: is 91 characters long, at most 90/],
  350. ['m027 a test step on a work copy', rf('r-stepscratch.json', { ...good(), test: ['open it', 'see .scratch/dev/x.png'] }), /REPORT REFUSED — 1 problem:\n test\[1\]: names a work copy \(\.scratch\) — the creator tests the real thing on https:\/\/alpha\.example/],
  351. ['m027 a test step on a local address (alpha has a live site)', rf('r-steplocal.json', { ...good(), test: ['open http://127.0.0.1:8711/x', 'reload'] }), /REPORT REFUSED — 1 problem:\n test\[0\]: names a local address — write the step for the live site https:\/\/alpha\.example/],
  352. ['a question title over 80 characters', rf('r-qt.json', { ...good(), questions: [{ title: 'T'.repeat(81), text: 'Which?' }] }), /REPORT REFUSED — 1 problem:\n questions\[0\]\.title: is 81 characters long, at most 80/],
  353. ['a question object without a title', rf('r-qn.json', { ...good(), questions: [{ text: 'Which?' }] }), /REPORT REFUSED — 1 problem:\n questions\[0\]: title must be a non-empty string/],
  354. ['m027 a question of 4 lines', rf('r-q4l.json', { ...good(), questions: ['Which?\nA) one\nB) two\nC) three'] }), /REPORT REFUSED — 1 problem:\n questions\[0\]: has 4 lines, at most 3/],
  355. ['m027 a question of 301 characters', rf('r-qlong.json', { ...good(), questions: ['Which? ' + 'z'.repeat(294)] }), /REPORT REFUSED — 1 problem:\n questions\[0\]: is 301 characters long, at most 300/],
  356. ['m027 decided on two lines', rf('r-dec2.json', { ...good(), decided: ['one\ntwo'] }), /REPORT REFUSED — 1 problem:\n decided\[0\]: must be ONE line/],
  357. ['m027 decided not a list', rf('r-decs.json', { ...good(), decided: 'x' }), /REPORT REFUSED — 1 problem:\n decided: must be a list of strings, not a string/],
  358. ['a test step on two lines', rf('r-teststep.json', { ...good(), test: ['open it', 'a\nb'] }), /REPORT REFUSED — 1 problem:\n test\[1\]: must be ONE line/],
  359. ['verified without output', rf('r-noout.json', { ...good(), verified: [{ claim: 'c', command: 'ls' }] }), /REPORT REFUSED — 1 problem:\n verified\[0\]: 'output' is missing \(every verified entry needs command AND output\)/],
  360. ['verified with an empty command', rf('r-emptycmd.json', { ...good(), verified: [good().verified[0], { claim: 'c', command: ' ', output: 'o' }] }), /REPORT REFUSED — 1 problem:\n verified\[1\]: 'command' is empty \(every verified entry needs command AND output\)/],
  361. ['unknown field in a verified entry', rf('r-vextra.json', { ...good(), verified: [{ ...good().verified[0], when: 'now' }] }), /REPORT REFUSED — 1 problem:\n verified\[0\]: unknown field 'when' \(allowed: claim, command, output\)/],
  362. ['bad ticket ref', rf('r-ref.json', { ...good(), ticket: 'alpha 3' }), /REPORT REFUSED — 1 problem:\n ticket: "alpha 3" is not "<project>#<number>"/],
  363. ['empty session', rf('r-session.json', { ...good(), session: '' }), /REPORT REFUSED — 1 problem:\n session: must be a non-empty string/],
  364. ['blocks not boolean', rf('r-blocks.json', { ...good(), issues: [{ ...good().issues[0], blocks: 'yes' }] }), /REPORT REFUSED — 1 problem:\n issues\[0\]: 'blocks' must be true or false, not a string/],
  365. ['issue without repro', rf('r-repro.json', { ...good(), issues: [(() => { const i = good().issues[0]; delete i.repro; return i; })()] }), /REPORT REFUSED — 1 problem:\n issues\[0\]: 'repro' is missing/],
  366. ['unknown field in an issue', rf('r-iextra.json', { ...good(), issues: [{ ...good().issues[0], severity: 'high' }] }), /REPORT REFUSED — 1 problem:\n issues\[0\]: unknown field 'severity'/],
  367. ['running pid as string', rf('r-pid.json', { ...good(), running: [{ ...good().running[0], pid: '4242' }] }), /REPORT REFUSED — 1 problem:\n running\[0\]: 'pid' must be a number, not a string/],
  368. ['done entry not a string', rf('r-done.json', { ...good(), done: [42] }), /REPORT REFUSED — 1 problem:\n done\[0\]: must be a non-empty string, not a number/],
  369. ['questions not a list', rf('r-q.json', { ...good(), questions: 'why?' }), /REPORT REFUSED — 1 problem:\n questions: must be a list of strings, not a string/],
  370. ['several problems at once', rf('r-many.json', { ...good(), verified: [{ claim: 'c', command: 'x', output: '' }], extra: 1, refused: null }), /REPORT REFUSED — 3 problems:\n \(top\): unknown field 'extra'.*\n refused: must not be null \(use \[\] for an empty list\)\n verified\[0\]: 'output' is empty/],
  371. ];
  372. for (const [what, file, re] of bad) {
  373. const o = colony(['report', file]);
  374. check(`report refuses: ${what}`, re.test(o) && !/REPORT OK/.test(o), o);
  375. }
  376. out = colony(['report', 'nope.json']);
  377. check('report refuses: no such file', /REPORT REFUSED — no such file: .*nope\.json/.test(out), out);
  378. const goodFile = rf('r-good.json', good());
  379. out = colony(['report', goodFile]);
  380. check('report accepts the good one; the unknown issue project is a NOTE (→ creator question), not a refusal', /REPORT OK — alpha#3, session sess-e2e-1: complete, 2 test steps, 1 done, 1 verified, 1 open, 2 issues, 1 questions, 1 decided/.test(out) && /note: issues\[1\]: project 'zeta' is not in the registry .* → becomes a creator question on --post/.test(out), out);
  381. out = colony(['report', rf('r-nodecided.json', (() => { const g = good(); delete g.decided; return g; })())]);
  382. check('m027 report: a report WITHOUT `decided` (written before mission 027) is still accepted ("0 decided")', /REPORT OK — alpha#3, .* 1 questions, 0 decided$/m.test(out), out);
  383. check('report without --post writes nothing', await snapshot() === snap);
  384. out = colony(['report', goodFile, '--post'], { COLONY_TOKEN_FILE: '' });
  385. check('report --post without a token file → POST REFUSED, nothing written', /POST REFUSED — COLONY_TOKEN_FILE is not set/.test(out) && await snapshot() === snap, out);
  386. out = colony(['report', rf('r-99.json', { ...good(), ticket: 'alpha#99' }), '--post']);
  387. check('report --post for a missing ticket → POST REFUSED 404, nothing written', /POST REFUSED — ticket alpha#99 → 404/.test(out) && await snapshot() === snap, out);
  388. out = colony(['report', 'r-noout.json', '--post']);
  389. check('report --post of an invalid report → refused before any write', /REPORT REFUSED/.test(out) && await snapshot() === snap, out);
  390. // ---- report --post --------------------------------------------------------------------------
  391. const before = { alpha3: await ticket('alpha', 3), hyb: (await api('GET', '/api/projects/hybriel/tickets')).json.tickets.length, inbox: (await api('GET', '/api/projects/antcolony/tickets')).json.tickets.length, all: (await api('GET', '/api/tickets')).json.tickets.length };
  392. out = colony(['report', goodFile, '--post']);
  393. console.log('---- report --post output ----\n' + out.trim() + '\n------------------------------');
  394. const a3 = await ticket('alpha', 3);
  395. const newEv = a3.events.slice(before.alpha3.events.length);
  396. check('report --post: POSTED', /\nPOSTED\n?$/.test(out.trimEnd() + '\n') || /POSTED$/m.test(out), out);
  397. // mission 022: the issue with `blocks: true` → alpha#3 is blocked by it (a link event first)
  398. const nL = REL ? 1 : 0; // with relations the first new event is the blocked-by link
  399. check(REL ? 'report --post: alpha#3 got exactly 3 events: the blocked-by link (mission 022: the `blocks: true` issue), the comment, then the state change, all by Colony' : 'report --post: alpha#3 got exactly 2 events: the comment, then the state change, both by Colony (no relations in this tickets)', newEv.length === 2 + nL && (!REL || newEv[0].kind === 'link') && newEv[nL].kind === 'comment' && newEv[nL + 1].kind === 'state' && newEv.every(e => e.author === 'Colony'), J(newEv.map(e => [e.kind, e.author])));
  400. // mission 027: alpha has a LIVE site (registry `live`) → the DEPLOY GATE: not "awaiting creator" before the deploy
  401. check('m027 deploy gate: alpha (live site) → alpha#3 is "in progress" (open → in progress "Built — waiting for the next deploy."), NOT awaiting creator; the output says READY + how to go on', a3.ticket.state === 'in progress' && newEv[nL + 1] && newEv[nL + 1].from === 'open' && newEv[nL + 1].to === 'in progress' && newEv[nL + 1].text === 'Built — waiting for the next deploy.' && /^ READY alpha#3: built — goes live with the next deploy \(list: \.\/colony ready; after the deploy: \.\/colony deployed alpha 3\)$/m.test(out), a3.ticket.state + J(newEv[nL + 1]) + out);
  402. const c = newEv[nL] ? newEv[nL].text : '';
  403. check('m027 deploy gate: the comment is ONE line "Built — goes live with the next deploy." + the machine line ending "· ready to deploy" (nothing else: no Test steps before the deploy)', /^Built — goes live with the next deploy\.\n\ncolony-report: sess-e2e-1 · sha256 [0-9a-f]{12} · ready to deploy$/.test(c) && readableLines(c) === 1, c);
  404. const hyb = (await api('GET', '/api/projects/hybriel/tickets')).json.tickets;
  405. const issue = hyb.find(t => t.subject === 'widget crashes');
  406. check('report --post: the hybriel issue became hybriel#3 (open), with repro / observed / expected, a link back and "Blocks alpha#3"', hyb.length === before.hyb + 1 && issue && issue.number === 3 && issue.state === 'open' && issue.summary.startsWith(`Found while working on [alpha #3](${BASE}/projects/alpha/3) — it blocks that ticket.`) && /\*\*Repro:\*\* `hybriel w\.hl`\n\n\*\*Observed:\*\* crash\n\n\*\*Expected:\*\* no crash$/.test(issue.summary) && issue.source === 'colony-report:sess-e2e-1:issue:0', issue && J(issue));
  407. const inb = (await api('GET', '/api/projects/antcolony/tickets')).json.tickets;
  408. const zq = inb.find(t => /zeta/.test(t.subject));
  409. check('report --post: the unknown-project issue became a creator question in antcolony, "awaiting creator"', inb.length === before.inbox + 1 && zq && zq.state === 'awaiting creator' && zq.subject === 'Question (alpha#3): where should "zeta thing broken" go?' && zq.summary.startsWith(`A problem found while working on [alpha #3](${BASE}/projects/alpha/3) belongs to "zeta", a project the colony does not know.`), zq && J(zq));
  410. check('report --post: exactly 3 new tickets in the whole store (issue, unknown-project question, the report\'s question — mission 022)', (await api('GET', '/api/tickets')).json.tickets.length === before.all + 3);
  411. // mission 022: blocks → blocked-by; questions → tickets in the ticket's project, awaiting creator, parent = the ticket
  412. if (REL) check('m022 report --post: alpha#3 is now blocked by hybriel#3 (the `blocks: true` issue; link event text "marked it blocked by")', a3.ticket.blockedBy.length === 1 && a3.ticket.blockedBy[0].key === 'hybriel#3' && /blocked by/.test(newEv[0].text + ' ' + (newEv[0].label || '')), J({ blockedBy: a3.ticket.blockedBy, ev: newEv[0] }));
  413. const aq = (await api('GET', '/api/projects/alpha/tickets')).json.tickets.find(t => t.source === 'colony-report:sess-e2e-1:questions:0');
  414. check('m022 report --post: the report\'s question "Is small enough?" became its own ticket in alpha: subject "Question (alpha#3): …", awaiting creator, parent alpha#3, the summary = the question + where it came from', aq && aq.subject === 'Question (alpha#3): Is it small enough?' && aq.state === 'awaiting creator' && (!REL || (aq.parent && aq.parent.key === 'alpha#3' && a3.ticket.children.some(k => k.key === 'alpha#' + aq.number))) && aq.summary === `Is small enough?\n\nAsked while working on [alpha #3](${BASE}/projects/alpha/3). **Answer** with a comment here — the next worker reads it.`, aq && J(aq));
  415. if (REL) check('m022: the unknown-project creator question (antcolony) is linked to alpha#3 as its parent too', zq && zq.parent && zq.parent.key === 'alpha#3', zq && J(zq.parent));
  416. snap = await snapshot();
  417. out = colony(['report', goodFile, '--post']);
  418. check('report --post AGAIN: "nothing new", the whole store unchanged (events, states, updated times)', /POSTED — nothing new \(report already filed\)/.test(out) && !/FILED|STATE alpha/.test(out) && await snapshot() === snap, out);
  419. const changed = { ...good(), done: ['Built the thing, differently'] };
  420. out = colony(['report', rf('r-changed.json', changed), '--post']);
  421. check('report --post of a DIFFERENT report under the same session → refused, nothing written', /POST REFUSED — session sess-e2e-1 already has a DIFFERENT report on alpha#3/.test(out) && await snapshot() === snap, out);
  422. // ---- mission 027: the deploy gate — `ready` lists it, `next` / `leases` never pick or expire it, `deployed` posts the
  423. // readable comment (numbered Test steps, details) from runs/<S>/report.json and sets awaiting creator ----
  424. out = colony(['next']);
  425. check('m027 next: alpha#3 is "built — waiting for the deploy (session sess-e2e-1; ./colony ready)", not eligible', /^ alpha#3 "alpha the one" — built — waiting for the deploy \(session sess-e2e-1; \.\/colony ready\)$/m.test(out) && !/^NEXT: alpha#3/m.test(out), out);
  426. out = colony(['ready']);
  427. console.log('---- ready ----\n' + out.trim() + '\n---------------');
  428. check('m027 ready: lists alpha#3 with its session, the live site, the deploy note and the command for after the deploy; nothing else', /^READY alpha#3 "alpha the one" — built by session sess-e2e-1 · live https:\/\/alpha\.example · deploy: \.\/deploy\.sh \(architect\) · after the deploy: \.\/colony deployed alpha 3 · http/m.test(out) && /^READY: 1 ticket\(s\) waiting for a deploy/m.test(out) && (out.match(/^READY [a-z]/gm) || []).length === 1, out);
  429. out = colony(['leases']);
  430. check('m027 leases: alpha#3 "built, waiting for the deploy … never expires"', /^LEASE alpha#3 — built, waiting for the deploy \(session sess-e2e-1\): never expires/m.test(out), out);
  431. const RUNS27 = join(WORK, 'runs-m027');
  432. mkdirSync(RUNS27, { recursive: true });
  433. snap = await snapshot();
  434. out = colony(['deployed', 'alpha', '3'], { COLONY_RUNS_DIR: RUNS27 });
  435. check('m027 deployed without the session\'s run folder on this host → REFUSED, nothing written', /^deployed: REFUSED — the report of session sess-e2e-1 is not on this host/m.test(out) && await snapshot() === snap, out);
  436. out = colony(['deployed', 'alpha', '4'], { COLONY_RUNS_DIR: RUNS27 });
  437. check('m027 deployed on a ticket that waits for no deploy (alpha#4 open) → REFUSED, nothing written', /^deployed: REFUSED — alpha#4 is "open" and not waiting for a deploy/m.test(out) && await snapshot() === snap, out);
  438. mkdirSync(join(RUNS27, 'sess-e2e-1'), { recursive: true });
  439. writeFileSync(join(RUNS27, 'sess-e2e-1', 'report.json'), readFileSync(join(WORK, goodFile), 'utf8'));
  440. out = colony(['deployed', 'alpha', '3'], { COLONY_RUNS_DIR: RUNS27 });
  441. console.log('---- deployed alpha 3 ----\n' + out.trim() + '\n--------------------------');
  442. const a3d = await ticket('alpha', 3), evD = a3d.events.slice(a3.events.length);
  443. const dcm = evD[0] && evD[0].kind === 'comment' ? evD[0].text : '';
  444. check('m027 deployed: DEPLOYED — alpha#3 got exactly the deployed comment + in progress → awaiting creator ("Ready for you to test — see the last comment.")', /^DEPLOYED — alpha#3 \(session sess-e2e-1, live on alpha\.example\)$/m.test(out) && evD.length === 2 && evD[1].kind === 'state' && evD[1].from === 'in progress' && evD[1].to === 'awaiting creator' && evD[1].text === 'Ready for you to test — see the last comment.' && a3d.ticket.state === 'awaiting creator', out + J(evD));
  445. check('m027 deployed comment: "Now live on alpha.example — <result>", then "**Test:**" and the steps as a NUMBERED Markdown list (one line each)', dcm.startsWith('Now live on alpha.example — The thing is built and answers.\n\n**Test:**\n1. open https://alpha.example/x — it says ok\n2. reload — it still says ok\n'), dcm);
  446. check('m027 deployed comment: ≤ 3 details — the question, the problems filed elsewhere (hybriel #3 blocks, the unknown-project question), "Decided: …"; the machine line last (colony-deployed: S · sha256 12 hex); ≤ 5 lines around the steps', aq && zq && (dcm.match(/^- /gm) || []).length === 3 && dcm.includes('\n- A question for you: [alpha #' + aq.number + '](' + BASE + '/projects/alpha/' + aq.number + ')\n') && dcm.includes(`[hybriel #3](${BASE}/projects/hybriel/3) (blocks this ticket)`) && dcm.includes('[antcolony #' + zq.number + '](' + BASE + '/projects/antcolony/' + zq.number + ') (where should it go?)') && dcm.includes('\n- Decided: Kept the default port of the thing.\n') && /\n\ncolony-deployed: sess-e2e-1 · sha256 [0-9a-f]{12}$/.test(dcm) && readableLines(dcm) <= 5 && !dcm.includes('curl'), dcm);
  447. snap = await snapshot();
  448. out = colony(['deployed', 'alpha', '3'], { COLONY_RUNS_DIR: RUNS27 });
  449. const out2r = colony(['ready']);
  450. check('m027 deployed AGAIN → "nothing new", store unchanged; ready lists nothing any more', /HAVE the deployed comment of session sess-e2e-1/.test(out) && /^DEPLOYED — alpha#3 .*: nothing new$/m.test(out) && await snapshot() === snap && /^READY: 0 ticket\(s\) waiting for a deploy$/m.test(out2r), out + out2r);
  451. // a second session on a ticket that is already awaiting creator: comment yes, state left alone
  452. const a3c = await ticket('alpha', 3);
  453. out = colony(['report', rf('r-s2.json', { ...good(), session: 'sess-e2e-2', issues: [] }), '--post']);
  454. const a3b = await ticket('alpha', 3);
  455. check('report --post, 2nd session on a ticket already "awaiting creator": new comment, state left, no duplicate state event', /STATE left as "awaiting creator"/.test(out) && a3b.events.length === a3c.events.length + 1 && a3b.events.at(-1).kind === 'comment', out);
  456. // ---- work (agent v0, mission 016) — with a FAKE claude (tests/fake-claude.mjs, no quota) --------
  457. const DUMMY = join(WORK, 'dummy');
  458. mkdirSync(DUMMY, { recursive: true });
  459. writeFileSync(join(DUMMY, 'CONCEPT.md'), '# dummy\nA test project: every ticket is a tiny file task in this folder.\n');
  460. writeFileSync(join(DUMMY, 'README.md'), '# dummy\nTest project of the antcolony-scheduler e2e. Nothing else to read.\n');
  461. meta('dummy', DUMMY + '/CONCEPT.md', [], { dev: { host: 'e2e-host', folder: DUMMY } });
  462. meta('omega', 'loreana:/srv/omega/CONCEPT.md', [], { workers: false, note: 'like hybriel: AntColony only files issues here' });
  463. const RUNS = join(WORK, 'runs');
  464. const FAKE = join(CODE, 'tests', 'fake-claude.mjs');
  465. const FLOG = join(WORK, 'fake-claude.json');
  466. const CLOG = join(WORK, 'fake-controller.json');
  467. // mission 026: every `work` of the e2e gets a port range in OUR range (8750–8799, not the 8700-8749 default): the scheduler
  468. // now stops what a session leaves listening on its ports — only ever on ports this test owns
  469. const WPORTS = `${PB + 3}-${PB + 9}`;
  470. const workEnv = (mode, extra = {}) => ({ COLONY_PORTS: WPORTS, COLONY_HOST: 'e2e-host', COLONY_RUNS_DIR: RUNS, COLONY_CLAUDE: FAKE, FAKE_CLAUDE_MODE: mode, FAKE_CLAUDE_LOG: FLOG, FAKE_CONTROLLER_MODE: 'pass', FAKE_CONTROLLER_LOG: CLOG, ...extra });
  471. const sessionOf = (o) => (o.match(/session (s-\d{8}T\d{4}-[0-9a-f]{6}) started/) || [])[1];
  472. await mk('omega', 'omega open', 'o'); // omega#1 (workers: false)
  473. for (let i = 1; i <= 5; i++) await mk('dummy', 'dummy task ' + i, 'Create a file. Verify it.'); // dummy#1..5
  474. out = colony(['next']);
  475. check('next: a "workers": false project is never eligible, listed with that reason, no creator question', /^ omega#1 "omega open" — project omega takes no workers \("workers": false in .*omega\.json\)$/m.test(out) && !/Project omega/.test(out) && /^NEXT: hybriel#1 /m.test(out), out);
  476. snap = await snapshot();
  477. const refusals = [
  478. ['a "workers": false project', ['work', 'omega', '1'], {}, /work: REFUSED — project omega takes no workers/],
  479. ['a ticket that is not open (alpha#3 awaiting creator)', ['work', 'alpha', '3'], {}, /work: REFUSED — alpha#3 is "awaiting creator", not open/],
  480. ['permission mode bypassPermissions', ['work', 'dummy', '1', '--permission-mode', 'bypassPermissions'], {}, /work: REFUSED — permission mode "bypassPermissions" is not allowed .*never bypassPermissions/],
  481. ['the dev folder is on another host', ['work', 'dummy', '1'], { COLONY_HOST: 'otherhost' }, /work: REFUSED — the dev folder of dummy is on e2e-host, this host is otherhost/],
  482. ['no token file', ['work', 'dummy', '1'], { COLONY_TOKEN_FILE: '' }, /work: REFUSED — COLONY_TOKEN_FILE is not set/],
  483. ['a project without concept', ['work', 'beta', '1'], {}, /work: REFUSED — project beta has no concept/],
  484. ['--max-turns 0', ['work', 'dummy', '1', '--max-turns', '0'], {}, /work: REFUSED — --max-turns must be a whole number/],
  485. ];
  486. for (const [what, args, extra, re] of refusals) {
  487. const o = colony(args, workEnv('good', extra));
  488. check(`work refuses: ${what} — before any write, no worker`, re.test(o) && !/worker started/.test(o), o);
  489. }
  490. check('work refusals wrote nothing to tickets, started no worker', await snapshot() === snap && !existsSync(FLOG) && !existsSync(CLOG));
  491. // success, --post
  492. let evBefore = (await ticket('dummy', 1)).events.length;
  493. out = colony(['work', 'dummy', '1', '--post', '--model', 'haiku', '--max-turns', '7'], workEnv('good'));
  494. console.log('---- work (fake, good, --post) ----\n' + out.trim() + '\n-----------------------------------');
  495. let s1 = sessionOf(out);
  496. let d1 = await ticket('dummy', 1);
  497. let ev1 = d1.events.slice(evBefore);
  498. check('work --post: WORK DONE', /^WORK DONE — dummy#1 report posted/m.test(out), out);
  499. check('work: the lease — first new event is state open → in progress, text "A worker started on this ticket." + ONE short colony-lease line (m023), by Colony', ev1[0] && ev1[0].kind === 'state' && ev1[0].from === 'open' && ev1[0].to === 'in progress' && new RegExp(`^A worker started on this ticket\\.\\n\\ncolony-lease: ${s1} · host e2e-host · started · ms \\d+$`).test(ev1[0].text) && ev1[0].author === 'Colony', J(ev1));
  500. check('work --post: then the report comment (marker with this session), then → awaiting creator', ev1.length === 3 && ev1[1].kind === 'comment' && ev1[1].text.includes(`colony-report: ${s1} · sha256 `) && ev1[2].to === 'awaiting creator' && d1.ticket.state === 'awaiting creator', J(ev1));
  501. const flog = existsSync(FLOG) ? JSON.parse(readFileSync(FLOG, 'utf8')) : { argv: [] };
  502. const fo = (n) => { const i = flog.argv.indexOf(n); return i >= 0 ? flog.argv[i + 1] : null; };
  503. const schemaText = readFileSync(join(CODE, 'templates', 'report.schema.json'), 'utf8');
  504. check('work: claude ran IN the dev folder, with the brief on stdin', flog.cwd === DUMMY && flog.brief.startsWith('# Brief — dummy#1: dummy task 1') && existsSync(join(DUMMY, 'fake-worker-was-here.txt')), J({ cwd: flog.cwd, brief: (flog.brief || '').slice(0, 80) }));
  505. check('work: claude flags: -p, --output-format json, --json-schema = templates/report.schema.json, --model, --max-turns, --permission-mode auto, --permission-prompts none, --strict-mcp-config, --session-id; never a bypass flag', flog.argv[0] === '-p' && fo('--output-format') === 'json' && fo('--json-schema') === schemaText && fo('--model') === 'haiku' && fo('--max-turns') === '7' && fo('--permission-mode') === 'auto' && fo('--permission-prompts') === 'none' && flog.argv.includes('--strict-mcp-config') && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-8[0-9a-f]{3}-[0-9a-f]{12}$/.test(fo('--session-id') || '') && !flog.argv.some(a => /dangerously|bypass/i.test(a)), J(flog.argv.map(a => a.length > 60 ? a.slice(0, 60) + '…' : a)));
  506. check('work: the worker does not see COLONY_TOKEN_FILE', flog.tokenFileVisible === false);
  507. const R1 = join(RUNS, s1 || 'none');
  508. const files1 = ['brief.md', 'claude-result.json', 'claude-stderr.log', 'report.json', 'session.json', 'controller-prompt.md', 'controller-result.json', 'controller-stderr.log', 'verdict.json'];
  509. check('work: runs/<session>/ holds brief.md, claude-result.json (raw), claude-stderr.log, report.json, session.json + the controller\'s controller-prompt.md, controller-result.json, controller-stderr.log, verdict.json', files1.every(f => existsSync(join(R1, f))), R1);
  510. const sj1 = existsSync(join(R1, 'session.json')) ? JSON.parse(readFileSync(join(R1, 'session.json'), 'utf8')) : {};
  511. check('work: session.json records ticket, host, cost, usage, turns, claude session, permission mode seen, outcome "posted"', sj1.ticket === 'dummy#1' && sj1.host === 'e2e-host' && sj1.costUsd === 0.0123 && sj1.usage && sj1.usage.output_tokens === 22 && sj1.numTurns === 2 && sj1.claudeSession === fo('--session-id') && sj1.permissionModeSeen === 'auto' && sj1.outcome === 'posted', J(sj1));
  512. check('work: the brief in runs/ is the brief the worker got; report.json is the structured output', existsSync(join(R1, 'brief.md')) && readFileSync(join(R1, 'brief.md'), 'utf8') === flog.brief && existsSync(join(R1, 'report.json')) && JSON.parse(readFileSync(join(R1, 'report.json'), 'utf8')).session === s1);
  513. check('work: prints the usage/cost line', /^USAGE: \$0\.0123 · 2 turns · 1\.234 s · tokens in 11 \/ out 22 \/ cache read 33 \/ cache write 44 · fake-haiku · success · claude session [0-9a-f-]{36}$/m.test(out), out);
  514. // ---- the controller (mission 019) on that run ----
  515. const clog = existsSync(CLOG) ? JSON.parse(readFileSync(CLOG, 'utf8')) : { argv: [], brief: '' };
  516. const co = (n) => { const i = clog.argv.indexOf(n); return i >= 0 ? clog.argv[i + 1] : null; };
  517. const verdictSchema = readFileSync(join(CODE, 'templates', 'verdict.schema.json'), 'utf8');
  518. check('controller: ran after the worker, in the dev folder; flags -p, --json-schema = templates/verdict.schema.json, --model sonnet + --max-turns 20 (defaults), --permission-mode auto, --permission-prompts none, --strict-mcp-config, --disallowedTools Edit,Write,NotebookEdit, its own --session-id; no bypass; no token', clog.cwd === DUMMY && clog.argv[0] === '-p' && co('--output-format') === 'json' && co('--json-schema') === verdictSchema && co('--model') === 'sonnet' && co('--max-turns') === '20' && co('--permission-mode') === 'auto' && co('--permission-prompts') === 'none' && clog.argv.includes('--strict-mcp-config') && co('--disallowedTools') === 'Edit,Write,NotebookEdit' && /^[0-9a-f-]{36}$/.test(co('--session-id') || '') && co('--session-id') !== fo('--session-id') && !clog.argv.some(a => /dangerously|bypass/i.test(a)) && clog.tokenFileVisible === false, J(clog.argv.map(a => a.length > 60 ? a.slice(0, 60) + '…' : a)));
  519. const cprompt = clog.brief || '';
  520. check('controller input (stdin = runs/<session>/controller-prompt.md): the instructions, report.json, the worker\'s session as transcript (its tool call + result), the brief', cprompt.startsWith(`# Controller — check the worker report of dummy#1 (worker session \`${s1}\`)`) && cprompt.includes('## The worker report (report.json)\n\n```\n' + readFileSync(join(R1, 'report.json'), 'utf8')) && /#### \d+ · tool call `Bash`\n```\n\{"command":"cat fake-worker-was-here.txt"\}/.test(cprompt) && cprompt.includes('tool result\n```\n' + s1) && cprompt.includes('## The brief the worker got (brief.md)') && cprompt.includes(flog.brief) && existsSync(join(R1, 'controller-prompt.md')) && readFileSync(join(R1, 'controller-prompt.md'), 'utf8') === cprompt, cprompt.slice(0, 3000));
  521. const v1 = existsSync(join(R1, 'verdict.json')) ? JSON.parse(readFileSync(join(R1, 'verdict.json'), 'utf8')) : {};
  522. check('controller: verdict.json = the structured verdict (pass: the fake looked at the file), session.json.controller has cost / verdict / its claude session', v1.verdict === 'pass' && v1.findings && v1.findings[0].status === 'ok' && sj1.controller && sj1.controller.costUsd === 0.0045 && sj1.controller.verdict === 'pass' && sj1.controller.effective === 'pass' && sj1.controller.claudeSession === co('--session-id'), J({ v1, c: sj1.controller }));
  523. check('controller: prints its usage line and the verdict', /^CONTROLLER USAGE: \$0\.0045 · 2 turns .* · fake-sonnet · success/m.test(out) && /^VERDICT: PASS — 1 finding\(s\)\n ok — verified: the file exists$/m.test(out), out);
  524. check('m023/m027 pass (dummy: no live site → no deploy gate) → the comment is the worker\'s result + the Test steps as a numbered list, the controller only as "· controller pass" at the end of the ONE machine line', ev1[1] && ev1[1].text === `The fake worker wrote its file.\n\n**Test:**\n1. open the dev folder — \`fake-worker-was-here.txt\` is there\n2. it holds the session id\n\n` + ev1[1].text.split('\n').at(-1) && new RegExp(`^colony-report: ${s1} · sha256 [0-9a-f]{12} · controller pass$`).test(ev1[1].text.split('\n').at(-1)), ev1[1] && ev1[1].text);
  525. check('m023 → awaiting creator with the short state text "Ready for you to test — see the last comment."', ev1[2] && ev1[2].text === 'Ready for you to test — see the last comment.', ev1[2] && ev1[2].text);
  526. out = colony(['work', 'dummy', '1', '--post'], workEnv('good'));
  527. check('work on the same ticket again → REFUSED (not open any more)', /work: REFUSED — dummy#1 is "awaiting creator", not open/.test(out), out);
  528. // no structured report (e.g. max turns) → back to open
  529. rmSync(FLOG, { force: true });
  530. evBefore = (await ticket('dummy', 2)).events.length;
  531. out = colony(['work', 'dummy', '2', '--post'], workEnv('nostructured'));
  532. let d2 = await ticket('dummy', 2), ev2 = d2.events.slice(evBefore), s2 = sessionOf(out);
  533. check('work, no structured report (Claude error_max_turns, exit 1) → WORK FAILED, lease + back to open with the reason', /^WORK FAILED — no structured report \(Claude: error_max_turns, error, 2 turns, exit 1\) → dummy#2 back to open/m.test(out) && ev2.length === 2 && ev2[0].to === 'in progress' && ev2[1].from === 'in progress' && ev2[1].to === 'open' && ev2[1].text === `Back to open: the worker stopped without a usable result (it ran out of steps) — the next worker tries again.\n\ncolony-run: ${s2} · host e2e-host · no report` && d2.ticket.state === 'open', out + J(ev2));
  534. const sj2 = existsSync(join(RUNS, s2 || 'none', 'session.json')) ? JSON.parse(readFileSync(join(RUNS, s2, 'session.json'), 'utf8')) : {};
  535. check('work (failed): session.json keeps the outcome + cost, no report.json', /^no valid report: no structured report/.test(sj2.outcome || '') && sj2.costUsd === 0.0123 && !existsSync(join(RUNS, s2 || 'none', 'report.json')), J(sj2));
  536. // a report that fails the validation → back to open, problems listed
  537. evBefore = (await ticket('dummy', 3)).events.length;
  538. out = colony(['work', 'dummy', '3', '--post'], workEnv('badreport'));
  539. let d3 = await ticket('dummy', 3), ev3 = d3.events.slice(evBefore), s3 = sessionOf(out);
  540. const sj3 = existsSync(join(RUNS, s3 || 'none', 'session.json')) ? JSON.parse(readFileSync(join(RUNS, s3, 'session.json'), 'utf8')) : {};
  541. check('work, report refused by the validation → REPORT REFUSED, back to open; the ticket says it in plain words (m023), the problem is in session.json', /REPORT REFUSED — 1 problem:\n verified\[0\]: 'output' is missing/.test(out) && /^WORK FAILED — the report was refused by the validation \(1 problem\) → dummy#3 back to open/m.test(out) && ev3.length === 2 && ev3[1].to === 'open' && ev3[1].text.startsWith('Back to open: the worker stopped without a usable result (its report was incomplete)') && (sj3.problems || []).includes("verified[0]: 'output' is missing (every verified entry needs command AND output)") && d3.ticket.state === 'open', out + J(ev3) + J(sj3.problems));
  542. // timeout → killed → back to open
  543. evBefore = (await ticket('dummy', 4)).events.length;
  544. out = colony(['work', 'dummy', '4', '--post', '--timeout', '2'], workEnv('hang'));
  545. let d4 = await ticket('dummy', 4), ev4 = d4.events.slice(evBefore);
  546. check('work --timeout 2 on a hanging worker → killed, WORK FAILED, back to open', /work: TIMEOUT after 2 s — killing pid \d+/.test(out) && /^WORK FAILED — timed out after 2 s \(killed\) → dummy#4 back to open/m.test(out) && ev4.length === 2 && ev4[1].to === 'open' && ev4[1].text.startsWith('Back to open: the worker stopped without a usable result (it ran out of time)') && d4.ticket.state === 'open', out + J(ev4));
  547. const hung = spawnSync('pgrep', ['-f', FAKE], { encoding: 'utf8' }).stdout.trim();
  548. check('work --timeout: no fake worker left running', hung === '', hung);
  549. // valid report WITHOUT --post → stays in progress; `report --post` finishes it
  550. evBefore = (await ticket('dummy', 5)).events.length;
  551. out = colony(['work', 'dummy', '5'], workEnv('good'));
  552. let s5 = sessionOf(out);
  553. let d5 = await ticket('dummy', 5);
  554. check('work without --post: WORK OK, report valid, controller ran (pass), not posted, ticket stays "in progress" (leased)', /^REPORT OK — dummy#5/m.test(out) && /^WORK OK — valid report, controller: pass, NOT posted \(no --post\); dummy#5 stays "in progress" \(leased\)\. Post it by the verdict: \.\/colony check \S+ --post$/m.test(out) && d5.ticket.state === 'in progress' && d5.events.length === evBefore + 1, out);
  555. out = colony(['report', join(RUNS, s5 || 'none', 'report.json'), '--post']);
  556. check('… then `report runs/<session>/report.json --post` → POSTED, awaiting creator', /POSTED$/m.test(out) && (await ticket('dummy', 5)).ticket.state === 'awaiting creator', out);
  557. // ---- the controller step, posting by the verdict, check, cycle (mission 019) — FAKE worker + controller ----
  558. const hybBefore = (await api('GET', '/api/projects/hybriel/tickets')).json.tickets.length;
  559. const inboxCount = async () => (await api('GET', '/api/projects/antcolony/tickets')).json.tickets.length;
  560. // fail (1 of 2): the report claims a file that does not exist; the (fake) controller looks and finds nothing
  561. const t6 = await mk('dummy', 'dummy false claim', 'Create missing.txt.');
  562. evBefore = (await ticket('dummy', t6.number)).events.length;
  563. const inb0 = await inboxCount();
  564. out = colony(['work', 'dummy', String(t6.number), '--post'], workEnv('falseclaim'));
  565. console.log('---- work (fake, false claim, --post) ----\n' + out.trim() + '\n------------------------------------------');
  566. const s6 = sessionOf(out);
  567. let d6 = await ticket('dummy', t6.number), ev6 = d6.events.slice(evBefore);
  568. check('fail: a report claiming a file that does not exist → VERDICT: FAIL (finding false) → WORK FAILED (1 of 2) → back to open', /^VERDICT: FAIL — 1 finding\(s\)\n false — verified: `missing\.txt` exists and says hello$/m.test(out) && new RegExp(`^WORK FAILED — controller: fail \\(1 of 2\\) → dummy#${t6.number} back to open with the findings`, 'm').test(out) && d6.ticket.state === 'open', out);
  569. check('m023 fail: events = lease, ONE short comment (what happened, the false finding, the machine line "fail · 1 of 2"), → open with a short state text', ev6.length === 3 && ev6[0].to === 'in progress' && ev6[1].kind === 'comment' && ev6[1].text === `The result did not hold up when it was checked — back to open for another try.\n\n- Not true: \`missing.txt\` exists and says hello\n\ncolony-verdict: ${s6} · fail · 1 of 2` && ev6[2].from === 'in progress' && ev6[2].to === 'open' && ev6[2].text === 'Back to open: the check failed (1 of 2 tries).', J(ev6));
  570. check('fail: the failed report\'s issues are NOT filed, no creator question yet', (await api('GET', '/api/projects/hybriel/tickets')).json.tickets.length === hybBefore && await inboxCount() === inb0);
  571. snap = await snapshot();
  572. rmSync(CLOG, { force: true });
  573. out = colony(['check', join(RUNS, s6 || 'none'), '--post'], workEnv('good'));
  574. check('check --post on that run again: uses its verdict.json (no controller session), "nothing new", store unchanged', /check: using the verdict of .*verdict\.json/.test(out) && /HAVE the controller fail comment/.test(out) && !/controller: started/.test(out) && !existsSync(CLOG) && await snapshot() === snap, out);
  575. // fail 2 of 2 → a creator question instead, ticket on hold
  576. evBefore = (await ticket('dummy', t6.number)).events.length;
  577. out = colony(['work', 'dummy', String(t6.number), '--post'], workEnv('falseclaim'));
  578. const s6b = sessionOf(out);
  579. d6 = await ticket('dummy', t6.number); ev6 = d6.events.slice(evBefore);
  580. const inb2 = (await api('GET', '/api/projects/antcolony/tickets')).json.tickets;
  581. const fq = inb2.find(t => t.source === `colony-verdict:${s6b}:question`);
  582. check('2nd fail on the same ticket (N = 2) → creator question (antcolony, awaiting creator) naming both sessions; ticket on hold; comment links the question', /^WORK FAILED — controller: fail \(2 of 2\) → creator question antcolony#\d+, dummy#\d+ on hold/m.test(out) && fq && fq.state === 'awaiting creator' && fq.subject === `Question (dummy#${t6.number}): the work failed its check 2 times — how should it go on?` && fq.summary.startsWith(`[dummy #${t6.number}](${BASE}/projects/dummy/${t6.number}) failed its check 2 times, so no worker starts on it any more (it is on hold).\n\n**What to do:**`) && d6.ticket.state === 'on hold' && ev6.length === 3 && ev6[1].text.startsWith(`The result failed its check 2 times — on hold until you answer [antcolony #${fq && fq.number}](`) && ev6[1].text.endsWith(`colony-verdict: ${s6b} · fail · 2 of 2`) && ev6[2].to === 'on hold' && inb2.length === inb0 + 1, out + J(ev6) + J(fq));
  583. out = colony(['next']);
  584. check('next: the on-hold ticket is not picked any more', new RegExp(`^ dummy#${t6.number} .* — on hold$`, 'm').test(out), out);
  585. // missing evidence: the controller says pass, but one finding is "no evidence" → the CODE makes it a fail
  586. const t7 = await mk('dummy', 'dummy no evidence', 'Create a file.');
  587. evBefore = (await ticket('dummy', t7.number)).events.length;
  588. out = colony(['work', 'dummy', String(t7.number), '--post'], workEnv('good', { FAKE_CONTROLLER_MODE: 'noevidence' }));
  589. const s7 = sessionOf(out);
  590. const d7 = await ticket('dummy', t7.number), ev7 = d7.events.slice(evBefore);
  591. const sj7 = s7 && existsSync(join(RUNS, s7, 'session.json')) ? JSON.parse(readFileSync(join(RUNS, s7, 'session.json'), 'utf8')) : { controller: {} };
  592. check('missing evidence: controller answered pass with a "no evidence" finding → VERDICT: FAIL (overruled by the code) → back to open, the comment says so', /^VERDICT: FAIL \(the controller said pass, but 1 finding\(s\) are false \/ no evidence → fail\)/m.test(out) && /^ no evidence — done: created `fake-worker-was-here\.txt`$/m.test(out) && d7.ticket.state === 'open' && ev7.length === 3 && ev7[1].text.startsWith('The result could not be confirmed when it was checked') && ev7[1].text.includes('\n- Not shown: created `fake-worker-was-here.txt`\n') && ev7[1].text.endsWith('· fail · 1 of 2 · overruled') && sj7.controller.verdict === 'pass' && sj7.controller.effective === 'fail' && sj7.controller.overruled === true && sj7.outcome === 'sent back', out + J(ev7) + J(sj7.controller));
  593. // --max-fails 1: the first fail is already the creator question
  594. const t8 = await mk('dummy', 'dummy max fails 1', 'x');
  595. out = colony(['work', 'dummy', String(t8.number), '--post', '--max-fails', '1'], workEnv('good', { FAKE_CONTROLLER_MODE: 'fail' }));
  596. const d8 = await ticket('dummy', t8.number);
  597. check('--max-fails 1 (controller says fail): the first fail → creator question, ticket on hold; the texts say "once" (m023)', /^WORK FAILED — controller: fail \(1 of 1\) → creator question antcolony#\d+/m.test(out) && d8.ticket.state === 'on hold' && d8.events.at(-1).text === 'On hold: the check failed once — waiting for your answer.' && d8.events.at(-2).text.startsWith('The result failed its check once — on hold until you answer'), out + J(d8.events.slice(-2)));
  598. // no verdict (the controller ends without structured output) → nothing posted, stays leased; `check --post` retries
  599. const t9 = await mk('dummy', 'dummy controller crash', 'x');
  600. evBefore = (await ticket('dummy', t9.number)).events.length;
  601. out = colony(['work', 'dummy', String(t9.number), '--post'], workEnv('good', { FAKE_CONTROLLER_MODE: 'nostructured' }));
  602. const s9 = sessionOf(out);
  603. let d9 = await ticket('dummy', t9.number);
  604. check('controller without a verdict → "WORK OK, CONTROLLER FAILED", nothing posted, ticket stays "in progress"', /^controller: NO VERDICT — no structured verdict \(Claude: error_max_turns, error, 2 turns, exit 1\)$/m.test(out) && /^WORK OK, CONTROLLER FAILED — .* Retry: \.\/colony check \S+ --post$/m.test(out) && d9.ticket.state === 'in progress' && d9.events.length === evBefore + 1, out);
  605. out = colony(['check', join(RUNS, s9 || 'none'), '--post'], workEnv('good'));
  606. d9 = await ticket('dummy', t9.number);
  607. check('check <run> --post: runs the controller anew (pass) and posts → CHECK DONE, awaiting creator', /^controller: started/m.test(out) && /^VERDICT: PASS/m.test(out) && /^CHECK DONE — dummy#\d+ report posted \(controller: pass\)/m.test(out) && d9.ticket.state === 'awaiting creator' && d9.events.at(-2).text.endsWith(' · controller pass'), out);
  608. // cycle: nothing on this host; dry run; then one whole cycle
  609. out = colony(['cycle'], workEnv('good', { COLONY_HOST: 'nohost' }));
  610. check('cycle on a host without an eligible ticket → nothing to do, the skipped ones named', /^CYCLE — nothing to do: no eligible open ticket with its dev folder on nohost$/m.test(out) && /^cycle: next — skipping hybriel#1 \(eligible, but its dev folder is on loreana, this host is nohost\)$/m.test(out), out);
  611. const oldest = (await api('GET', '/api/projects/dummy/tickets')).json.tickets.filter(t => t.state === 'open').sort((a, b) => a.createdMs - b.createdMs || a.number - b.number)[0];
  612. rmSync(FLOG, { force: true }); rmSync(CLOG, { force: true });
  613. snap = await snapshot();
  614. const runsBefore = readdirSync(RUNS).length;
  615. out = colony(['cycle', '--post', '--dry-run'], workEnv('good'));
  616. check(`cycle --post --dry-run → picks dummy#${oldest.number} (oldest eligible on e2e-host), says what it would run, starts no Claude session, writes nothing`, new RegExp(`^cycle: 1 next → dummy#${oldest.number} `, 'm').test(out) && /^work: DRY RUN — would lease dummy#\d+ .*then the controller .*then post by the verdict \(max fails 2\)\. Nothing written, no Claude session started\.$/m.test(out) && /^CYCLE DRY RUN — stopped before any Claude session; nothing written$/m.test(out) && !existsSync(FLOG) && !existsSync(CLOG) && readdirSync(RUNS).length === runsBefore && await snapshot() === snap, out);
  617. out = colony(['cycle', '--post'], workEnv('good'));
  618. console.log('---- cycle --post (fake) ----\n' + out.trim() + '\n-----------------------------');
  619. const dc = await ticket('dummy', oldest.number);
  620. check('cycle --post → next → work → controller → post in one call: each step printed, summed cost $0.0168 (0.0123 + 0.0045), ticket awaiting creator', new RegExp(`^cycle: 1 next → dummy#${oldest.number} `, 'm').test(out) && /^cycle: 2 work → 3 controller → 4 post by the verdict$/m.test(out) && /^work: dummy#\d+ leased/m.test(out) && /^controller: started/m.test(out) && /^WORK DONE — /m.test(out) && /^CYCLE COST: \$0\.0168 \(worker \$0\.0123 \+ controller \$0\.0045\)$/m.test(out) && new RegExp(`^CYCLE END — dummy#${oldest.number}: posted$`, 'm').test(out) && dc.ticket.state === 'awaiting creator', out);
  621. // ---- mission 025: the finalize step (out of steps / time → resume the same session once to write the report),
  622. // the budget in the brief, `salvage` — FAKE claude only ----
  623. {
  624. const ZLOG = join(WORK, 'fake-finalize.json');
  625. const zlog = () => existsSync(ZLOG) ? JSON.parse(readFileSync(ZLOG, 'utf8')) : { argv: [], brief: '' };
  626. const zo = (l, n) => { const i = l.argv.indexOf(n); return i >= 0 ? l.argv[i + 1] : null; };
  627. const sjOf = (s) => s && existsSync(join(RUNS, s, 'session.json')) ? JSON.parse(readFileSync(join(RUNS, s, 'session.json'), 'utf8')) : {};
  628. const zenv = (mode, fmode, extra = {}) => workEnv(mode, { FAKE_FINALIZE_MODE: fmode, FAKE_FINALIZE_LOG: ZLOG, ...extra });
  629. // the budget in the brief
  630. const tb = await mk('dummy', 'dummy budget', 'x');
  631. colony(['brief', 'dummy', String(tb.number), '--out', 'brief-budget-120.md', '--max-turns', '120', '--timeout', '3600']);
  632. const bb = readFileSync(join(WORK, 'brief-budget-120.md'), 'utf8');
  633. check('m025 brief: a "Budget" section tells the step limit (--max-turns 120) and to report before step 110, the time (60 minutes), and to report honestly when short', bb.includes('## Budget — leave room for the report\n- **Steps: at most 120** (every tool call is a step; Claude stops the session at step 120). **Write your report before step 110.**') && bb.includes('- Time: at most 60 minutes, then the session is killed.') && /not copied back yet/.test(bb) && bb.indexOf('## Budget') < bb.indexOf('## Rules'), bb.slice(bb.indexOf('## Budget'), bb.indexOf('## Budget') + 600));
  634. colony(['brief', 'dummy', String(tb.number), '--out', 'brief-budget-def.md']);
  635. const bd2 = readFileSync(join(WORK, 'brief-budget-def.md'), 'utf8');
  636. colony(['brief', 'dummy', String(tb.number), '--out', 'brief-budget-7.md'], { COLONY_MAX_TURNS: '7', COLONY_WORK_TIMEOUT: '90' });
  637. const b7 = readFileSync(join(WORK, 'brief-budget-7.md'), 'utf8');
  638. check('m025 brief: default 40 steps → report before step 30; COLONY_MAX_TURNS 7 → before step 5, 90 s → "1 minutes"', bd2.includes('**Steps: at most 40**') && bd2.includes('before step 30.') && b7.includes('**Steps: at most 7**') && b7.includes('before step 5.') && b7.includes('at most 1 minutes'), bd2.slice(bd2.indexOf('## Budget'), bd2.indexOf('## Budget') + 300) + '\n' + b7.slice(b7.indexOf('## Budget'), b7.indexOf('## Budget') + 300));
  639. await api('POST', `/api/projects/dummy/tickets/${tb.number}/state`, { state: 'on hold', text: 'e2e: only for the brief' });
  640. // 1. max turns → finalize → valid report → controller → posted
  641. const tz = await mk('dummy', 'dummy out of steps', 'x');
  642. let evB = (await ticket('dummy', tz.number)).events.length;
  643. rmSync(ZLOG, { force: true }); rmSync(FLOG, { force: true });
  644. out = colony(['work', 'dummy', String(tz.number), '--post', '--max-turns', '7'], zenv('nostructured', 'good'));
  645. console.log('---- work (fake: out of steps → finalize → report) ----\n' + out.trim() + '\n-----------------------------------');
  646. const sz = sessionOf(out), RZ = join(RUNS, sz || 'none'), sjz = sjOf(sz), zl = zlog();
  647. const dz = await ticket('dummy', tz.number), evz = dz.events.slice(evB);
  648. check('m025 out of steps: "work: FINALIZE — the worker ran out of steps …", FINALIZE USAGE, then controller PASS and WORK DONE → posted', /^work: FINALIZE — the worker ran out of steps without a report \(no structured report \(Claude: error_max_turns, error, 2 turns, exit 1\)\); resuming claude session [0-9a-f-]{36} once to write it: --max-turns 8, timeout 600 s, same tools as the worker \(prompt cache\)/m.test(out) && /^FINALIZE USAGE: \$0\.0246 · 2 turns .* · success · claude session /m.test(out) && /^FINALIZE COST: \$0\.0123 for this part \(Claude reports \$0\.0246 for the whole session so far\)$/m.test(out) && /^VERDICT: PASS/m.test(out) && new RegExp(`^WORK DONE — dummy#${tz.number} report posted`, 'm').test(out), out);
  649. check('m025 out of steps: the ticket got exactly lease, report comment (the finalize step\'s result), → awaiting creator — nothing about the finalize step itself', evz.length === 3 && evz[0].to === 'in progress' && evz[1].kind === 'comment' && evz[1].text.startsWith('The fake worker wrote its file; its report came from the finalize step.') && evz[1].text.endsWith(' · controller pass') && evz[2].to === 'awaiting creator' && dz.ticket.state === 'awaiting creator', J(evz));
  650. const wlog = existsSync(FLOG) ? JSON.parse(readFileSync(FLOG, 'utf8')) : { argv: [] };
  651. check('m025 finalize call: `claude -p --resume <the worker\'s claude session>` in the dev folder, same --json-schema / model / permission mode auto / --permission-prompts none / --strict-mcp-config, --max-turns 8, NO --disallowedTools (m026: same tool list as the worker → prompt cache), no --session-id, no bypass, no token', zl.cwd === DUMMY && zl.argv[0] === '-p' && zo(zl, '--resume') === zo(wlog, '--session-id') && zo(zl, '--resume') === sjz.claudeSession && zo(zl, '--json-schema') === schemaText && zo(zl, '--model') === 'sonnet' && zo(zl, '--max-turns') === '8' && zo(zl, '--permission-mode') === 'auto' && zo(zl, '--permission-prompts') === 'none' && zl.argv.includes('--strict-mcp-config') && !zl.argv.includes('--disallowedTools') && !zl.argv.includes('--session-id') && !zl.argv.some(a => /dangerously|bypass/i.test(a)) && zl.tokenFileVisible === false, J({ cwd: zl.cwd, argv: zl.argv.map(a => a.length > 60 ? a.slice(0, 60) + '…' : a), worker: zo(wlog, '--session-id') }));
  652. const zp = zl.brief || '';
  653. check('m025 finalize instruction (stdin = runs/<S>/finalize.md): stop working, start nothing, change nothing, the step budget, honest done/verified/open, the ticket + session, the marker line', zp.startsWith('# STOP WORKING — write your report now\n\nYour session ran out of steps (the step limit of 7 was reached) before you wrote your report.') && zp.includes('**Do not continue the task.**') && zp.includes('You have at most 8 steps') && zp.includes('goes into `open`') && zp.includes(`"ticket": "dummy#${tz.number}",`) && zp.includes(`"session": "${sz}",`) && zp.endsWith(`colony-finalize: ${sz}`) && existsSync(join(RZ, 'finalize.md')) && readFileSync(join(RZ, 'finalize.md'), 'utf8') === zp, zp);
  654. check('m025 run files: the worker\'s output kept as claude-result.before-finalize.json (+ stderr), the finalize output as claude-result.json, report.json, verdict.json', ['claude-result.before-finalize.json', 'claude-stderr.before-finalize.log', 'claude-result.json', 'finalize.md', 'report.json', 'verdict.json'].every(f => existsSync(join(RZ, f))) && /error_max_turns/.test(readFileSync(join(RZ, 'claude-result.before-finalize.json'), 'utf8')) && /"structured_output"/.test(readFileSync(join(RZ, 'claude-result.json'), 'utf8')), RZ);
  655. check('m025 session.json: finalize { why, worker subtype/turns/cost, maxTurns 8, cost, outcome "valid report" }, the costs summed (worker $0.0123 before + finalize $0.0123 = the cumulative $0.0246 Claude reports for the resumed session, not added twice), outcome posted', sjz.finalize && sjz.finalize.why === 'ran out of steps' && sjz.finalize.workerSubtype === 'error_max_turns' && sjz.finalize.workerCostUsd === 0.0123 && sjz.finalize.maxTurns === '8' && sjz.finalize.costUsd === 0.0123 && sjz.finalize.outcome === 'valid report' && sjz.costEarlier === 0.0123 && sjz.costUsd === 0.0123 && sjz.claudeCostSeen === 0.0246 && sjz.outcome === 'posted', J({ finalize: sjz.finalize, costEarlier: sjz.costEarlier, costUsd: sjz.costUsd, outcome: sjz.outcome }));
  656. const zcp = existsSync(join(RZ, 'controller-prompt.md')) ? readFileSync(join(RZ, 'controller-prompt.md'), 'utf8') : '';
  657. check('m025 the controller sees both parts: the worker until it ran out, then the finalize step', zcp.includes('### Part 1 — the worker, until it ran out of steps / time (claude-result.before-finalize.json)') && zcp.includes('### Part 2 — the finalize step: the same session resumed ONLY to write the report') && !zcp.includes('SALVAGED run'), zcp.slice(zcp.indexOf("## The worker's session"), zcp.indexOf("## The worker's session") + 800));
  658. // 2. the finalize step fails too → back to open ONCE (as before), the reason = the worker's
  659. const tf = await mk('dummy', 'dummy finalize fails', 'x');
  660. evB = (await ticket('dummy', tf.number)).events.length;
  661. out = colony(['work', 'dummy', String(tf.number), '--post'], zenv('nostructured', 'nostructured'));
  662. const sf2 = sessionOf(out), sjf2 = sjOf(sf2);
  663. const df2 = await ticket('dummy', tf.number), evf2 = df2.events.slice(evB);
  664. check('m025 finalize fails too → "work: FINALIZE FAILED", WORK FAILED with the worker\'s reason → back to open ONCE (lease + one give-back, "it ran out of steps"), no controller', /^work: FINALIZE — /m.test(out) && /^work: FINALIZE FAILED — no structured report \(Claude: error_max_turns, error, 2 turns, exit 1\) \(the worker ran out of steps before\)$/m.test(out) && new RegExp(`^WORK FAILED — no structured report \\(Claude: error_max_turns, error, 2 turns, exit 1\\) → dummy#${tf.number} back to open`, 'm').test(out) && !/controller: started/.test(out) && evf2.length === 2 && evf2[1].to === 'open' && evf2[1].text === `Back to open: the worker stopped without a usable result (it ran out of steps) — the next worker tries again.\n\ncolony-run: ${sf2} · host e2e-host · no report` && df2.ticket.state === 'open', out + J(evf2));
  665. check('m025 finalize fails: session.json outcome names both, finalize.outcome "no report: …", cost of both parts', /^no valid report: no structured report .* · finalize step: no structured report/.test(sjf2.outcome || '') && /^no report: /.test((sjf2.finalize || {}).outcome || '') && sjf2.costEarlier === 0.0123 && sjf2.costUsd === 0.0123, J(sjf2));
  666. // 3. out of TIME → finalize (its timeout ≤ the worker's) → report → posted
  667. const tt = await mk('dummy', 'dummy out of time', 'x');
  668. rmSync(ZLOG, { force: true });
  669. out = colony(['work', 'dummy', String(tt.number), '--post', '--timeout', '2'], zenv('hang', 'good'));
  670. const stt = sessionOf(out), sjt = sjOf(stt);
  671. check('m025 out of time: worker killed after 2 s → FINALIZE ("ran out of time", timeout 2 s = never more than the worker had) → WORK DONE, awaiting creator', /^work: TIMEOUT after 2 s — killing pid \d+/m.test(out) && /^work: FINALIZE — the worker ran out of time without a report \(timed out after 2 s \(killed\)\); .*--max-turns 8, timeout 2 s/m.test(out) && new RegExp(`^WORK DONE — dummy#${tt.number}`, 'm').test(out) && (await ticket('dummy', tt.number)).ticket.state === 'awaiting creator' && sjt.finalize && sjt.finalize.why === 'ran out of time' && /killed after 2 s/.test(zlog().brief || ''), out);
  672. // out of time, finalize hangs too → killed as well → back to open
  673. const th = await mk('dummy', 'dummy out of time twice', 'x');
  674. out = colony(['work', 'dummy', String(th.number), '--post', '--timeout', '2'], zenv('hang', 'hang'));
  675. check('m025 out of time, the finalize step hangs too → killed after 2 s → WORK FAILED "timed out" → open ("it ran out of time"); no fake claude left running', (out.match(/TIMEOUT after 2 s — killing pid/g) || []).length === 2 && /^WORK FAILED — timed out after 2 s \(killed\) → /m.test(out) && (await ticket('dummy', th.number)).events.at(-1).text.startsWith('Back to open: the worker stopped without a usable result (it ran out of time)') && spawnSync('pgrep', ['-f', FAKE], { encoding: 'utf8' }).stdout.trim() === '', out);
  676. // 4. --finalize-max-turns 0 = no finalize step (the old behaviour); bad values refused
  677. const t0 = await mk('dummy', 'dummy no finalize', 'x');
  678. rmSync(ZLOG, { force: true });
  679. out = colony(['work', 'dummy', String(t0.number), '--post', '--finalize-max-turns', '0'], zenv('nostructured', 'good'));
  680. const s0 = sessionOf(out);
  681. check('m025 --finalize-max-turns 0 → no finalize call, WORK FAILED → open (as before mission 025)', !/FINALIZE/.test(out) && /^WORK FAILED — no structured report/m.test(out) && !existsSync(ZLOG) && (await ticket('dummy', t0.number)).ticket.state === 'open', out);
  682. snap = await snapshot();
  683. out = colony(['work', 'dummy', String(t0.number), '--finalize-max-turns', 'x'], zenv('good', 'good'));
  684. const o2 = colony(['work', 'dummy', String(t0.number), '--finalize-timeout', '0'], zenv('good', 'good'));
  685. check('m025 work refuses --finalize-max-turns x / --finalize-timeout 0 before any write', /^work: REFUSED — --finalize-max-turns must be a whole number/m.test(out) && /^work: REFUSED — --finalize-timeout must be whole seconds/m.test(o2) && await snapshot() === snap, out + o2);
  686. // 5. salvage: refusals
  687. const R0 = join(RUNS, s0 || 'none');
  688. const refs = [
  689. ['a run that ended well (posted)', [R1], {}, /^salvage: REFUSED — the worker of .* did not run out of steps or time \(outcome: posted\) — nothing to salvage$/m],
  690. ['a run whose worker did not run out (its report was refused)', [join(RUNS, s3 || 'none')], {}, /^salvage: REFUSED — the worker of .* did not run out of steps or time \(outcome: no valid report: the report was refused/m],
  691. ['another host', [R0], { COLONY_HOST: 'otherhost' }, /^salvage: REFUSED — session s-\S+ ran on e2e-host, this host is otherhost/m],
  692. ['--post without a token', [R0, '--post'], { COLONY_TOKEN_FILE: '' }, /^salvage: REFUSED — COLONY_TOKEN_FILE is not set/m],
  693. ['no such folder', [join(RUNS, 'nope')], {}, /^salvage: REFUSED — /m],
  694. ];
  695. for (const [what, a, extra, re] of refs) {
  696. const o = colony(['salvage', ...a], zenv('good', 'good', extra));
  697. check(`m025 salvage refuses: ${what} — nothing started`, re.test(o) && !/FINALIZE/.test(o), o);
  698. }
  699. check('m025 salvage refusals: store unchanged, no finalize call', await snapshot() === snap && !existsSync(ZLOG) && !existsSync(join(R0, 'finalize.md')));
  700. // a leased ticket → refused with --post
  701. await api('POST', `/api/projects/dummy/tickets/${t0.number}/state`, { state: 'in progress', text: 'e2e: leased by someone else' });
  702. snap = await snapshot();
  703. out = colony(['salvage', R0, '--post'], zenv('good', 'good'));
  704. check('m025 salvage --post while the ticket is "in progress" (another session) → refused, nothing started', new RegExp(`^salvage: REFUSED — dummy#${t0.number} is "in progress" at http`, 'm').test(out) && await snapshot() === snap && !existsSync(ZLOG), out);
  705. await api('POST', `/api/projects/dummy/tickets/${t0.number}/state`, { state: 'open', text: 'e2e: back' });
  706. // salvage whose finalize fails → (mission 026: salvage takes a LEASE like work) lease + the lease given back; a second salvage is refused
  707. let evS0 = (await ticket('dummy', t0.number)).events.length;
  708. out = colony(['salvage', R0, '--post'], zenv('good', 'nostructured'));
  709. let evS = (await ticket('dummy', t0.number)).events.slice(evS0);
  710. check('m025/m026 salvage, the finalize step gives no report → SALVAGE FAILED; m026: the ticket got the salvage LEASE (open → in progress, colony-lease … salvage) and it back (→ open, "ran out of steps") — nothing else', /^salvage: dummy#\d+ leased until /m.test(out) && /^work: FINALIZE — the worker ran out of steps/m.test(out) && /^SALVAGE FAILED — the finalize step gave no valid report .* → dummy#\d+ back to open/m.test(out) && /^SALVAGE END — dummy#\d+: no valid report$/m.test(out) && evS.length === 2 && evS[0].from === 'open' && evS[0].to === 'in progress' && evS[0].text.startsWith('A worker started on this ticket: it writes the report of an earlier session.\n\ncolony-lease: ' + s0 + ' · host e2e-host · salvage · ms ') && evS[1].from === 'in progress' && evS[1].to === 'open' && evS[1].text.startsWith('Back to open: the worker stopped without a usable result (it ran out of steps)'), out + J(evS));
  711. snap = await snapshot();
  712. out = colony(['salvage', R0, '--post'], zenv('good', 'good'));
  713. check('m025 salvage again on the same run → refused (one finalize per session)', /^salvage: REFUSED — .* had its finalize step already \(no report: /m.test(out) && await snapshot() === snap, out);
  714. // salvage → report → controller (read-only note) → posted
  715. const ts = await mk('dummy', 'dummy salvage me', 'x');
  716. out = colony(['work', 'dummy', String(ts.number), '--post', '--finalize-max-turns', '0'], zenv('nostructured', 'good'));
  717. const ss = sessionOf(out), RS = join(RUNS, ss || 'none');
  718. evB = (await ticket('dummy', ts.number)).events.length;
  719. rmSync(ZLOG, { force: true });
  720. out = colony(['salvage', RS, '--post', '--ports', '8770-8779'], zenv('good', 'good', { FAKE_LOG_DIR: join(WORK, 'fake-calls-m025') }));
  721. console.log('---- salvage --post (fake) ----\n' + out.trim() + '\n-----------------------------------');
  722. const dsv = await ticket('dummy', ts.number), evs = dsv.events.slice(evB), sjs = sjOf(ss);
  723. const scp = existsSync(join(RS, 'controller-prompt.md')) ? readFileSync(join(RS, 'controller-prompt.md'), 'utf8') : '';
  724. check('m025 salvage --post: FINALIZE (resume of the run\'s claude session) → controller PASS → SALVAGE DONE; SALVAGE COST finalize $0.0123 + controller $0.0045 (worker before $0.0123, whole $0.0291); SALVAGE END posted', /^salvage: dummy#\d+ session s-\S+ — the worker ran out of steps .* · posts to http/m.test(out) && zo(zlog(), '--resume') === sjs.claudeSession && /^VERDICT: PASS/m.test(out) && new RegExp(`^SALVAGE DONE — dummy#${ts.number} report posted \\(controller: pass\\)`, 'm').test(out) && /^SALVAGE COST: finalize \$0\.0123 \+ controller \$0\.0045 \(the worker before: \$0\.0123; whole session now \$0\.0291\)$/m.test(out) && new RegExp(`^SALVAGE END — dummy#${ts.number}: posted$`, 'm').test(out), out);
  725. check('m025/m026 salvage --post: the open ticket got the LEASE (m026: open → in progress), the report comment, → awaiting creator', evs.length === 3 && evs[0].from === 'open' && evs[0].to === 'in progress' && evs[0].text.includes('colony-lease: ' + ss + ' · host e2e-host · salvage · ms ') && evs[1].kind === 'comment' && evs[1].text.startsWith('The fake worker wrote its file; its report came from the finalize step.') && evs[2].from === 'in progress' && evs[2].to === 'awaiting creator' && dsv.ticket.state === 'awaiting creator', J(evs));
  726. check('m025 salvage: the controller was told to re-run nothing that writes (SALVAGED run), sees both parts; --ports went to finalize + controller; session.json: salvaged, finalize, outcome posted', scp.includes('This is a SALVAGED run') && scp.includes('### Part 2 — the finalize step') && sjs.salvaged && sjs.finalize && sjs.finalize.outcome === 'valid report' && sjs.ports === '8770-8779' && sjs.outcome === 'posted' && readdirSync(join(WORK, 'fake-calls-m025')).map(f => JSON.parse(readFileSync(join(WORK, 'fake-calls-m025', f), 'utf8'))).filter(c => c.kind === 'finalize' || c.kind === 'controller').every(c => c.ports === '8770-8779'), J({ salvaged: sjs.salvaged, finalize: sjs.finalize, ports: sjs.ports, outcome: sjs.outcome }));
  727. snap = await snapshot();
  728. out = colony(['check', RS, '--post'], zenv('good', 'good'));
  729. check('m025 check --post on the salvaged run afterwards → uses verdict.json, nothing new (store identical) — how the architect posts a salvage elsewhere', /check: using the verdict/.test(out) && /POSTED — nothing new/.test(out) && await snapshot() === snap, out);
  730. }
  731. // ---- `run`: the loop, leases that expire, quota reserve, parking + resume, port ranges, SIGTERM (mission 020) ----
  732. // FAKE claude only. Projects d1 + d2 (host e2e-host, own dev folders); the open dummy tickets are put on hold first
  733. // (test setup) so only d1/d2 tickets are eligible on e2e-host. Worker port pool 8780-8799 (10 per session).
  734. {
  735. for (const t of (await api('GET', '/api/projects/dummy/tickets')).json.tickets) if (t.state === 'open') await api('POST', `/api/projects/dummy/tickets/${t.number}/state`, { state: 'on hold', text: 'e2e: parked for the run tests' });
  736. const D1 = join(WORK, 'd1'), D2 = join(WORK, 'd2');
  737. for (const [n, d] of [['d1', D1], ['d2', D2]]) {
  738. mkdirSync(d, { recursive: true });
  739. writeFileSync(join(d, 'CONCEPT.md'), `# ${n}\nA test project of the run loop.\n`);
  740. meta(n, d + '/CONCEPT.md', [], { dev: { host: 'e2e-host', folder: d } });
  741. }
  742. const FLD = join(WORK, 'fake-calls');
  743. const UF = join(WORK, 'usage-percent');
  744. writeFileSync(UF, '5\n');
  745. const WF = join(WORK, 'week-percent'); // mission 023: the weekly usage the fake /usage answers
  746. writeFileSync(WF, '41\n');
  747. const denv = (extra = {}) => ({ COLONY_HOST: 'e2e-host', COLONY_RUNS_DIR: RUNS, COLONY_CLAUDE: FAKE, FAKE_CLAUDE_MODE: 'good', FAKE_CONTROLLER_MODE: 'pass',
  748. FAKE_LOG_DIR: FLD, FAKE_USAGE_FILE: UF, FAKE_WEEK_FILE: WF, COLONY_PORT_POOL: '8780-8799', COLONY_PORTS_PER_SESSION: '10', FAKE_CLAUDE_LOG: '', FAKE_CONTROLLER_LOG: '', ...extra });
  749. const calls = () => { try { return readdirSync(FLD).map(f => JSON.parse(readFileSync(join(FLD, f), 'utf8'))); } catch { return []; } };
  750. const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
  751. // `colony run` in the background: its output, waiting for a line, its exit
  752. const startRun = (args, extra) => {
  753. const proc = spawn(join(CODE, 'colony'), ['run', ...args], { cwd: WORK, env: colonyEnv(extra), stdio: ['ignore', 'pipe', 'pipe'] });
  754. const d = { proc, text: '', schedPid: null, exited: null };
  755. d.exited = new Promise(r => proc.once('exit', (code, sig) => r({ code, sig })));
  756. const add = (b) => { d.text += b; const m = d.text.match(/scheduler pid (\d+)/); if (m) d.schedPid = Number(m[1]); };
  757. proc.stdout.on('data', add); proc.stderr.on('data', add);
  758. d.waitFor = async (re, ms = 20000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { const m = d.text.match(re); if (m) return m; await sleep(100); } return null; };
  759. d.done = async (ms = 20000) => Promise.race([d.exited, sleep(ms).then(() => null)]);
  760. daemons.push(d);
  761. return d;
  762. };
  763. const RUNLOG = join(WORK, 'run-logs');
  764. mkdirSync(RUNLOG, { recursive: true });
  765. const keep = (name, d) => writeFileSync(join(RUNLOG, name + '.txt'), d.text);
  766. const iterLines = (t) => t.split('\n').filter(l => l.startsWith('ITER '));
  767. // live guard
  768. out = colony(['run', '--once'], denv({ COLONY_TICKETS_URL: '' }));
  769. check('run without COLONY_TICKETS_URL (= the live default) → REFUSED, names --live, starts nothing', /^run: REFUSED — https:\/\/tickets\.worldapi\.org is the LIVE tickets server .* pass --live/m.test(out) && !/^ITER /m.test(out), out);
  770. out = colony(['run', '--once'], denv({ COLONY_TICKETS_URL: 'https://Tickets.WorldAPI.org/' }));
  771. check('run against https://Tickets.WorldAPI.org/ (any spelling) → REFUSED', /^run: REFUSED — .* is the LIVE tickets server/m.test(out), out);
  772. out = colony(['run', '--once', '--parallel', '3'], denv());
  773. check('run --parallel 3 with a pool of 2 ranges (8780-8799, 10 each) → REFUSED', /^run: REFUSED — the port pool 8780-8799 has 2 range\(s\) of 10 ports, --parallel 3 needs 3/m.test(out), out);
  774. // quota above the reserve → no start
  775. const q1 = await mk('d1', 'd1 quota', 'Create a file.');
  776. writeFileSync(UF, '80\n');
  777. let snapQ = await snapshot();
  778. out = colony(['run', '--once'], denv());
  779. writeFileSync(join(RUNLOG, 'quota-above.txt'), out);
  780. check('run --once, quota 80% ≥ reserve 70% → ONE iteration line "no start (waiting: d1#1)", RUN STOPPED, store unchanged, no worker', /^ITER 1 \S+ · quota 80% of 5 h \(resets [^)]+\), week 41% ≥ reserve 70% → no start \(waiting: d1#1\) · running 0\/1/m.test(out) && /^RUN STOPPED — --once/m.test(out) && await snapshot() === snapQ && !calls().some(c => c.kind === 'worker'), out);
  781. const probe = calls().find(c => c.kind === 'usage');
  782. check('quota probe = `claude -p /usage --output-format json --verbose --model haiku --max-turns 1 --permission-mode plan --permission-prompts none --strict-mcp-config` (never bypass), no token, no stop file', probe && probe.argv.join(' ') === '-p /usage --output-format json --verbose --model haiku --max-turns 1 --permission-mode plan --permission-prompts none --strict-mcp-config' && !probe.tokenFileVisible && !probe.stopFileVisible, J(probe));
  783. // mission 023 (creator): no new start while the WEEKLY usage is above 84 % (--week-limit); unknown → no start
  784. out = colony(['run', '--once', '--week-limit', '101'], denv());
  785. check('m023 run --week-limit 101 → REFUSED (a percentage 0–100)', /^run: REFUSED — --week-limit must be a percentage 0–100, got 101$/m.test(out), out);
  786. writeFileSync(UF, '5\n'); writeFileSync(WF, '85\n');
  787. out = colony(['run', '--once'], denv());
  788. writeFileSync(join(RUNLOG, 'week-above.txt'), out);
  789. check('m023 run --once, 5 h quota 5% but WEEK 85% > week limit 84% → "no start (waiting: d1#1)", store unchanged, no worker', /^RUN: started .*no start above 84% of the week/m.test(out) && /^ITER 1 \S+ · quota 5% of 5 h \(resets [^)]+\), week 85% > week limit 84% → no start \(waiting: d1#1\)/m.test(out) && await snapshot() === snapQ && !calls().some(c => c.kind === 'worker'), out);
  790. writeFileSync(WF, 'none\n');
  791. out = colony(['run', '--once'], denv());
  792. writeFileSync(join(RUNLOG, 'week-unknown.txt'), out);
  793. check('m023 run --once, the /usage answer has NO weekly number → "week UNKNOWN → no start", store unchanged', /^ITER 1 \S+ · quota 5% of 5 h \(resets [^)]+\), week UNKNOWN → no start \(waiting: d1#1\)/m.test(out) && await snapshot() === snapQ && !calls().some(c => c.kind === 'worker'), out);
  794. // architect update 2026-09-24: both limits can be set to 100 = no block (5 h at 100 %, week unknown → still starts)
  795. // mission 034: at 100 % the limit is REACHED — that holds every start also with the checks off (block "mission 034"), so
  796. // "both checks off" is shown at 99 % (was 100 % before mission 034)
  797. writeFileSync(UF, '99\n');
  798. out = colony(['run', '--once', '--reserve', '100', '--week-limit', '100'], denv({ FAKE_CLAUDE_MODE: 'good' }));
  799. writeFileSync(join(RUNLOG, 'quota-reserve90.txt'), out);
  800. check('m023 --reserve 100 --week-limit 100 at 5 h 99 % and NO weekly number → both checks off, starts d1#1', /^RUN: started .*quota reserve 100% of the 5 h window, no start above 100% of the week/m.test(out) && /^ITER 1 \S+ · quota 99% of 5 h \(resets [^)]+\) · 5 h reserve off \(100%\) · week limit off \(100%\) · running 1\/1 · started d1#1/m.test(out), out);
  801. writeFileSync(WF, '41\n');
  802. let sq = (out.match(/started d1#1 \((s-\d{8}T\d{4}-[0-9a-f]{6}), ports 8780-8789\)/) || [])[1];
  803. let dq = await ticket('d1', q1.number);
  804. check('run --once --reserve 100 --week-limit 100 → starts d1#1 with ports 8780-8789, SESSION END posted with the summed cost, RUN STOPPED', sq && /^ITER 1 \S+ · quota 99% of 5 h .* · running 1\/1 · started d1#1/m.test(out) && new RegExp(`^SESSION END d1#1 ${sq} · posted · cost \\$0\\.0168 \\(worker \\$0\\.0123 \\+ controller \\$0\\.0045\\) · ports 8780-8789 freed`, 'm').test(out) && /^RUN STOPPED — --once, 1 iteration/m.test(out) && dq.ticket.state === 'awaiting creator', out);
  805. const wq = calls().find(c => c.kind === 'worker' && c.session === sq);
  806. const bq = existsSync(join(RUNS, sq || 'x', 'brief.md')) ? readFileSync(join(RUNS, sq, 'brief.md'), 'utf8') : '';
  807. check('the session got its port range: brief "Ports … 8780–8789 only", env COLONY_PORTS=8780-8789 / _FROM / _TO for worker AND controller', wq && wq.ports === '8780-8789' && wq.portFrom === '8780' && wq.portTo === '8789' && bq.includes('**Ports for everything you start: 8780–8789 only**') && calls().some(c => c.kind === 'controller' && c.session === sq && c.ports === '8780-8789'), J(wq));
  808. writeFileSync(UF, '5\n');
  809. // a crash (kill -9) while a session runs → the restarted run does NOT start a second session → the lease expires → open → a new session
  810. const t2 = await mk('d1', 'd1 crash', 'Create a file.');
  811. let r1 = startRun(['--interval', '1', '--lease-ttl', '6', '--heartbeat', '2'], denv({ FAKE_CLAUDE_SLEEP: '60' }));
  812. let m1 = await r1.waitFor(new RegExp(`started d1#${t2.number} \\((s-\\d{8}T\\d{4}-[0-9a-f]{6}), ports (\\d+-\\d+)\\)`));
  813. const sc1 = m1 && m1[1];
  814. let hbm = await r1.waitFor(new RegExp(`lease: renewed d1#${t2.number} \\(session ${sc1}\\)`), 8000);
  815. check('run: picks d1#2, lease ttl 6 s, the heartbeat renews it every 2 s (comment with a colony-lease "renewed" marker)', sc1 && hbm && (await ticket('d1', t2.number)).events.some(e => e.kind === 'comment' && new RegExp(`^Still working\\.\\n\\ncolony-lease: ${sc1} · host e2e-host · renewed · ms \\d+$`).test(e.text)), r1.text);
  816. const w1 = calls().find(c => c.kind === 'worker' && c.session === sc1);
  817. // SIMULATED CRASH: the scheduler dies without a word (SIGKILL); its orphaned fake worker is killed too (our own pid)
  818. const crashedPid = r1.schedPid;
  819. const crashed = killOurs(crashedPid, 'scheduler.hl');
  820. await r1.done(5000);
  821. await sleep(300);
  822. const orphanAlive = !!(w1 && isOurs(w1.pid, 'fake-claude.mjs'));
  823. const orphanKilled = orphanAlive && killOurs(w1.pid, 'fake-claude.mjs');
  824. console.log('crash: the fake worker pid ' + (w1 && w1.pid) + (orphanAlive ? ' outlived the scheduler (orphan) → killed by the test' : ' did NOT outlive the scheduler'));
  825. check('simulated crash: the scheduler pid got SIGKILL (gone), its fake worker is gone too (killed by the test if it outlived the scheduler)', crashed && w1 && !isOurs(crashedPid, 'scheduler.hl') && !isOurs(w1.pid, 'fake-claude.mjs'), J({ crashedPid, orphanAlive, orphanKilled, w1pid: w1 && w1.pid }));
  826. keep('crash-1', r1);
  827. const leasedUntil = Date.now();
  828. let r2 = startRun(['--interval', '1', '--lease-ttl', '6', '--heartbeat', '2'], denv());
  829. let it1 = await r2.waitFor(/^ITER 1 .*$/m);
  830. check('restart right after the crash: ITER 1 skips d1#2 ("leased by <session> on e2e-host until …"), starts nothing', it1 && new RegExp(`skipped: .*d1#${t2.number} leased by ${sc1} on e2e-host until `).test(it1[0]) && !/started d1#/.test(it1[0]) && calls().filter(c => c.kind === 'worker' && c.session !== sc1 && c.start > leasedUntil).length === 0, r2.text);
  831. let ex = await r2.waitFor(new RegExp(`expired d1#${t2.number} \\(session ${sc1} on e2e-host, lease ended [^)]+\\) → open`), 15000);
  832. let m2 = await r2.waitFor(new RegExp(`started d1#${t2.number} \\((s-\\d{8}T\\d{4}-[0-9a-f]{6}), ports`), 15000);
  833. const sc2 = m2 && m2[1];
  834. let end2 = await r2.waitFor(new RegExp(`^SESSION END d1#${t2.number} ${sc2} · posted`, 'm'), 15000);
  835. const d2e = (await ticket('d1', t2.number)).events;
  836. const iExp = d2e.findIndex(e => e.kind === 'state' && e.to === 'open' && (e.text || '').includes(`colony-lease-expired: ${sc1}`));
  837. check('after the lease expired (no heartbeat since the crash): d1#2 → open with the reason ("Lease of session … expired … crashed or its host is offline"), then ONE new session → posted', ex && sc2 && sc2 !== sc1 && end2 && iExp > 0 && d2e[iExp].text === `Back to open: the worker stopped answering (its computer crashed or went offline) — a new worker will pick it up.\n\ncolony-lease-expired: ${sc1} · host e2e-host` && d2e.slice(iExp + 1).some(e => e.kind === 'state' && e.to === 'in progress' && e.text.includes(`colony-lease: ${sc2}`)) && d2e[d2e.length - 1].to === 'awaiting creator', r2.text + J(d2e.map(e => [e.kind, e.to, (e.text || '').slice(0, 90)])));
  838. check('the in-progress ticket WITHOUT a colony lease (alpha#1, seeded by hand) is listed and never expired', /skipped: .*alpha#1 in progress without a colony lease/.test(r2.text) && (await ticket('alpha', 1)).ticket.state === 'in progress', r2.text);
  839. // SIGTERM while idle → stops at once
  840. r2.proc.kill('SIGTERM');
  841. let ex2 = await r2.done(8000);
  842. keep('crash-2-restart', r2);
  843. check('SIGTERM on an idle run → "stop requested", RUN STOPPED, the process ends', ex2 && /^RUN: stop requested — starting nothing more; 0 running/m.test(r2.text) && /^RUN STOPPED — stop requested \(finish\)/m.test(r2.text) && !isOurs(r2.schedPid, 'scheduler.hl'), r2.text);
  844. out = colony(['leases']);
  845. check('`leases`: lists alpha#1 as "no colony lease … never expires"', /^LEASE alpha#1 — no colony lease \("seed"\): never expires$/m.test(out) && /^LEASES: \d+ ticket\(s\) in progress$/m.test(out), out);
  846. // SIGTERM while a session runs → it FINISHES (not killed), nothing new starts
  847. const t3 = await mk('d1', 'd1 finish on sigterm', 'Create a file.');
  848. const t4 = await mk('d1', 'd1 parked by the 2nd sigterm', 'Create a file.');
  849. let r3 = startRun(['--interval', '1'], denv({ FAKE_CLAUDE_SLEEP: '4' }));
  850. let m3 = await r3.waitFor(new RegExp(`started d1#${t3.number} \\((s-\\d{8}T\\d{4}-[0-9a-f]{6})`));
  851. const sc3 = m3 && m3[1];
  852. r3.proc.kill('SIGTERM');
  853. let ex3 = await r3.done(20000);
  854. keep('sigterm-finish', r3);
  855. check('SIGTERM while d1#3 runs → "stop requested … 1 running session(s) finish", the worker finishes (not killed), SESSION END posted, RUN STOPPED, d1#4 never started', ex3 && sc3 && /^RUN: stop requested — starting nothing more; 1 running session/m.test(r3.text) && new RegExp(`^SESSION END d1#${t3.number} ${sc3} · posted`, 'm').test(r3.text) && /^RUN STOPPED — stop requested \(finish\)/m.test(r3.text) && calls().some(c => c.kind === 'worker-end' && c.session === sc3) && (await ticket('d1', t3.number)).ticket.state === 'awaiting creator' && (await ticket('d1', t4.number)).ticket.state === 'open' && !new RegExp(`started d1#${t4.number}`).test(r3.text), r3.text);
  856. // 2nd SIGTERM → the running session is PARKED (killed + marker), the next run RESUMES it (claude --resume)
  857. let r4 = startRun(['--interval', '1'], denv({ FAKE_CLAUDE_SLEEP: '30' }));
  858. let m4 = await r4.waitFor(new RegExp(`started d1#${t4.number} \\((s-\\d{8}T\\d{4}-[0-9a-f]{6})`));
  859. const sc4 = m4 && m4[1];
  860. await r4.waitFor(/work: worker started/);
  861. await sleep(500);
  862. const w4 = calls().find(c => c.kind === 'worker' && c.session === sc4);
  863. r4.proc.kill('SIGTERM');
  864. await sleep(700);
  865. r4.proc.kill('SIGTERM');
  866. let ex4 = await r4.done(15000);
  867. keep('sigterm-park', r4);
  868. const d4 = await ticket('d1', t4.number);
  869. const pk = d4.events.filter(e => e.kind === 'comment' && (e.text || '').includes(`colony-parked: ${sc4}`)).pop();
  870. const sj4 = existsSync(join(RUNS, sc4 || 'x', 'session.json')) ? JSON.parse(readFileSync(join(RUNS, sc4, 'session.json'), 'utf8')) : {};
  871. check('2nd SIGTERM → "parking 1", the worker process is killed, PARKED, SESSION END parked, RUN STOPPED; d1#4 stays in progress with a colony-parked marker (phase worker, the Claude session id)', ex4 && /^RUN: second stop signal — parking 1 running session/m.test(r4.text) && new RegExp(`^PARKED — d1#${t4.number} session ${sc4} \\(worker\\)`, 'm').test(r4.text) && new RegExp(`^SESSION END d1#${t4.number} ${sc4} · parked`, 'm').test(r4.text) && /^RUN STOPPED — stop requested \(park\)/m.test(r4.text) && w4 && !isOurs(w4.pid, 'fake-claude.mjs') && d4.ticket.state === 'in progress' && pk && new RegExp(`^Paused: the scheduler was stopped — work continues after \\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d UTC\\.\\n\\ncolony-parked: ${sc4} · host e2e-host · phase worker · resume-ms \\d+ · ms \\d+$`).test(pk.text) && sj4.parked && sj4.parked.phase === 'worker', r4.text + J(d4.events.slice(-2)));
  872. let r5 = startRun(['--once'], denv());
  873. let ex5 = await r5.done(20000);
  874. keep('resume-after-park', r5);
  875. const w5 = calls().filter(c => c.kind === 'worker' && c.session === sc4 && c.resume);
  876. const d5 = await ticket('d1', t4.number);
  877. check('the next run RESUMES the parked session: "resumed d1#4", `claude -p --resume <its Claude session>` (no new session id), lease comment "resumed", → controller → posted, awaiting creator', ex5 && new RegExp(`resumed d1#${t4.number} \\(${sc4}, ports 8780-8789\\)`).test(r5.text) && w5.length === 1 && w5[0].argv[0] === '-p' && w5[0].argv[1] === '--resume' && w5[0].argv[2] === sj4.claudeSession && !w5[0].argv.includes('--session-id') && d5.events.some(e => e.kind === 'comment' && e.text.startsWith(`Work continues after a pause.\n\ncolony-lease: ${sc4} · host e2e-host · resumed (park 1) · ms `)) && new RegExp(`^SESSION END d1#${t4.number} ${sc4} · posted`, 'm').test(r5.text) && d5.ticket.state === 'awaiting creator', r5.text + J(w5));
  878. check('resume files: the pre-park output kept as claude-result.parked-1.json, the resume prompt resume-1.md (ticket + session + port range)', existsSync(join(RUNS, sc4, 'claude-result.parked-1.json')) && existsSync(join(RUNS, sc4, 'resume-1.md')) && readFileSync(join(RUNS, sc4, 'resume-1.md'), 'utf8').includes(`"session": "${sc4}"`));
  879. // antcolony#28: a session that ENDS while another runs makes `run` rebuild its list of running sessions; the 2nd SIGTERM
  880. // must still park the other one (kill = parked, no WORK FAILED, marker, next run RESUMES it)
  881. const t28a = await mk('d1', 'd1 parked while d2 ended (28)', 'Create a file.');
  882. const t28b = await mk('d2', 'd2 ends early (28)', 'Create a file.');
  883. let r28 = startRun(['--interval', '1', '--parallel', '2'], denv({ FAKE_CLAUDE_SLEEP: '40', FAKE_CLAUDE_SLEEP_DIR: 'd2=2' }));
  884. let m28 = await r28.waitFor(new RegExp(`started d1#${t28a.number} \\((s-\\d{8}T\\d{4}-[0-9a-f]{6})`));
  885. const sc28 = m28 && m28[1];
  886. let e28 = await r28.waitFor(new RegExp(`^SESSION END d2#${t28b.number} \\S+ · `, 'm'), 30000);
  887. r28.proc.kill('SIGTERM');
  888. await sleep(700);
  889. r28.proc.kill('SIGTERM');
  890. let ex28 = await r28.done(15000);
  891. keep('sigterm-park-after-end', r28);
  892. const d28 = await ticket('d1', t28a.number);
  893. const pk28 = d28.events.filter(e => e.kind === 'comment' && (e.text || '').includes(`colony-parked: ${sc28}`)).pop();
  894. check('antcolony#28: another session ended first, then the 2nd SIGTERM → the worker is killed AND parked (no WORK FAILED, marker on the ticket, ticket stays in progress)', ex28 && e28 && sc28 && new RegExp(`^PARKED — d1#${t28a.number} session ${sc28} \\(worker\\)`, 'm').test(r28.text) && !/WORK FAILED/.test(r28.text) && pk28 && d28.ticket.state === 'in progress', r28.text);
  895. let r28b = startRun(['--once'], denv());
  896. await r28b.done(20000);
  897. keep('resume-after-park-after-end', r28b);
  898. check('antcolony#28: the next run RESUMES that same session', new RegExp(`resumed d1#${t28a.number} \\(${sc28}`).test(r28b.text), r28b.text);
  899. // the usage limit → PARKED until the reset → quota above the reserve holds the resume → below → resumed → posted
  900. const t5 = await mk('d1', 'd1 rate limited', 'Create a file.');
  901. let r6 = startRun(['--interval', '1'], denv({ FAKE_CLAUDE_MODE: 'ratelimit', FAKE_RATELIMIT_RESET: '3' }));
  902. let m6 = await r6.waitFor(new RegExp(`^PARKED — d1#${t5.number} session (s-\\S+) \\(worker\\) until (\\S+): Claude usage limit \\(You've hit your limit`, 'm'));
  903. writeFileSync(UF, '90\n');
  904. const sc6 = m6 && m6[1];
  905. let held = await r6.waitFor(new RegExp(`quota 90% of 5 h .* ≥ reserve 70% → no start \\(waiting: d1#${t5.number}\\)`), 12000);
  906. // mission 034: until the reset the run is PAUSED (worker parked on the limit) — ONE `LIMIT:` line instead of an ITER
  907. // line per second saying "parked until"
  908. const parkedLine = await r6.waitFor(new RegExp(`^LIMIT: usage limit reached — librarian and starts paused until \\S+ · d1#${t5.number} ${sc6} was parked on it`, 'm'), 1000);
  909. writeFileSync(UF, '10\n');
  910. let m6r = await r6.waitFor(new RegExp(`resumed d1#${t5.number} \\(${sc6}, ports`), 12000);
  911. let e6 = await r6.waitFor(new RegExp(`^SESSION END d1#${t5.number} ${sc6} · posted`, 'm'), 12000);
  912. r6.proc.kill('SIGTERM');
  913. await r6.done(10000);
  914. keep('ratelimit-park-resume', r6);
  915. const d6 = await ticket('d1', t5.number);
  916. check('usage limit (fake: rate_limit_event rejected + "You\'ve hit your limit", 429) → PARKED until the reset (not back to open), marker phase worker', m6 && d6.events.some(e => e.kind === 'comment' && e.text.startsWith('Paused: the Claude usage limit is reached — work continues after ') && e.text.includes(`colony-parked: ${sc6} · host e2e-host · phase worker`)) && !d6.events.some(e => e.kind === 'state' && e.to === 'open' && e.seq > 1), r6.text);
  917. check('parked: paused until the reset time (m034: ONE LIMIT line); after it, quota 90% ≥ 70% holds the resume; at 10% it is resumed (--resume) → posted', parkedLine && held && m6r && e6 && d6.ticket.state === 'awaiting creator' && calls().some(c => c.kind === 'worker' && c.session === sc6 && c.resume), r6.text);
  918. // --parallel 2: two sessions at once, disjoint port ranges; a third ticket in the same dev folder waits
  919. writeFileSync(UF, '5\n');
  920. const t6 = await mk('d1', 'd1 parallel', 'Create a file.');
  921. const t7 = await mk('d2', 'd2 parallel', 'Create a file.');
  922. const t8 = await mk('d1', 'd1 same folder', 'Create a file.');
  923. out = colony(['run', '--once', '--parallel', '2'], denv({ FAKE_CLAUDE_SLEEP: '3' }), 90000);
  924. writeFileSync(join(RUNLOG, 'parallel.txt'), out);
  925. const mp = out.match(new RegExp(`started d1#${t6.number} \\((s-\\S+), ports (\\d+-\\d+)\\) · started d2#${t7.number} \\((s-\\S+), ports (\\d+-\\d+)\\)`));
  926. const wa = mp && calls().find(c => c.kind === 'worker' && c.session === mp[1]);
  927. const wb = mp && calls().find(c => c.kind === 'worker' && c.session === mp[3]);
  928. const ea = mp && calls().find(c => c.kind === 'worker-end' && c.session === mp[1]);
  929. const eb = mp && calls().find(c => c.kind === 'worker-end' && c.session === mp[3]);
  930. check('run --once --parallel 2 → ONE iteration starts d1#' + t6.number + ' AND d2#' + t7.number + ' with disjoint ranges 8780-8789 / 8790-8799; d1#' + t8.number + ' waits (same dev folder)', mp && mp[2] === '8780-8789' && mp[4] === '8790-8799' && new RegExp(`d1#${t8.number} waits \\(same dev folder as d1#${t6.number}\\)`).test(out), out);
  931. check('the two workers really ran at the same time (each started before the other ended), each with its own COLONY_PORTS', wa && wb && ea && eb && wa.start < eb.end && wb.start < ea.end && wa.ports === '8780-8789' && wb.ports === '8790-8799', J([wa, wb, ea, eb]));
  932. check('both posted (awaiting creator), RUN STOPPED after both ended', /^RUN STOPPED — --once/m.test(out) && (await ticket('d1', t6.number)).ticket.state === 'awaiting creator' && (await ticket('d2', t7.number)).ticket.state === 'awaiting creator' && (await ticket('d1', t8.number)).ticket.state === 'open', out);
  933. check('the run printed ONE line per iteration (ITER …), never more', iterLines(out).length === 1, out);
  934. // test teardown: nothing of d1/d2 stays open (the REAL section picks the oldest eligible on e2e-host)
  935. await api('POST', `/api/projects/d1/tickets/${t8.number}/state`, { state: 'on hold', text: 'e2e: parked after the run tests' });
  936. check('no `colony run`, scheduler or fake claude of this section left running', daemons.every(d => d.proc.exitCode !== null || d.proc.signalCode !== null) && daemons.every(d => !d.schedPid || !isOurs(d.schedPid, 'scheduler.hl')) && fakePids().every(p => !isOurs(p, 'fake-claude.mjs')), J(daemons.map(d => [d.proc.pid, d.schedPid])));
  937. }
  938. // ---- mission 022: blocked-by, parents, questions as tickets, rejections as work — FAKE claude only ----------------
  939. {
  940. // the creator (admin of every project) logged in at the start (antcolony#31)
  941. // antcolony#31: roles decide now — Colony is `edit` (may set any state) but not an admin: it may not change members (403)
  942. const nc = await api('POST', '/api/projects/alpha/members', { user: 'Colony', role: 'admin' }).catch(e => String(e));
  943. check('m022 setup: user "Creator" (admin of every project) has a token; Colony (role edit) may NOT change members (403)', ccb.status === 302 && /^tkt_/.test(CTOKEN || '') && /→ 403/.test(nc), nc);
  944. // relations: project rel (dev folder on host loreana — never started here, only next / brief / work refusals)
  945. let out2 = '';
  946. if (REL) {
  947. meta('rel', 'loreana:/srv/rel/CONCEPT.md', []);
  948. const b1 = await mk('rel', 'rel blocked', 'waits for rel#2'); // rel#1
  949. const b2 = await mk('rel', 'rel blocker', 'must be done first'); // rel#2
  950. const p3 = await mk('rel', 'rel parent', 'split into children'); // rel#3
  951. const c4 = await mk('rel', 'rel child', 'part of rel#3'); // rel#4
  952. await api('POST', `/api/projects/rel/tickets/${b1.number}/blocked-by`, { add: 'rel#' + b2.number });
  953. await api('POST', `/api/projects/rel/tickets/${c4.number}/parent`, { parent: 'rel#' + p3.number });
  954. out = colony(['next']);
  955. check('m022 next: rel#1 → "blocked by rel#2 (open)", not eligible', new RegExp(`^ rel#${b1.number} "rel blocked" — blocked by rel#${b2.number} \\(open\\)$`, 'm').test(out) && !new RegExp(`^ rel#${b1.number} "rel blocked" — opened `, 'm').test(out), out);
  956. check('m022 next: rel#3 (has a child) → "a parent (1 child ticket, 0 confirmed) — not work itself", its child rel#4 and the blocker rel#2 are eligible', new RegExp(`^ rel#${p3.number} "rel parent" — a parent \\(1 child ticket, 0 confirmed\\) — not work itself, its children are$`, 'm').test(out) && new RegExp(`^ rel#${c4.number} "rel child" — opened `, 'm').test(out) && new RegExp(`^ rel#${b2.number} "rel blocker" — opened `, 'm').test(out), out);
  957. console.log('---- m022 next (relations) ----\n' + out.trim() + '\n-------------------------------');
  958. snap = await snapshot();
  959. out = colony(['work', 'rel', String(b1.number)], workEnv('good'));
  960. out2 = colony(['work', 'rel', String(p3.number)], workEnv('good'));
  961. check('m022 work refuses a blocked ticket and a parent — before any write', new RegExp(`^work: REFUSED — rel#${b1.number} is not eligible: blocked by rel#${b2.number} \\(open\\)$`, 'm').test(out) && new RegExp(`^work: REFUSED — rel#${p3.number} is not eligible: a parent \\(1 child`, 'm').test(out2) && await snapshot() === snap, out + out2);
  962. out = colony(['brief', 'rel', String(b1.number), '--out', 'brief-rel-1.md']);
  963. let bt = readFileSync(join(WORK, 'brief-rel-1.md'), 'utf8');
  964. check('m022 brief: a "Relations" section — parent none, children none, blocked by rel#2 [open] **blocking**, blocks none', bt.includes('## Relations (parent / children / blocked by)\n- Parent: none\n- Children: none\n- Blocked by (1 not confirmed):\n - rel#' + b2.number + ' [open] rel blocker — **blocking** — ' + BASE + '/projects/rel/' + b2.number + '\n- Blocks: none'), bt.slice(bt.indexOf('## Relations'), bt.indexOf('## Relations') + 500));
  965. out = colony(['brief', 'rel', String(c4.number), '--out', 'brief-rel-4.md']);
  966. bt = readFileSync(join(WORK, 'brief-rel-4.md'), 'utf8');
  967. out2 = colony(['brief', 'rel', String(p3.number), '--out', 'brief-rel-3.md']);
  968. const bp = readFileSync(join(WORK, 'brief-rel-3.md'), 'utf8');
  969. check('m022 brief: the child names its parent, the parent lists its child; the blocker lists "Blocks"', bt.includes('- Parent: rel#' + p3.number + ' [open] rel parent — ' + BASE + '/projects/rel/' + p3.number) && bp.includes('- Children (1):\n - rel#' + c4.number + ' [open] rel child — '), bt.slice(bt.indexOf('## Relations'), bt.indexOf('## Relations') + 300) + '\n' + bp.slice(bp.indexOf('## Relations'), bp.indexOf('## Relations') + 300));
  970. // the blocker is confirmed (by the creator) → rel#1 eligible; a blocker that is `awaiting creator` still blocks
  971. await api('POST', `/api/projects/rel/tickets/${b2.number}/state`, { state: 'awaiting creator', text: 'e2e' });
  972. out = colony(['next']);
  973. check('m022 next: a blocker in "awaiting creator" still blocks ("blocked by rel#2 (awaiting creator)")', new RegExp(`^ rel#${b1.number} "rel blocked" — blocked by rel#${b2.number} \\(awaiting creator\\)$`, 'm').test(out), out);
  974. await capi('POST', `/api/projects/rel/tickets/${b2.number}/state`, { state: 'confirmed', text: 'e2e: confirmed by the creator' });
  975. out = colony(['next']);
  976. out2 = colony(['brief', 'rel', String(b1.number), '--out', 'brief-rel-1b.md']);
  977. bt = readFileSync(join(WORK, 'brief-rel-1b.md'), 'utf8');
  978. check('m022: blocker confirmed by the creator → rel#1 eligible; its brief lists the blocker as confirmed (not blocking)', new RegExp(`^ rel#${b1.number} "rel blocked" — opened `, 'm').test(out) && bt.includes('- Blocked by (0 not confirmed):\n - rel#' + b2.number + ' [confirmed] rel blocker — ' + BASE), out + bt.slice(bt.indexOf('## Relations'), bt.indexOf('## Relations') + 300));
  979. // mission 023: a question the architect filed BY HAND (no colony source; subject starts with "Question") is a
  980. // creator question too — it does not make its ticket a parent
  981. const h5 = await mk('rel', 'rel with a hand-filed question', 'x');
  982. const h6 = await mk('rel', 'Question (rel#' + h5.number + '): which colour?', 'Filed by hand by the architect.', 'awaiting creator');
  983. const h7 = await mk('rel', 'rel with a normal child', 'x');
  984. const h8 = await mk('rel', 'Questionnaire page', 'a normal child whose subject only starts with "Question" as a word part', 'awaiting creator');
  985. await api('POST', `/api/projects/rel/tickets/${h6.number}/parent`, { parent: 'rel#' + h5.number });
  986. await api('POST', `/api/projects/rel/tickets/${h8.number}/parent`, { parent: 'rel#' + h7.number });
  987. out = colony(['next']);
  988. out2 = colony(['brief', 'rel', String(h5.number), '--out', 'brief-rel-5.md']);
  989. const bh = readFileSync(join(WORK, 'brief-rel-5.md'), 'utf8');
  990. check('m023 next: rel#' + h5.number + ' with a hand-filed child "Question (…): …" (no colony source) is NOT "a parent" but waits for the answer (antcolony#30); its brief lists the child as "creator question"', new RegExp(`^ rel#${h5.number} "rel with a hand-filed question" — waiting for the creator's answer on rel#${h6.number} \\(awaiting creator\\)`, 'm').test(out) && !new RegExp(`rel#${h5.number} .* a parent`).test(out) && bh.includes(` - rel#${h6.number} [awaiting creator] Question (rel#${h5.number}): which colour? — creator question — `), out + bh.slice(bh.indexOf('## Relations'), bh.indexOf('## Relations') + 400));
  991. // antcolony#30: the creator answers (confirms) the question → the ticket continues
  992. await capi('POST', `/api/projects/rel/tickets/${h6.number}/state`, { state: 'confirmed', text: 'blue' });
  993. out = colony(['next']);
  994. check('antcolony#30 next: question confirmed → rel#' + h5.number + ' eligible again', new RegExp(`^ rel#${h5.number} "rel with a hand-filed question" — opened `, 'm').test(out) || /^NEXT: rel#/m.test(out), out);
  995. check('m023 next: the rule is the whole word — a child "Questionnaire page" is a normal child, rel#' + h7.number + ' IS a parent', new RegExp(`^ rel#${h7.number} "rel with a normal child" — a parent \\(1 child ticket, 0 confirmed\\)`, 'm').test(out), out);
  996. // teardown of rel: nothing of it stays eligible for later sections (host loreana: never picked on e2e-host anyway)
  997. for (const n of [b1.number, p3.number, c4.number, h5.number, h7.number]) await api('POST', `/api/projects/rel/tickets/${n}/state`, { state: 'on hold', text: 'e2e: done with the relation tests' });
  998. }
  999. // questions + rejections: project rej (dev folder on e2e-host), the fake worker asks 2 questions
  1000. const REJ = join(WORK, 'rej');
  1001. mkdirSync(REJ, { recursive: true });
  1002. writeFileSync(join(REJ, 'CONCEPT.md'), '# rej\nA test project: questions + rejections.\n');
  1003. meta('rej', REJ + '/CONCEPT.md', [], { dev: { host: 'e2e-host', folder: REJ } });
  1004. const rt = await mk('rej', 'rej the feature', 'Build the feature as described: use the relations API.');
  1005. const QS = ['Should the widget be blue?', 'Which port?\nThe concept names none — I used 8711 (smallest choice).'];
  1006. rmSync(FLOG, { force: true });
  1007. out = colony(['work', 'rej', String(rt.number), '--post'], workEnv('good', { FAKE_QUESTIONS: J(QS) }));
  1008. console.log('---- m022 work (fake, 2 questions, --post) ----\n' + out.trim() + '\n-----------------------------------------------');
  1009. const sA = sessionOf(out);
  1010. let rd = await ticket('rej', rt.number);
  1011. const qrows = () => api('GET', '/api/projects/rej/tickets').then(r => r.json.tickets.filter(t => (t.source || '').startsWith('colony-report:')));
  1012. let qs = await qrows();
  1013. const q0 = qs.find(t => t.source === `colony-report:${sA}:questions:0`), q1 = qs.find(t => t.source === `colony-report:${sA}:questions:1`);
  1014. check('m022 questions: work --post (pass) → WORK DONE; each of the 2 questions is its own ticket in rej, "awaiting creator", parent rej#' + rt.number, /^WORK DONE — rej#/m.test(out) && qs.length === 2 && q0 && q1 && [q0, q1].every(q => q.state === 'awaiting creator' && (!REL || (q.parent && q.parent.key === 'rej#' + rt.number))) && (!REL || rd.ticket.children.length === 2), out + J(qs.map(q => [q.ref, q.state, q.source, q.parent && q.parent.key])));
  1015. check('m022 questions: subjects = "Question (rej#n): <first line>", summary = the whole question + where it came from', q0 && q0.subject === `Question (rej#${rt.number}): Should the widget be blue?` && q1.subject === `Question (rej#${rt.number}): Which port?` && q1.summary === QS[1] + `\n\nAsked while working on [rej #${rt.number}](${BASE}/projects/rej/${rt.number}). **Answer** with a comment here — the next worker reads it.`, J([q0 && q0.subject, q1 && q1.subject, q1 && q1.summary]));
  1016. const rc = rd.events.filter(e => e.kind === 'comment' && (e.text || '').includes('colony-report: ' + sA)).pop();
  1017. check('m022 questions: the report comment links both question tickets; the ticket itself → awaiting creator', rc && q0 && rc.text.includes(`\n- Questions for you: [rej #${q0.number}](${BASE}/projects/rej/${q0.number}), [rej #${q1.number}](${BASE}/projects/rej/${q1.number})\n`) && rd.ticket.state === 'awaiting creator', rc && rc.text);
  1018. snap = await snapshot();
  1019. out = colony(['check', join(RUNS, sA || 'none'), '--post'], workEnv('good'));
  1020. out2 = colony(['report', join(RUNS, sA || 'none', 'report.json'), '--post']);
  1021. check('m022 questions: re-posting (check --post, report --post) → "HAVE" for both questions, nothing new, whole store unchanged', (out.match(/HAVE question \d/g) || []).length === 2 && (out2.match(/HAVE question \d/g) || []).length === 2 && !/FILED/.test(out + out2) && await snapshot() === snap, out + out2);
  1022. // rejection 1: the creator comments, Colony comments (not the creator's), the creator rejects with a reason
  1023. await capi('POST', `/api/projects/rej/tickets/${rt.number}/comments`, { text: 'Creator: the widget must use the **relations API**,\nnot a text marker.' });
  1024. await api('POST', `/api/projects/rej/tickets/${rt.number}/comments`, { text: 'colony note (not the creator)' });
  1025. await capi('POST', `/api/projects/rej/tickets/${rt.number}/state`, { state: 'rejected', text: 'Not what I described: you built a workaround.' });
  1026. out = colony(['next']);
  1027. check('m022 rejections: next lists rej#' + rt.number + ' as eligible again ("REJECTED by the creator … (newer than the last colony session) → rework"); its 2 question children do NOT make it a parent', new RegExp(`rej#${rt.number} "rej the feature" — .*REJECTED by the creator \\d{4}-\\d\\d-\\d\\d \\d\\d:\\d\\d \\(newer than the last colony session\\) → rework`).test(out) && !new RegExp(`rej#${rt.number} .* a parent`).test(out), out);
  1028. console.log('---- m022 next (rejected) ----\n' + out.trim() + '\n------------------------------');
  1029. out = colony(['brief', 'rej', String(rt.number), '--out', 'brief-rej-1.md']);
  1030. let br = readFileSync(join(WORK, 'brief-rej-1.md'), 'utf8');
  1031. check('m022 brief of the rejected ticket STARTS with "REJECTED by the creator: <the rejection\'s text>", then the creator\'s comment since the last session (not Colony\'s), then the rule; the brief title after it', br.startsWith('REJECTED by the creator: Not what I described: you built a workaround.\n') && br.includes("The creator's comments since the last session:") && br.includes('> Creator: the widget must use the **relations API**,\n> not a text marker.') && !br.slice(0, br.indexOf('# Brief')).includes('colony note') && br.includes('**Build what was described, not a workaround; if impossible, stop and explain**') && br.indexOf('# Brief — rej#') > br.indexOf('stop and explain'), br.slice(0, 1200));
  1032. check(REL ? 'm022 brief: the Relations section lists the 2 question children as "creator question"' : 'm022 brief: the Relations section says this tickets serves no relations', !REL ? br.includes('## Relations (parent / children / blocked by)\n- _(this tickets server serves no relations)_') : q0 && br.includes(`- Children (2):\n - rej#${q0.number} [on hold] Question (rej#${rt.number}): Should the widget be blue? — creator question — `), br.slice(br.indexOf('## Relations'), br.indexOf('## Relations') + 600));
  1033. rmSync(FLOG, { force: true });
  1034. let evB = (await ticket('rej', rt.number)).events.length;
  1035. out = colony(['work', 'rej', String(rt.number), '--post'], workEnv('good'));
  1036. const sB = sessionOf(out);
  1037. rd = await ticket('rej', rt.number);
  1038. let evR = rd.events.slice(evB);
  1039. let fl = existsSync(FLOG) ? JSON.parse(readFileSync(FLOG, 'utf8')) : { brief: '' };
  1040. check('m022 rework: work on the REJECTED ticket → lease rejected → in progress (by Colony), the worker\'s brief starts with the rejection, pass → posted → awaiting creator', /^work: rej#\d+ was REJECTED by the creator/m.test(out) && /^WORK DONE — rej#/m.test(out) && evR[0] && evR[0].kind === 'state' && evR[0].from === 'rejected' && evR[0].to === 'in progress' && evR[0].author === 'Colony' && fl.brief.startsWith('REJECTED by the creator: Not what I described') && rd.ticket.state === 'awaiting creator', out + J(evR.map(e => [e.kind, e.from, e.to, e.author])));
  1041. out = colony(['brief', 'rej', String(rt.number), '--out', 'brief-rej-2.md']);
  1042. br = readFileSync(join(WORK, 'brief-rej-2.md'), 'utf8');
  1043. check('m022: once a colony report answered the rejection, the brief no longer starts with it', br.startsWith('# Brief — rej#'), br.slice(0, 200));
  1044. // rejection 2 → only the comments since the LAST session; a failed rework (no report) keeps the rejection in the next brief;
  1045. // the `run` loop picks the rejected ticket too
  1046. await capi('POST', `/api/projects/rej/tickets/${rt.number}/comments`, { text: 'Creator: still wrong — the parent link is missing.' });
  1047. await capi('POST', `/api/projects/rej/tickets/${rt.number}/state`, { state: 'rejected', text: 'Second rejection.' });
  1048. out = colony(['brief', 'rej', String(rt.number), '--out', 'brief-rej-3.md']);
  1049. br = readFileSync(join(WORK, 'brief-rej-3.md'), 'utf8');
  1050. const head3 = br.slice(0, br.indexOf('# Brief'));
  1051. check('m022 2nd rejection: the brief starts with it and quotes only the creator\'s comments since the last session (the new one, not the first one)', br.startsWith('REJECTED by the creator: Second rejection.\n') && head3.includes('> Creator: still wrong — the parent link is missing.') && !head3.includes('relations API'), head3);
  1052. out = colony(['work', 'rej', String(rt.number), '--post'], workEnv('nostructured'));
  1053. rd = await ticket('rej', rt.number);
  1054. out2 = colony(['brief', 'rej', String(rt.number), '--out', 'brief-rej-4.md']);
  1055. br = readFileSync(join(WORK, 'brief-rej-4.md'), 'utf8');
  1056. check('m022: a rework without a valid report → back to open; the next brief STILL starts with the unanswered rejection', /^WORK FAILED — no structured report/m.test(out) && rd.ticket.state === 'open' && br.startsWith('REJECTED by the creator: Second rejection.\n'), out + br.slice(0, 300));
  1057. await capi('POST', `/api/projects/rej/tickets/${rt.number}/state`, { state: 'rejected', text: 'Third rejection, for the run loop.' });
  1058. const FLD22 = join(WORK, 'fake-calls-m022');
  1059. out = colony(['run', '--once', '--port-pool', '8780-8799'], workEnv('good', { FAKE_LOG_DIR: FLD22, FAKE_CLAUDE_LOG: '', FAKE_CONTROLLER_LOG: '' }), 90000);
  1060. writeFileSync(join(WORK, 'run-m022.txt'), out);
  1061. rd = await ticket('rej', rt.number);
  1062. check('m022 run --once picks the rejected ticket (the only eligible one on e2e-host) → posted → awaiting creator', new RegExp(`started rej#${rt.number} \\(s-`).test(out) && new RegExp(`^SESSION END rej#${rt.number} \\S+ · posted`, 'm').test(out) && rd.ticket.state === 'awaiting creator', out);
  1063. out = colony(['next']);
  1064. check('m022 next: a rejected ticket counts as "rejected" only while no rejection is newer than the last session — none now (it is awaiting creator)', /Not considered: \d+ awaiting creator \(inbox\), \d+ confirmed, 0 rejected$/m.test(out), out);
  1065. // ---- mission 030: a QUESTION ticket is never work, in no state (the live run reworked rejected questions:
  1066. // ident#17 once, gitoria#3 twice, gitoria#4 five times). The same situation here: the creator REJECTS the question
  1067. // q0 ("already answered") — newer than any colony session on it, i.e. exactly what made pre-030 code rework it —
  1068. // plus a hand-filed question (no colony source, subject "Question: …") left OPEN, and one ON HOLD.
  1069. {
  1070. const QWHY = 'a question for the creator — the librarian takes its answer';
  1071. await capi('POST', `/api/projects/rej/tickets/${q0.number}/state`, { state: 'rejected', text: 'this is a question the original ticket description already answered' });
  1072. const qh = await mk('rej', 'Question: which colour for the hand-filed one?', 'Filed by hand; left open.');
  1073. const qo = await mk('rej', 'Question: one on hold?', 'Filed by hand; on hold.', 'on hold');
  1074. const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  1075. const FL30 = join(WORK, 'fake-calls-m030');
  1076. const noWorkCall = () => !existsSync(FL30) || !readdirSync(FL30).some(f => !/-(usage|librarian)\.json$/.test(f));
  1077. out = colony(['next']);
  1078. check('m030 next: the REJECTED question (newer than any session), an OPEN and an ON HOLD question are listed "' + QWHY + ' (<state>) — never work"; none is NEXT / eligible / "→ rework"; the rejected one is not counted as "rejected"',
  1079. new RegExp(`^ rej#${q0.number} "${esc(q0.subject)}" — ${esc(QWHY)} \\(rejected\\) — never work$`, 'm').test(out) && new RegExp(`^ rej#${qh.number} "${esc(qh.subject)}" — ${esc(QWHY)} \\(open\\) — never work$`, 'm').test(out) && new RegExp(`^ rej#${qo.number} "${esc(qo.subject)}" — ${esc(QWHY)} \\(on hold\\) — never work$`, 'm').test(out) && !/→ rework/.test(out) && !new RegExp(`^NEXT: rej#(${q0.number}|${qh.number}|${qo.number}) `, 'm').test(out) && /, 0 rejected$/m.test(out), out);
  1080. console.log('---- m030 next (questions) ----\n' + out.trim() + '\n-------------------------------');
  1081. snap = await snapshot();
  1082. rmSync(FLOG, { force: true });
  1083. const runsBefore = existsSync(RUNS) ? readdirSync(RUNS).length : 0;
  1084. const wq0 = colony(['work', 'rej', String(q0.number), '--post'], workEnv('good'));
  1085. const wqh = colony(['work', 'rej', String(qh.number), '--post'], workEnv('good'));
  1086. const wqo = colony(['work', 'rej', String(qo.number), '--post'], workEnv('good'));
  1087. check('m030 work REFUSES the rejected / open / on-hold question with the reason — nothing written (store unchanged, no run folder, no claude call)',
  1088. new RegExp(`^work: REFUSED — rej#${q0.number} "${esc(q0.subject)}" is ${esc(QWHY)} \\(state rejected\\); never work — no brief, no worker$`, 'm').test(wq0) && new RegExp(`^work: REFUSED — rej#${qh.number} .* is ${esc(QWHY)} \\(state open\\)`, 'm').test(wqh) && new RegExp(`^work: REFUSED — rej#${qo.number} .* is ${esc(QWHY)} \\(state on hold\\)`, 'm').test(wqo) && !existsSync(FLOG) && (existsSync(RUNS) ? readdirSync(RUNS).length : 0) === runsBefore && await snapshot() === snap, wq0 + wqh + wqo);
  1089. const bq = colony(['brief', 'rej', String(q0.number), '--out', 'brief-m030-q0.md']);
  1090. const bqh = colony(['brief', 'rej', String(qh.number), '--out', 'brief-m030-qh.md']);
  1091. check('m030 brief REFUSES a question ticket (rejected, open) with the reason — no file written', new RegExp(`^brief: REFUSED — rej#${q0.number} .* is ${esc(QWHY)} \\(state rejected\\)`, 'm').test(bq) && new RegExp(`^brief: REFUSED — rej#${qh.number} .* is ${esc(QWHY)} \\(state open\\)`, 'm').test(bqh) && !existsSync(join(WORK, 'brief-m030-q0.md')) && !existsSync(join(WORK, 'brief-m030-qh.md')), bq + bqh);
  1092. out = colony(['cycle', '--post'], workEnv('good'));
  1093. check('m030 cycle picks no question (nothing else eligible on e2e-host → "nothing to do"), store unchanged, no claude call', /^CYCLE — nothing to do: no eligible open ticket with its dev folder on e2e-host$/m.test(out) && !/cycle: 1 next → rej#/.test(out) && !existsSync(FLOG) && await snapshot() === snap, out);
  1094. out = colony(['run', '--once', '--port-pool', '8780-8799'], workEnv('good', { FAKE_LOG_DIR: join(WORK, 'fake-calls-m030'), FAKE_CLAUDE_LOG: '', FAKE_CONTROLLER_LOG: '' }), 90000);
  1095. check('m030 run --once starts nothing on a question ("nothing to start"), store unchanged', /^ITER 1 .*nothing to start/m.test(out) && !/started rej#/.test(out) && noWorkCall() && await snapshot() === snap, out);
  1096. // a session PARKED on a question (as a pre-030 run could leave one): `run` does not resume it
  1097. const SQ = 's-m030-parked-q';
  1098. mkdirSync(join(RUNS, SQ), { recursive: true });
  1099. writeFileSync(join(RUNS, SQ, 'session.json'), J({ ticket: 'rej#' + q1.number, project: 'rej', number: String(q1.number), session: SQ, host: 'e2e-host', folder: REJ, runDir: join(RUNS, SQ), ports: '8780-8789', model: 'sonnet', maxTurns: '40', permissionMode: 'auto', timeoutSeconds: '3600', claude: FAKE, claudeSession: '00000000-0000-4000-8000-000000000030', post: true, outcome: 'parked (worker)', parked: { phase: 'worker', reason: 'e2e', resumeMs: Date.now() - 1000, count: 1 } }));
  1100. const exp = Date.now() + 3600000;
  1101. await api('POST', `/api/projects/rej/tickets/${q1.number}/state`, { state: 'in progress', text: `A worker started on this ticket.\n\ncolony-lease: ${SQ} · host e2e-host · started · ms ${exp}` });
  1102. await api('POST', `/api/projects/rej/tickets/${q1.number}/comments`, { text: `Paused: the scheduler was stopped — work continues after now.\n\ncolony-parked: ${SQ} · host e2e-host · phase worker · resume-ms ${Date.now() - 1000} · ms ${exp}` });
  1103. const evQ1 = (await ticket('rej', q1.number)).events.length;
  1104. out = colony(['run', '--once', '--port-pool', '8780-8799'], workEnv('good', { FAKE_LOG_DIR: join(WORK, 'fake-calls-m030'), FAKE_CLAUDE_LOG: '', FAKE_CONTROLLER_LOG: '' }), 90000);
  1105. const q1d = await ticket('rej', q1.number);
  1106. check('m030 run --once: a session parked on a question is NOT resumed ("parked, but ' + QWHY + ' → not resumed"), no claude call, nothing written on it',
  1107. new RegExp(`rej#${q1.number} parked, but ${esc(QWHY)} → not resumed`).test(out) && !/resumed rej#/.test(out) && noWorkCall() && q1d.events.length === evQ1 && q1d.ticket.state === 'in progress', out);
  1108. out = colony(['next']);
  1109. check('m030 next: the question leased in progress is listed "leased … — ' + QWHY + ': never resumed"', new RegExp(`^ rej#${q1.number} .* — leased: in progress \\(a worker has it\\) — ${esc(QWHY)}: never resumed$`, 'm').test(out), out);
  1110. // salvage (it leases like work): a run of a worker on the question that ran out of steps → refused, nothing written
  1111. const SV = 's-m030-salvage-q';
  1112. mkdirSync(join(RUNS, SV), { recursive: true });
  1113. writeFileSync(join(RUNS, SV, 'session.json'), J({ ticket: 'rej#' + q0.number, project: 'rej', number: String(q0.number), session: SV, host: 'e2e-host', folder: REJ, runDir: join(RUNS, SV), ports: '8780-8789', model: 'sonnet', maxTurns: '40', permissionMode: 'auto', timeoutSeconds: '3600', claude: FAKE, claudeSession: '00000000-0000-4000-8000-000000000031', post: true, costUsd: 0, outcome: 'no valid report: no structured report (Claude: error_max_turns, error, 40 turns, exit 0)' }));
  1114. writeFileSync(join(RUNS, SV, 'claude-result.json'), J([{ type: 'system', subtype: 'init', permissionMode: 'auto', session_id: '00000000-0000-4000-8000-000000000031' }, { type: 'result', subtype: 'error_max_turns', is_error: true, num_turns: 40, duration_ms: 1, session_id: '00000000-0000-4000-8000-000000000031', total_cost_usd: 0 }]));
  1115. snap = await snapshot();
  1116. rmSync(FLOG, { force: true });
  1117. out = colony(['salvage', join(RUNS, SV), '--post'], workEnv('good'));
  1118. check('m030 salvage REFUSES a run on a question ticket — nothing written, no claude call', new RegExp(`^salvage: REFUSED — rej#${q0.number} is ${esc(QWHY)} \\(state rejected\\); never work — nothing started$`, 'm').test(out) && !existsSync(FLOG) && await snapshot() === snap && !existsSync(join(RUNS, SV, 'finalize.md')), out);
  1119. // teardown: q1 back to the inbox (the lease marker must not reach later sections), the others out of the way
  1120. await api('POST', `/api/projects/rej/tickets/${q1.number}/state`, { state: 'awaiting creator', text: 'e2e: back after the m030 park test' });
  1121. await api('POST', `/api/projects/rej/tickets/${qh.number}/state`, { state: 'on hold', text: 'e2e: done with the m030 tests' });
  1122. }
  1123. }
  1124. // ---- mission 026: finished or not (`complete`), the "Previous attempt" in the brief, cleanup of what a session left
  1125. // listening on its ports, the cheaper finalize (no --disallowedTools), salvage's lease — FAKE claude only ----
  1126. {
  1127. const sjOf = (s) => s && existsSync(join(RUNS, s, 'session.json')) ? JSON.parse(readFileSync(join(RUNS, s, 'session.json'), 'utf8')) : {};
  1128. const alive = (pid) => { try { process.kill(pid, 0); return readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1][0] !== 'Z'; } catch { return false; } };
  1129. const P0 = PB + 3; // = the first port of WPORTS
  1130. // 1. complete: false → HALF DONE: short comment, back to open (never awaiting creator); questions still filed
  1131. const th = await mk('dummy', 'dummy half done', 'Build the thing and copy it into the app.');
  1132. let evB = (await ticket('dummy', th.number)).events.length;
  1133. const OPEN26 = ['The changes are only in the work copy .scratch/dev-m026/ — not copied into the app yet.', 'README not updated.'];
  1134. rmSync(FLOG, { force: true });
  1135. out = colony(['work', 'dummy', String(th.number), '--post'], workEnv('good', { FAKE_COMPLETE: 'false', FAKE_OPEN: J(OPEN26), FAKE_QUESTIONS: J(['Copy it in now?']) }));
  1136. console.log('---- m026 work (fake, complete: false) ----\n' + out.trim() + '\n-----------------------------------');
  1137. const sh = sessionOf(out), sjh = sjOf(sh);
  1138. let dh = await ticket('dummy', th.number), evh = dh.events.slice(evB);
  1139. const hc = evh.find(e => e.kind === 'comment');
  1140. check('m026 complete:false → REPORT OK "NOT complete (half done)", controller PASS, "WORK HALF DONE — … back to open, the next worker continues"; session.json outcome "half done"', /^REPORT OK — dummy#\d+, session s-\S+: NOT complete \(half done\), /m.test(out) && /^VERDICT: PASS/m.test(out) && new RegExp(`^WORK HALF DONE — dummy#${th.number}: the report says the work is not complete → "Half done" comment, back to open, the next worker continues`, 'm').test(out) && sjh.outcome === 'half done', out);
  1141. check('m026 half done: the ticket got lease, ONE short comment "Half done: <result>; the next worker continues." + still open + the question, no Test line, machine line "… · controller pass · half done", then → open "Back to open: half done — the next worker continues." (never awaiting creator)', evh.length === 3 && evh[0].to === 'in progress' && hc && hc.text.startsWith('Half done: The fake worker wrote its file; the next worker continues.\n\n- Still open: The changes are only in the work copy .scratch/dev-m026/ — not copied into the app yet. (and 1 more)\n- A question for you: [dummy #') && !hc.text.includes('**Test:**') && new RegExp(`\\ncolony-report: ${sh} · sha256 [0-9a-f]{12} · controller pass · half done$`).test(hc.text) && readableLines(hc.text) <= 5 && evh[2].kind === 'state' && evh[2].from === 'in progress' && evh[2].to === 'open' && evh[2].text === 'Back to open: half done — the next worker continues.' && dh.ticket.state === 'open', J(evh));
  1142. out = colony(['next']);
  1143. // antcolony#30: it filed a creator question and ended half done → it WAITS (no restart every minute) until the answer
  1144. dh = await ticket('dummy', th.number);
  1145. const hq = (dh.ticket.children || [])[0];
  1146. check('antcolony#30 next: half done + open creator question → "waiting for the creator\'s answer", not eligible; work refuses it', new RegExp(`dummy#${th.number} "dummy half done" — waiting for the creator's answer on dummy#\\d+ \\((awaiting creator|on hold)\\)`).test(out), out);
  1147. check('antcolony#30 work refuses the waiting ticket before any write', /^work: REFUSED — dummy#\d+ is not eligible: waiting for the creator's answer on dummy#\d+/m.test(colony(['work', 'dummy', String(th.number), '--post'], workEnv('good'))));
  1148. // the creator answers (confirms) the question → the half-done ticket continues
  1149. await capi('POST', `/api/projects/dummy/tickets/${hq.href.split('/').pop()}/state`, { state: 'confirmed', text: 'yes, copy it in' });
  1150. out = colony(['next']);
  1151. check('m026 next: the half-done ticket is open → eligible again (listed as a candidate, not "awaiting creator")', new RegExp(`dummy#${th.number} "dummy half done"`).test(out) && !new RegExp(`dummy#${th.number} .*awaiting`).test(out), out);
  1152. // 2. the next brief: "Previous attempt" — result, what is open, the work copy, the report path
  1153. colony(['brief', 'dummy', String(th.number), '--out', 'brief-m026-prev.md'], { COLONY_RUNS_DIR: RUNS });
  1154. const bp = readFileSync(join(WORK, 'brief-m026-prev.md'), 'utf8');
  1155. const prev = bp.slice(bp.indexOf('## Previous attempt'), bp.indexOf('## Read first'));
  1156. check('m026 brief after a half-done session: "## Previous attempt — continue it, do not start over" before "Read first": the session + how it ended (HALF DONE), its result (not complete), the open items, the work copy (absolute, in the dev folder), the report path', bp.indexOf('## Previous attempt') > 0 && bp.indexOf('## Previous attempt') < bp.indexOf('## Read first') && prev.includes('- Session `' + sh + '` (started ') && prev.includes(' on e2e-host): HALF DONE — its report said the work is not complete.') && prev.includes('- Its result: The fake worker wrote its file. _(not complete)_') && prev.includes('- Still open (from its report):\n - ' + OPEN26[0] + '\n - README not updated.') && prev.includes('- Its work copy: `' + DUMMY + '/.scratch/dev-m026` — check what is there') && prev.includes('- Its report: `' + join(RUNS, sh) + '/report.json`') && prev.includes('verdict.json') && prev.trim().endsWith('say in your report what you took over.'), prev || bp.slice(0, 1500));
  1157. const tfresh = await mk('dummy', 'dummy never worked', 'x');
  1158. colony(['brief', 'dummy', String(tfresh.number), '--out', 'brief-m026-fresh.md']);
  1159. check('m026 brief of a ticket without an earlier colony session: no "Previous attempt"', !readFileSync(join(WORK, 'brief-m026-fresh.md'), 'utf8').includes('## Previous attempt'));
  1160. await api('POST', `/api/projects/dummy/tickets/${tfresh.number}/state`, { state: 'on hold', text: 'e2e: only for the brief' });
  1161. // 3. the next worker continues: its brief (stdin) has the section; complete → posted → awaiting creator
  1162. rmSync(FLOG, { force: true });
  1163. evB = (await ticket('dummy', th.number)).events.length;
  1164. out = colony(['work', 'dummy', String(th.number), '--post'], workEnv('good'));
  1165. const sc = sessionOf(out);
  1166. let fl = existsSync(FLOG) ? JSON.parse(readFileSync(FLOG, 'utf8')) : { brief: '' };
  1167. dh = await ticket('dummy', th.number);
  1168. check('m026 continuation: the next worker\'s brief (its stdin) has "Previous attempt" naming the half-done session and its work copy; complete:true → WORK DONE → awaiting creator', fl.brief.includes('## Previous attempt — continue it, do not start over\n- Session `' + sh + '`') && fl.brief.includes(DUMMY + '/.scratch/dev-m026') && new RegExp(`^WORK DONE — dummy#${th.number}`, 'm').test(out) && dh.ticket.state === 'awaiting creator', out + fl.brief.slice(fl.brief.indexOf('## Previous'), fl.brief.indexOf('## Previous') + 900));
  1169. colony(['brief', 'dummy', String(th.number), '--out', 'brief-m026-prev2.md'], { COLONY_RUNS_DIR: RUNS });
  1170. const bp2 = readFileSync(join(WORK, 'brief-m026-prev2.md'), 'utf8');
  1171. check('m026 brief: "Previous attempt" is always the LAST session (now the posted one, "2 colony sessions")', bp2.includes('- Session `' + sc + '` (') && bp2.includes('; 2 colony sessions on this ticket so far): its report was posted (ready for the creator to test).') && !bp2.includes('`' + sh + '`'), bp2.slice(bp2.indexOf('## Previous'), bp2.indexOf('## Read first')));
  1172. // 4. a rejected rework that ends HALF DONE does not answer the rejection: back to open, the next brief still starts with it
  1173. await capi('POST', `/api/projects/dummy/tickets/${th.number}/state`, { state: 'rejected', text: 'Rejected: the button is missing.' });
  1174. out = colony(['work', 'dummy', String(th.number), '--post'], workEnv('good', { FAKE_COMPLETE: 'false', FAKE_OPEN: J(['The button: only in .scratch/dev-btn.']) }));
  1175. dh = await ticket('dummy', th.number);
  1176. colony(['brief', 'dummy', String(th.number), '--out', 'brief-m026-rej.md'], { COLONY_RUNS_DIR: RUNS });
  1177. const br26 = readFileSync(join(WORK, 'brief-m026-rej.md'), 'utf8');
  1178. check('m026 rejected rework ends half done → rejected → in progress → open; the next brief STILL starts with the rejection, then "Previous attempt" (half done, .scratch/dev-btn)', /^work: dummy#\d+ was REJECTED by the creator/m.test(out) && /^WORK HALF DONE — /m.test(out) && dh.ticket.state === 'open' && br26.startsWith('REJECTED by the creator: Rejected: the button is missing.\n') && br26.includes('HALF DONE — its report said the work is not complete.') && br26.includes('- Its work copy: `' + DUMMY + '/.scratch/dev-btn` — '), out + br26.slice(0, 400));
  1179. // 5. "Previous attempt" after a session WITHOUT a usable report
  1180. const tn = await mk('dummy', 'dummy no report', 'x');
  1181. out = colony(['work', 'dummy', String(tn.number), '--post', '--finalize-max-turns', '0'], workEnv('nostructured'));
  1182. const sn = sessionOf(out);
  1183. colony(['brief', 'dummy', String(tn.number), '--out', 'brief-m026-norep.md'], { COLONY_RUNS_DIR: RUNS });
  1184. const bn = readFileSync(join(WORK, 'brief-m026-norep.md'), 'utf8');
  1185. check('m026 brief after a session without a report: "stopped without a usable report", "It wrote no usable report (…)", its run files', bn.includes('- Session `' + sn + '` (') && bn.includes('): it stopped without a usable report (out of steps / time, or its report was incomplete).') && bn.includes('- It wrote no usable report (no valid report: no structured report (Claude: error_max_turns') && bn.includes('- Its run files: `' + join(RUNS, sn) + '/`'), bn.slice(bn.indexOf('## Previous'), bn.indexOf('## Read first')));
  1186. await api('POST', `/api/projects/dummy/tickets/${tn.number}/state`, { state: 'on hold', text: 'e2e: done with it' });
  1187. // 6. CLEANUP: what a session leaves listening on its ports is stopped when it ends — never other processes.
  1188. // The e2e starts its OWN "pre-existing" server on P0+1 BEFORE the session (must survive: started before); the fake worker
  1189. // leaves: its own on P0 (stopped), one with COLONY_SESSION of another session on P0+2 (must survive), one ignoring SIGTERM
  1190. // on P0+3 (→ SIGKILL); the fake controller leaves one on P0+4 (stopped after the controller).
  1191. rmSync(SERVER_LOG, { force: true });
  1192. const pre = spawn(process.execPath, ['-e', `require('http').createServer((q,r)=>r.end('pre')).listen(${P0 + 1},'127.0.0.1');setInterval(()=>{},1000)`, 'e2e-preexisting-server'], { detached: true, stdio: 'ignore' });
  1193. pre.unref(); ownServerPids.push(pre.pid);
  1194. for (let i = 0; i < 50; i++) { try { if ((await fetch(`http://127.0.0.1:${P0 + 1}/`)).ok) break; } catch {} await sleep(100); }
  1195. const tcl = await mk('dummy', 'dummy leaves servers', 'x');
  1196. out = colony(['work', 'dummy', String(tcl.number), '--post'], workEnv('good', { FAKE_LEAVE_SERVER: 'worker,controller', FAKE_SERVER_LOG: SERVER_LOG }), 90000);
  1197. console.log('---- m026 work (fake, leaves servers) ----\n' + out.trim() + '\n-----------------------------------');
  1198. const scl = sessionOf(out), sjc = sjOf(scl);
  1199. const rows = serverRows(), byRole = (k, r) => rows.find(x => x.kind === k && x.role === r) || {};
  1200. const own = byRole('worker', 'own'), foreign = byRole('worker', 'foreign'), stub = byRole('worker', 'stubborn'), ctl = byRole('controller', 'own');
  1201. await sleep(300);
  1202. check('m026 cleanup after the WORKER: "work: CLEANUP — stopped pid <own> · port P0 · … SIGTERM", the SIGTERM-ignoring one → SIGKILL; both gone', new RegExp(`^work: CLEANUP — stopped pid ${own.pid} · port ${P0} · started \\S+ · SIGTERM · .*createServer`, 'm').test(out) && new RegExp(`^work: CLEANUP — stopped pid ${stub.pid} · port ${P0 + 3} · started \\S+ · SIGKILL · `, 'm').test(out) && own.pid && !alive(own.pid) && stub.pid && !alive(stub.pid), out + J(rows));
  1203. check('m026 cleanup NEVER touches others: the e2e\'s own server started BEFORE the session ("before the session") and the one of another colony session ("belongs to colony session s-foreign-e2e") are skipped and still alive', new RegExp(`^work: CLEANUP — skipped pid ${pre.pid} · port ${P0 + 1} · started \\S+, before the session · `, 'm').test(out) && new RegExp(`^work: CLEANUP — skipped pid ${foreign.pid} · port ${P0 + 2} · belongs to colony session s-foreign-e2e · `, 'm').test(out) && alive(pre.pid) && foreign.pid && alive(foreign.pid), out);
  1204. check('m026 cleanup after the CONTROLLER: "controller: CLEANUP — stopped pid <ctl> · port P0+4"; the post went on as usual (WORK DONE)', new RegExp(`^controller: CLEANUP — stopped pid ${ctl.pid} · port ${P0 + 4} · `, 'm').test(out) && ctl.pid && !alive(ctl.pid) && new RegExp(`^WORK DONE — dummy#${tcl.number}`, 'm').test(out) && out.indexOf('controller: CLEANUP') > out.indexOf('controller: started'), out);
  1205. check('m026 cleanup logged in session.json: cleanup[{ part worker, lines: stopped + skipped }], controller.cleanup; the sessions ran with COLONY_SESSION=<session>', Array.isArray(sjc.cleanup) && sjc.cleanup[0].part === 'worker' && sjc.cleanup[0].lines.filter(l => l.startsWith('stopped pid')).length === 2 && sjc.cleanup[0].lines.filter(l => l.startsWith('skipped pid')).length === 2 && sjc.controller && Array.isArray(sjc.controller.cleanup) && sjc.controller.cleanup.some(l => l.startsWith(`stopped pid ${ctl.pid}`)) && readFileSync(`/proc/${foreign.pid}/environ`, 'utf8').includes('COLONY_SESSION=s-foreign-e2e'), J({ cleanup: sjc.cleanup, controller: sjc.controller && sjc.controller.cleanup }));
  1206. // 6b. any outcome: a worker that ran out of steps (finalize step) and one killed by the timeout
  1207. out = colony(['work', 'dummy', String((await mk('dummy', 'dummy servers + finalize', 'x')).number), '--post'], workEnv('nostructured', { FAKE_FINALIZE_MODE: 'good', FAKE_LEAVE_SERVER: 'worker,finalize', FAKE_SERVER_LOG: SERVER_LOG }), 90000);
  1208. let rows2 = serverRows().slice(rows.length), fin = rows2.find(x => x.kind === 'finalize') || {}, own2 = rows2.find(x => x.kind === 'worker' && x.role === 'own') || {};
  1209. check('m026 cleanup, worker out of steps: after the worker (its own server stopped) AND after the finalize step ("finalize: CLEANUP — stopped pid <p> · port P0+5")', new RegExp(`^work: CLEANUP — stopped pid ${own2.pid} · port ${P0} · `, 'm').test(out) && new RegExp(`^finalize: CLEANUP — stopped pid ${fin.pid} · port ${P0 + 5} · `, 'm').test(out) && fin.pid && !alive(fin.pid) && /^WORK DONE — /m.test(out), out);
  1210. const nrows = serverRows().length;
  1211. out = colony(['work', 'dummy', String((await mk('dummy', 'dummy servers + timeout', 'x')).number), '--post', '--timeout', '3', '--finalize-max-turns', '0'], workEnv('hang', { FAKE_LEAVE_SERVER: 'worker', FAKE_SERVER_LOG: SERVER_LOG }), 90000);
  1212. const own3 = serverRows().slice(nrows).find(x => x.role === 'own') || {};
  1213. check('m026 cleanup, worker killed by the timeout: its server is stopped too, then WORK FAILED → open', /^work: TIMEOUT after 3 s/m.test(out) && new RegExp(`^work: CLEANUP — stopped pid ${own3.pid} · port ${P0} · `, 'm').test(out) && own3.pid && !alive(own3.pid) && /^WORK FAILED — timed out after 3 s/m.test(out), out);
  1214. const killed = killTestServers();
  1215. await sleep(500);
  1216. check('m026: the servers the scheduler had to leave alone (pre-existing + other session) are killed by the test itself; nothing of ours listens on ' + WPORTS + ' any more', killed >= 2 && !alive(pre.pid) && spawnSync('ss', ['-ltnpH'], { encoding: 'utf8' }).stdout.split('\n').filter(l => { const m = l.match(/:(\d+)\s/); return m && Number(m[1]) >= P0 && Number(m[1]) <= P0 + 6 && /pid=/.test(l); }).length === 0, 'killed ' + killed);
  1217. // 7. cheaper finalize, MEASURED WITH THE FAKE (its simulated prompt cache: a resume with another tool list misses):
  1218. // the finalize call of mission 025 (--disallowedTools) vs. the one now (same tools as the worker)
  1219. const CW = join(WORK, 'cache-sim'); mkdirSync(CW, { recursive: true });
  1220. const schemaTxt = readFileSync(join(CODE, 'templates', 'report.schema.json'), 'utf8');
  1221. const fakeCall = (args, stdin) => { const r = spawnSync(FAKE, args, { cwd: CW, input: stdin, encoding: 'utf8', env: { ...process.env, FAKE_CLAUDE_LOG: '', FAKE_FINALIZE_LOG: '', FAKE_FINALIZE_MODE: 'good' } }); return JSON.parse(r.stdout).find(m => m.type === 'result'); };
  1222. const sidC = '11111111-2222-4333-8444-555555555555';
  1223. const base = ['-p', '--output-format', 'json', '--json-schema', schemaTxt, '--model', 'sonnet', '--permission-mode', 'auto', '--permission-prompts', 'none', '--strict-mcp-config'];
  1224. const w0 = fakeCall([...base, '--max-turns', '7', '--session-id', sidC], '"ticket": "dummy#1",\n"session": "s-cache"\n');
  1225. const fOld = fakeCall([...base, '--resume', sidC, '--max-turns', '8', '--disallowedTools', 'Edit,Write,NotebookEdit'], 'colony-finalize: s-cache\n"ticket": "dummy#1",\n"session": "s-cache"\n');
  1226. const fNew = fakeCall([...base, '--resume', sidC, '--max-turns', '8'], 'colony-finalize: s-cache\n"ticket": "dummy#1",\n"session": "s-cache"\n');
  1227. const incOld = Math.round((fOld.total_cost_usd - w0.total_cost_usd) * 1e4) / 1e4, incNew = Math.round((fNew.total_cost_usd - fOld.total_cost_usd) * 1e4) / 1e4;
  1228. console.log(`m026 finalize cost (fake, simulated cache): with --disallowedTools $${incOld} (cache read ${fOld.usage.cache_read_input_tokens}, write ${fOld.usage.cache_creation_input_tokens}); without $${incNew} (cache read ${fNew.usage.cache_read_input_tokens}, write ${fNew.usage.cache_creation_input_tokens})`);
  1229. check('m026 cheaper finalize (fake, simulated prompt cache): the old finalize (--disallowedTools) re-writes the prefix (cache read 0, write 20000, $0.1107); the new one (same tools) reads it (cache read 20000, write 44, $0.0123) — 9× cheaper in the model', fOld.usage.cache_read_input_tokens === 0 && fOld.usage.cache_creation_input_tokens === 20000 && incOld === 0.1107 && fNew.usage.cache_read_input_tokens === 20000 && fNew.usage.cache_creation_input_tokens === 44 && incNew === 0.0123, J({ fOld: fOld.usage, fNew: fNew.usage, incOld, incNew }));
  1230. }
  1231. // ---- mission 027: a report the validation refuses gets ONE fix step (the same Claude session, like the finalize step)
  1232. // instead of losing the whole session; `decided` → one "Decided: …" bullet — FAKE claude only ----
  1233. {
  1234. const sjOf = (s) => s && existsSync(join(RUNS, s, 'session.json')) ? JSON.parse(readFileSync(join(RUNS, s, 'session.json'), 'utf8')) : {};
  1235. const XLOG = join(WORK, 'fake-fix.json');
  1236. const tl = await mk('dummy', 'dummy long result', 'Create a file.');
  1237. let evB = (await ticket('dummy', tl.number)).events.length;
  1238. rmSync(XLOG, { force: true }); rmSync(FLOG, { force: true });
  1239. out = colony(['work', 'dummy', String(tl.number), '--post'], workEnv('longresult', { FAKE_FINALIZE_MODE: 'good', FAKE_FINALIZE_LOG: XLOG, FAKE_DECIDED: J(['No keyboard shortcut — the button is enough.']) }));
  1240. console.log('---- m027 work (fake: report refused → fix step) ----\n' + out.trim() + '\n-----------------------------------');
  1241. const sl = sessionOf(out), sjl = sjOf(sl), xl = existsSync(XLOG) ? JSON.parse(readFileSync(XLOG, 'utf8')) : { argv: [], brief: '' };
  1242. const wl = existsSync(FLOG) ? JSON.parse(readFileSync(FLOG, 'utf8')) : { argv: [] };
  1243. const xo = (l, n) => { const i = l.argv.indexOf(n); return i >= 0 ? l.argv[i + 1] : null; };
  1244. const dl = await ticket('dummy', tl.number), evl = dl.events.slice(evB);
  1245. check('m027 fix step: the worker\'s report is refused (result 124 > 120, a test step on .scratch) → "work: FINALIZE — the worker had its report refused … once to FIX it" → the fixed report → controller PASS → WORK DONE → awaiting creator', /REPORT REFUSED — 2 problems:\n result: is 124 characters long, at most 120 .*\n test\[0\]: names a work copy \(\.scratch\)/.test(out) && /^work: FINALIZE — the worker had its report refused \(the report was refused by the validation \(2 problems\)\); resuming claude session [0-9a-f-]{36} once to FIX it: --max-turns 8/m.test(out) && /^VERDICT: PASS/m.test(out) && new RegExp(`^WORK DONE — dummy#${tl.number} report posted`, 'm').test(out) && dl.ticket.state === 'awaiting creator' && evl.length === 3, out + J(evl));
  1246. check('m027 fix step call: `--resume <the worker\'s claude session>`, stdin = "# FIX YOUR REPORT — the scheduler refused it" + the problems + "do not continue" + ticket/session + the colony-finalize marker; session.json finalize.why "had its report refused", outcome "valid report"', xo(xl, '--resume') === xo(wl, '--session-id') && (xl.brief || '').startsWith('# FIX YOUR REPORT — the scheduler refused it\n\nYour report was refused by the validation (2 problems):\n- result: is 124 characters long') && xl.brief.includes('**Do not continue the task.**') && xl.brief.includes(`"session": "${sl}",`) && xl.brief.endsWith(`colony-finalize: ${sl}`) && sjl.finalize && sjl.finalize.why === 'had its report refused' && sjl.finalize.outcome === 'valid report' && sjl.outcome === 'posted', J({ argv: xl.argv.slice(0, 3), brief: (xl.brief || '').slice(0, 300), finalize: sjl.finalize }));
  1247. const lc = evl[1] ? evl[1].text : '';
  1248. check('m027 the comment of the fixed report: result, numbered Test steps, the "Decided: …" bullet, the machine line', lc.startsWith('The fake worker wrote its file; its report came from the finalize step.\n\n**Test:**\n1. ') && lc.includes('\n- Decided: No keyboard shortcut — the button is enough.\n') && /\ncolony-report: s-\S+ · sha256 [0-9a-f]{12} · controller pass$/.test(lc), lc);
  1249. // the fixed report is refused again → back to open ONCE, "its report was incomplete"
  1250. const tl2 = await mk('dummy', 'dummy long result twice', 'x');
  1251. evB = (await ticket('dummy', tl2.number)).events.length;
  1252. out = colony(['work', 'dummy', String(tl2.number), '--post'], workEnv('longresult', { FAKE_FINALIZE_MODE: 'longresult' }));
  1253. const dl2 = await ticket('dummy', tl2.number), evl2 = dl2.events.slice(evB);
  1254. check('m027 fix step refused too → WORK FAILED (the first refusal) → back to open ONCE with "its report was incomplete", no controller', (out.match(/REPORT REFUSED — 2 problems/g) || []).length === 2 && new RegExp(`^WORK FAILED — the report was refused by the validation \\(2 problems\\) → dummy#${tl2.number} back to open`, 'm').test(out) && !/controller: started/.test(out) && evl2.length === 2 && evl2[1].to === 'open' && evl2[1].text.startsWith('Back to open: the worker stopped without a usable result (its report was incomplete)') && dl2.ticket.state === 'open', out + J(evl2));
  1255. await api('POST', `/api/projects/dummy/tickets/${tl2.number}/state`, { state: 'on hold', text: 'e2e: done with it' });
  1256. // `--finalize-max-turns 0` = no fix step either (the old behaviour)
  1257. const tl3 = await mk('dummy', 'dummy long result, no fix', 'x');
  1258. out = colony(['work', 'dummy', String(tl3.number), '--post', '--finalize-max-turns', '0'], workEnv('longresult', { FAKE_FINALIZE_MODE: 'good' }));
  1259. check('m027 --finalize-max-turns 0 → no fix step: refused → WORK FAILED → open', !/FINALIZE/.test(out) && /^WORK FAILED — the report was refused by the validation/m.test(out) && (await ticket('dummy', tl3.number)).ticket.state === 'open', out);
  1260. await api('POST', `/api/projects/dummy/tickets/${tl3.number}/state`, { state: 'on hold', text: 'e2e: done with it' });
  1261. // the deploy gate through the WHOLE `work` process (worker → controller → post by the verdict): project lv has a live
  1262. // site; a lease ttl of 2 s would long be dead — the ready ticket must never expire; `deployed` finishes it
  1263. const LV = join(WORK, 'lv');
  1264. mkdirSync(LV, { recursive: true });
  1265. writeFileSync(join(LV, 'CONCEPT.md'), '# lv\nA test project with a live site.\n');
  1266. meta('lv', LV + '/CONCEPT.md', [], { dev: { host: 'e2e-host', folder: LV }, live: { host: 'byrodin', folder: '/CONTAINERS/projects/lv', url: 'https://lv.example' }, deploy: './deploy.sh (architect)' });
  1267. const tv = await mk('lv', 'lv feature', 'Create a file.');
  1268. out = colony(['work', 'lv', String(tv.number), '--post', '--lease-ttl', '2', '--heartbeat', '1'], workEnv('good'));
  1269. const sv27 = sessionOf(out);
  1270. let dv = await ticket('lv', tv.number);
  1271. const rc27 = dv.events.filter(e => e.kind === 'comment' && (e.text || '').includes('colony-report: ' + sv27)).pop();
  1272. check('m027 work on a LIVE project → controller PASS → "WORK DONE — lv#1 built, waiting for the deploy …", the ONE-line comment (· controller pass · ready to deploy), ticket stays in progress', new RegExp(`^WORK DONE — lv#${tv.number} built, waiting for the deploy \\(controller: pass\\): .*after the deploy: \\./colony deployed lv ${tv.number} · files`, 'm').test(out) && rc27 && new RegExp(`^Built — goes live with the next deploy\\.\\n\\ncolony-report: ${sv27} · sha256 [0-9a-f]{12} · controller pass · ready to deploy$`).test(rc27.text) && dv.ticket.state === 'in progress' && !dv.events.some(e => e.kind === 'state' && e.to === 'awaiting creator'), out + J(dv.events.map(e => [e.kind, e.to, (e.text || '').slice(0, 80)])));
  1273. await sleep(2500);
  1274. out = colony(['leases', '--expire']);
  1275. const out2v = colony(['run', '--once', '--port-pool', '8780-8799'], workEnv('good', { FAKE_CLAUDE_LOG: '', FAKE_CONTROLLER_LOG: '' }), 90000);
  1276. dv = await ticket('lv', tv.number);
  1277. check('m027 its lease (ttl 2 s) is long past: `leases --expire` says "built, waiting for the deploy … never expires", `run --once` notes it and starts nothing on it; still in progress', new RegExp(`^LEASE lv#${tv.number} — built, waiting for the deploy \\(session ${sv27}\\): never expires`, 'm').test(out) && new RegExp(`lv#${tv.number} built, waiting for the deploy`).test(out2v) && !new RegExp(`started lv#${tv.number}`).test(out2v) && !/expired lv#/.test(out2v) && dv.ticket.state === 'in progress', out + out2v);
  1278. out = colony(['deployed', 'lv', String(tv.number)], { COLONY_RUNS_DIR: RUNS });
  1279. dv = await ticket('lv', tv.number);
  1280. const dc27 = dv.events.filter(e => e.kind === 'comment').pop();
  1281. check('m027 deployed lv → "Now live on lv.example — <result>" + numbered steps, → awaiting creator', new RegExp(`^DEPLOYED — lv#${tv.number} \\(session ${sv27}, live on lv\\.example\\)$`, 'm').test(out) && dc27 && dc27.text.startsWith('Now live on lv.example — The fake worker wrote its file.\n\n**Test:**\n1. open the dev folder') && dv.ticket.state === 'awaiting creator', out + (dc27 && dc27.text));
  1282. }
  1283. // ---- mission 031 (antcolony#1): lease expiry must never touch a finished ticket. Live 2026-09-24 21:05Z: ident#19 was
  1284. // deployed (`./colony deployed` → comment `colony-deployed: S`, 146 ms later → awaiting creator) while the daemon's
  1285. // iteration ran; the iteration acted on what it had read (in progress, S's lease dead since 20:52) → "Back to open: the
  1286. // worker stopped answering" → a second worker ran on the finished ticket. Here: (A) the gap between deployed's comment
  1287. // and its state change, frozen; (B–E) the race itself, made deterministic by COLONY_TEST_HOOK_URL: the daemon GETs our
  1288. // hook right after its survey and before it acts, and the hook changes the ticket there. FAKE claude only.
  1289. {
  1290. const REG31 = join(WORK, 'projects-m031'); // own registry: only `lr` (a live site) — nothing else can start
  1291. const LR = join(WORK, 'lr');
  1292. mkdirSync(REG31, { recursive: true }); mkdirSync(LR, { recursive: true });
  1293. writeFileSync(join(LR, 'CONCEPT.md'), '# lr\nA test project of the lease race (live site).\n');
  1294. writeFileSync(join(REG31, 'lr.json'), J({ name: 'lr', dev: { host: 'e2e-host', folder: LR }, concept: LR + '/CONCEPT.md', dependsOn: [], live: { host: 'byrodin', folder: '/CONTAINERS/projects/lr', url: 'https://lr.example' }, deploy: './deploy.sh (architect)' }, null, 1));
  1295. const FL31 = join(WORK, 'fake-calls-m031');
  1296. const renv = (extra = {}) => workEnv('good', { COLONY_PROJECTS_DIR: REG31, COLONY_LIBRARIAN: 'off', COLONY_PORT_POOL: '8780-8799', FAKE_LOG_DIR: FL31, FAKE_CLAUDE_LOG: '', FAKE_CONTROLLER_LOG: '', ...extra });
  1297. const noWorker = () => !existsSync(FL31) || !readdirSync(FL31).some(f => !/-usage\.json$/.test(f));
  1298. const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  1299. const stateP = (n) => `/api/projects/lr/tickets/${n}/state`;
  1300. const RUNLOG31 = join(WORK, 'run-logs-m031');
  1301. mkdirSync(RUNLOG31, { recursive: true });
  1302. // `./colony …` WITHOUT blocking node (the hook below must be answered while the scheduler waits for it)
  1303. const colonyAsync = (args, extra, ms = 90000) => new Promise(res => {
  1304. const p = spawn(join(CODE, 'colony'), args, { cwd: WORK, env: colonyEnv(extra), stdio: ['ignore', 'pipe', 'pipe'] });
  1305. const d = { proc: p, schedPid: null };
  1306. daemons.push(d);
  1307. let o = '';
  1308. const add = (b) => { o += b; const m = o.match(/scheduler pid (\d+)/); if (m) d.schedPid = Number(m[1]); };
  1309. p.stdout.on('data', add); p.stderr.on('data', add);
  1310. const t = setTimeout(() => { try { p.kill('SIGKILL'); } catch {} killOurs(d.schedPid, 'scheduler.hl'); }, ms);
  1311. p.on('exit', () => { clearTimeout(t); res(o); });
  1312. });
  1313. // (A) `deployed` wrote its comment, its state change has not come yet (146 ms live): the lease of the building session
  1314. // is long dead (ttl 2 s), the ready marker is void — the lease has ENDED: never expired by `leases --expire` or `run`
  1315. const ta = await mk('lr', 'lr deployed while the daemon looks', 'Create a file.');
  1316. out = colony(['work', 'lr', String(ta.number), '--post', '--lease-ttl', '2', '--heartbeat', '1'], renv());
  1317. const sa = sessionOf(out);
  1318. check('m031 setup: work on lr (live site) → built, waiting for the deploy (lease ttl 2 s)', sa && new RegExp(`^WORK DONE — lr#${ta.number} built, waiting for the deploy`, 'm').test(out) && (await ticket('lr', ta.number)).ticket.state === 'in progress', out);
  1319. await sleep(2500);
  1320. await api('POST', `/api/projects/lr/tickets/${ta.number}/comments`, { text: `Now live on lr.example — The fake worker wrote its file.\n\ncolony-deployed: ${sa} · sha256 000000000000` });
  1321. const evA = (await ticket('lr', ta.number)).events.length;
  1322. out = colony(['leases', '--expire'], renv());
  1323. let da = await ticket('lr', ta.number);
  1324. check('m031 (A) deployed comment written, state change not yet: `leases --expire` → "the lease of S has ended (deployed …): never expires", nothing written', new RegExp(`^LEASE lr#${ta.number} — the lease of session ${sa} has ended \\(deployed \\(colony-deployed: ${sa}\\)\\): never expires$`, 'm').test(out) && da.events.length === evA && da.ticket.state === 'in progress', out + J(da.events.slice(evA)));
  1325. rmSync(FL31, { recursive: true, force: true });
  1326. out = await colonyAsync(['run', '--once'], renv());
  1327. writeFileSync(join(RUNLOG31, 'A-run.txt'), out);
  1328. da = await ticket('lr', ta.number);
  1329. check('m031 (A) `run --once`: "lr#n in progress, but the lease of S has ended (deployed …) → never expired", not expired, nothing started, nothing written', new RegExp(`lr#${ta.number} in progress, but the lease of ${sa} has ended \\(deployed \\(colony-deployed: ${sa}\\)\\) → never expired`).test(out) && !/expired lr#/.test(out.replace(/never expired/g, '')) && !/started lr#/.test(out) && noWorker() && da.events.length === evA && da.ticket.state === 'in progress', out);
  1330. out = colony(['deployed', 'lr', String(ta.number)], renv());
  1331. da = await ticket('lr', ta.number);
  1332. check('m031 (A) `deployed` then completes it: its comment is there (HAVE), → awaiting creator', /^ HAVE the deployed comment of session /m.test(out) && /^ STATE lr#\d+: in progress → awaiting creator$/m.test(out) && da.ticket.state === 'awaiting creator' && da.events.length === evA + 1, out);
  1333. // (B–E) the race: the survey sees a dead / parked lease; right after it (hook) someone changes the ticket; then the step
  1334. const HOOK_PORT = PB + 8;
  1335. const hookHits = [];
  1336. let hookAct = null;
  1337. const hookSrv = createServer(async (req, res) => {
  1338. const u = new URL(req.url, 'http://hook');
  1339. const hit = Object.fromEntries(u.searchParams);
  1340. hookHits.push(hit);
  1341. try { if (hookAct) await hookAct(hit); } catch (e) { hit.error = String(e); }
  1342. res.end('ok');
  1343. });
  1344. await new Promise((r, j) => { hookSrv.once('error', j); hookSrv.listen(HOOK_PORT, '127.0.0.1', r); });
  1345. const HOOK = `http://127.0.0.1:${HOOK_PORT}/hook`;
  1346. const past = Date.now() - 60000, future = Date.now() + 3600000;
  1347. const lease = (s, ms) => `A worker started on this ticket.\n\ncolony-lease: ${s} · host e2e-host · started · ms ${ms}`;
  1348. const tb = await mk('lr', 'lr moved on during the iteration', 'x'); // (B) → awaiting creator (the live case)
  1349. const tc = await mk('lr', 'lr renewed during the iteration', 'x'); // (C) the heartbeat renews it
  1350. const td = await mk('lr', 'lr nobody touches it', 'x'); // (D) control: really dead → still expired
  1351. const te = await mk('lr', 'lr parked, moved on before the resume', 'x'); // (E) a parked session due for its resume
  1352. const SB = 's-m031-race-b', SC = 's-m031-race-c', SD = 's-m031-race-d', SE = 's-m031-race-e';
  1353. await api('POST', stateP(tb.number), { state: 'in progress', text: lease(SB, past) });
  1354. await api('POST', stateP(tc.number), { state: 'in progress', text: lease(SC, past) });
  1355. await api('POST', stateP(td.number), { state: 'in progress', text: lease(SD, past) });
  1356. await api('POST', stateP(te.number), { state: 'in progress', text: lease(SE, future) });
  1357. await api('POST', `/api/projects/lr/tickets/${te.number}/comments`, { text: `Paused: e2e — continues after now.\n\ncolony-parked: ${SE} · host e2e-host · phase worker · resume-ms ${Date.now() - 1000} · ms ${future}` });
  1358. mkdirSync(join(RUNS, SE), { recursive: true });
  1359. writeFileSync(join(RUNS, SE, 'session.json'), J({ ticket: 'lr#' + te.number, project: 'lr', number: String(te.number), session: SE, host: 'e2e-host', folder: LR, runDir: join(RUNS, SE), ports: '8780-8789', model: 'sonnet', maxTurns: '40', permissionMode: 'auto', timeoutSeconds: '3600', claude: FAKE, claudeSession: '00000000-0000-4000-8000-000000000031', post: true, outcome: 'parked (worker)', parked: { phase: 'worker', reason: 'e2e', resumeMs: Date.now() - 1000, count: 1 } }));
  1360. const len = async (n) => (await ticket('lr', n)).events.length;
  1361. const ev0 = { b: await len(tb.number), c: await len(tc.number), d: await len(td.number), e: await len(te.number) };
  1362. hookAct = async (hit) => {
  1363. if (hit.number === String(tb.number)) await api('POST', stateP(tb.number), { state: 'awaiting creator', text: 'Live since now — ready for you to test.' });
  1364. if (hit.number === String(tc.number)) await api('POST', `/api/projects/lr/tickets/${tc.number}/comments`, { text: `Still working.\n\ncolony-lease: ${SC} · host e2e-host · renewed · ms ${future}` });
  1365. if (hit.number === String(te.number)) await api('POST', stateP(te.number), { state: 'awaiting creator', text: 'Done by hand meanwhile.' });
  1366. };
  1367. rmSync(FL31, { recursive: true, force: true });
  1368. out = await colonyAsync(['run', '--once'], renv({ COLONY_TEST_HOOK_URL: HOOK }));
  1369. writeFileSync(join(RUNLOG31, 'BCDE-run.txt'), out);
  1370. hookAct = null;
  1371. const db = await ticket('lr', tb.number), dcc = await ticket('lr', tc.number), dd = await ticket('lr', td.number), de = await ticket('lr', te.number);
  1372. const hit = (n, a) => hookHits.some(h => h.number === String(n) && h.action === a && h.project === 'lr');
  1373. check('m031 the hook was called right before each step: expire for B, C, D (with their sessions), resume for E', hit(tb.number, 'expire') && hit(tc.number, 'expire') && hit(td.number, 'expire') && hit(te.number, 'resume') && hookHits.some(h => h.session === SB) && hookHits.some(h => h.session === SE), J(hookHits));
  1374. check('m031 (B) THE LIVE RACE: surveyed "in progress, lease dead", then moved to awaiting creator → "NOT expired … re-read right before — it is "awaiting creator" now → nothing written"; the ticket stays in the inbox, its only new event is that move',
  1375. new RegExp(`NOT expired lr#${tb.number} \\(session ${SB}, lease ended [^)]+\\): re-read right before — it is "awaiting creator" now → nothing written`).test(out) && !new RegExp(`(^|· )expired lr#${tb.number} `, 'm').test(out) && db.ticket.state === 'awaiting creator' && db.events.length === ev0.b + 1 && db.events[db.events.length - 1].to === 'awaiting creator' && !db.events.some(e => (e.text || '').includes('colony-lease-expired')), out + J(db.events.slice(ev0.b)));
  1376. check('m031 (C) renewed by its heartbeat after the survey → "NOT expired … the lease of S changed (now until …)", still in progress, nothing written by the run',
  1377. new RegExp(`NOT expired lr#${tc.number} \\(session ${SC}, [^)]+\\): re-read right before — the lease of ${SC} changed \\(now until [0-9T:-]+Z\\) → nothing written`).test(out) && dcc.ticket.state === 'in progress' && dcc.events.length === ev0.c + 1, out + J(dcc.events.slice(ev0.c)));
  1378. check('m031 (D) control — nothing changed after the survey → still expired as before ("expired lr#n … → open", colony-lease-expired marker)',
  1379. new RegExp(`expired lr#${td.number} \\(session ${SD} on e2e-host, lease ended [^)]+\\) → open`).test(out) && dd.ticket.state === 'open' && dd.events.length === ev0.d + 1 && dd.events[dd.events.length - 1].text === `Back to open: the worker stopped answering (its computer crashed or went offline) — a new worker will pick it up.\n\ncolony-lease-expired: ${SD} · host e2e-host`, out + J(dd.events.slice(ev0.d)));
  1380. check('m031 (E) a parked session due for its resume, moved to awaiting creator after the survey → "NOT resumed … it is "awaiting creator" now → nothing written", no claude call, no lease comment',
  1381. new RegExp(`NOT resumed lr#${te.number} \\(${SE}\\): re-read right before — it is "awaiting creator" now → nothing written`).test(out) && !new RegExp(`(^|· )resumed lr#${te.number} `, 'm').test(out) && noWorker() && de.ticket.state === 'awaiting creator' && de.events.length === ev0.e + 1, out + J(de.events.slice(ev0.e)));
  1382. // `leases --expire` goes through the same re-read: C again, renewed while `leases` looks (another renewal)
  1383. await api('POST', `/api/projects/lr/tickets/${tc.number}/comments`, { text: `Still working.\n\ncolony-lease: ${SC} · host e2e-host · renewed · ms ${Date.now() - 1000}` });
  1384. const evC2 = await len(tc.number);
  1385. hookAct = async (h) => { if (h.number === String(tc.number)) await api('POST', stateP(tc.number), { state: 'on hold', text: 'e2e: held while leases looked' }); };
  1386. out = await colonyAsync(['leases', '--expire'], renv({ COLONY_TEST_HOOK_URL: HOOK }));
  1387. hookAct = null;
  1388. const dc2 = await ticket('lr', tc.number);
  1389. check('m031 `leases --expire`: dead when listed, put on hold right after → "EXPIRED at … → NOT expired, re-read right before: it is "on hold" now (nothing written)"',
  1390. new RegExp(`^LEASE lr#${tc.number} — session ${SC} on e2e-host: EXPIRED at \\S+ → NOT expired, re-read right before: it is "on hold" now \\(nothing written\\)$`, 'm').test(out) && dc2.ticket.state === 'on hold' && dc2.events.length === evC2 + 1, out + J(dc2.events.slice(evC2)));
  1391. await new Promise(r => hookSrv.close(r));
  1392. // teardown: nothing of lr stays open / in progress for later sections
  1393. for (const n of [td.number]) await api('POST', stateP(n), { state: 'on hold', text: 'e2e: done with the m031 tests' });
  1394. }
  1395. // ---- mission 029 (antcolony#21): the LIBRARIAN — the creator's decisions (tickets + the architect's chat seed) →
  1396. // DECISIONS.md per project (by topic, current only; superseded → DECISIONS-archive.md), decisions-global.md for all;
  1397. // every brief carries them; the librarian runs FIRST (run: every iteration; brief/work/cycle by hand).
  1398. // FAKE librarian model only (fake-claude.mjs answers by markers in the creator's text).
  1399. {
  1400. const LD = join(WORK, 'lib029'); // the librarian's own folder (state, entries, calls)
  1401. const LT = join(WORK, 'lib029-templates');
  1402. const LG = join(LT, 'decisions-global.md');
  1403. const LDEV = join(WORK, 'libdev'); // project `lib`: dev folder on e2e-host, workers false, ui true
  1404. // mission 030: lib has `"workers": false` → its DECISIONS files live in the librarian folder, never in its dev folder
  1405. const PF = join(LD, 'projects', 'lib', 'DECISIONS.md'), PA = join(LD, 'projects', 'lib', 'DECISIONS-archive.md');
  1406. const LFL = join(WORK, 'lib029-fake.jsonl');
  1407. const LRUNS = join(WORK, 'lib029-runs');
  1408. mkdirSync(LDEV, { recursive: true });
  1409. writeFileSync(join(LDEV, 'CONCEPT.md'), '# lib\nA test project of the librarian.\n');
  1410. meta('lib', LDEV + '/CONCEPT.md', [], { dev: { host: 'e2e-host', folder: LDEV }, workers: false, ui: true });
  1411. const lenv = (extra = {}) => ({ COLONY_LIBRARIAN: 'on', COLONY_HOST: 'e2e-host', COLONY_CREATOR_NAMES: 'Creator', COLONY_LIBRARIAN_DIR: LD, COLONY_DECISIONS_GLOBAL: LG, COLONY_CLAUDE: FAKE, FAKE_LIBRARIAN_LOG: LFL, COLONY_RUNS_DIR: LRUNS, ...extra });
  1412. const calls = () => existsSync(LFL) ? readFileSync(LFL, 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l)) : [];
  1413. const nCalls = () => calls().length;
  1414. const rd = (f) => existsSync(f) ? readFileSync(f, 'utf8') : '';
  1415. const store = () => existsSync(join(LD, 'decisions.json')) ? JSON.parse(readFileSync(join(LD, 'decisions.json'), 'utf8')).entries : [];
  1416. const T0 = Date.now();
  1417. await sleep(30);
  1418. const comment = (n, text) => capi('POST', `/api/projects/lib/tickets/${n}/comments`, { text });
  1419. // the creator's texts (capi = the creator's token) + one by Colony (not the creator: never read)
  1420. const l1 = await mk('lib', 'lib page style', 'How the lib page looks.');
  1421. const C1 = 'we use tabs everywhere #topic:style';
  1422. await comment(l1.number, C1);
  1423. await sleep(15);
  1424. await comment(l1.number, 'all apps keep uuid keys #global #topic:data');
  1425. await sleep(15);
  1426. await comment(l1.number, 'test');
  1427. await api('POST', `/api/projects/lib/tickets/${l1.number}/comments`, { text: 'Colony says: use spaces #topic:style' });
  1428. await sleep(15);
  1429. await comment(l1.number, 'the header is green and the footer is grey #topic:style #excerpt:"the header is green"');
  1430. await sleep(15);
  1431. const BADGE = 'badge colour is teal #topic:style #paraphrase';
  1432. await comment(l1.number, BADGE);
  1433. const q29 = await mk('lib', 'Question: dark mode too?', 'Should the lib page have a dark mode? A) yes B) no');
  1434. await api('POST', `/api/projects/lib/tickets/${q29.number}/state`, { state: 'awaiting creator', text: 'Waiting for your answer.' });
  1435. await capi('POST', `/api/projects/lib/tickets/${q29.number}/state`, { state: 'confirmed', text: 'yes' });
  1436. await sleep(15);
  1437. const own = (await capi('POST', '/api/projects/lib/tickets', { subject: 'Search box', summary: 'the lib page needs a search box #topic:search' })).json.ticket;
  1438. await sleep(15);
  1439. const ML = 'two rules for the list:\n- newest first\n- no paging #topic:list';
  1440. await comment(l1.number, ML);
  1441. const since = String(T0);
  1442. // A: dry run — lists, no model call, nothing written
  1443. out = colony(['librarian', '--since', since], lenv());
  1444. check('m029 librarian (dry run): the 8 new creator texts listed, Colony\'s comment not among them; no model call, nothing written',
  1445. /^librarian: 8 new creator text\(s\) \(creator = Creator\)/m.test(out) && /LIBRARIAN DRY RUN — 8 new creator text\(s\); no model call, nothing written/.test(out) && !/Colony says/.test(out) && nCalls() === 0 && !existsSync(PF) && !existsSync(join(LD, 'state.json')), out);
  1446. out = colony(['librarian', '--post-files', '--seed', join(WORK, 'nope.md')], lenv());
  1447. check('m029 librarian --seed of a missing file → says so, writes nothing', /librarian: no such seed file: /.test(out) && !existsSync(join(LD, 'decisions.json')), out);
  1448. // B: the first pass — one model call per text, the files written
  1449. out = colony(['librarian', '--post-files', '--since', since], lenv());
  1450. console.log('---- librarian --post-files (fake) ----\n' + out.trim() + '\n---------------------------------------');
  1451. const c1 = calls();
  1452. const pf = rd(PF), lg = rd(LG);
  1453. check('m029 first pass: 8 model calls (one per text), 7 decisions, 1 not a decision, cost line', c1.length === 8 && /^LIBRARIAN: 8 of 8 new text\(s\) classified · 7 decision\(s\), 0 superseded, 1 not a decision, 0 refused, 0 without an answer/m.test(out) && /^LIBRARIAN COST: \$0\.0248 for 8 model call\(s\) \(sonnet\)$/m.test(out), out);
  1454. const a0 = c1[0] ? c1[0].argv : [];
  1455. const iTools = a0.indexOf('--tools');
  1456. check('m029 the model call: --json-schema = the librarian schema, --tools "" (no tools), permission mode default (never bypass), no session kept, cwd = the librarian folder, no token in its env',
  1457. /"supersedes"/.test(a0[a0.indexOf('--json-schema') + 1] || '') && iTools > 0 && a0[iTools + 1] === '' && a0[a0.indexOf('--permission-mode') + 1] === 'default' && !a0.includes('bypassPermissions') && a0.includes('--no-session-persistence') && a0[a0.indexOf('--model') + 1] === 'sonnet' && c1[0].cwd === LD && c1.every(c => !c.tokenFileVisible), J(c1[0] && { argv: a0, cwd: c1[0].cwd }));
  1458. const apiText = (await ticket('lib', l1.number)).events.find(e => e.author === 'Creator' && e.kind === 'comment').text;
  1459. const e1 = store().find(e => e.quotes[0] === C1);
  1460. check('m029 the stored quote IS the creator\'s text from the API, character for character; the reading is marked as the librarian\'s',
  1461. apiText === C1 && e1 && e1.quotes.length === 1 && pf.includes(`- ${e1.date} · **"${C1}"** · [lib #${l1.number} — lib page style](${BASE}/projects/lib/${l1.number})\n - means (the librarian's reading, not the creator's words): fake reading of: ${C1}`), pf);
  1462. check('m029 DECISIONS.md of lib: (m030: workers false → in the librarian folder, NOTHING in its dev folder) header rule, contents by topic (general, list, search, style 3), generated note',
  1463. !existsSync(join(LDEV, 'DECISIONS.md')) && !existsSync(join(LDEV, 'DECISIONS-archive.md')) && pf.startsWith('# Decisions — lib\n\n<!-- generated by the antcolony librarian') && pf.includes('**A decision here overrides anything else; ask only if it is not decided here.**') && pf.includes('## Contents\n- [general](#general) — 1\n- [list](#list) — 1\n- [search](#search) — 1\n- [style](#style) — 3\n') && /\n## style\n/.test(pf), pf);
  1464. check('m029 not a decision ("test") and Colony\'s comment: in no file, Colony\'s never sent to the model', !pf.includes('**"test"**') && !pf.includes('Colony says') && !lg.includes('Colony says') && !c1.some(c => /Colony says/.test(c.text)), pf);
  1465. check('m029 #global → decisions-global.md (COLONY_DECISIONS_GLOBAL), not in lib\'s file', lg.startsWith('# Decisions — all projects') && lg.includes('**"all apps keep uuid keys #global #topic:data"**') && lg.includes('## data') && !pf.includes('uuid keys'), lg);
  1466. check('m029 a verbatim excerpt is stored alone; a non-verbatim one → the whole text + a note', pf.includes('**"the header is green"**') && !pf.includes('**"the header is green and the footer is grey') && pf.includes(`**"${BADGE}"**`) && /note: the excerpt the model gave is not verbatim — the whole text is stored/.test(out), pf);
  1467. const yesCall = c1.find(c => c.text === 'yes');
  1468. check('m029 an answer on a question ticket ("yes" on the state change): stored with the question as the link; the model saw the question + its history',
  1469. pf.includes(`**"yes"** · [lib #${q29.number} — Question: dark mode too?](${BASE}/projects/lib/${q29.number})`) && yesCall && yesCall.input.ticket.isQuestion === true && yesCall.input.ticket.summary.startsWith('Should the lib page') && yesCall.input.creatorText.what === 'state change awaiting creator → confirmed' && yesCall.input.historyBefore.some(h => h.to === 'awaiting creator'), J(yesCall && yesCall.input));
  1470. check('m029 a ticket the creator opened: subject + summary are both his words', pf.includes(`**"Search box"** · **"the lib page needs a search box #topic:search"** · [lib #${own.number} — Search box]`), pf);
  1471. check('m029 a multi-line text is quoted line by line (blockquote), exact', pf.includes(`[lib #${l1.number} — lib page style](${BASE}/projects/lib/${l1.number}) · the creator wrote:\n > two rules for the list:\n > - newest first\n > - no paging #topic:list\n - means (the librarian's reading`), pf);
  1472. const sI = pf.indexOf('\n## style\n');
  1473. check('m029 newest first inside a topic (badge, header, tabs)', sI > 0 && pf.indexOf('badge colour', sI) < pf.indexOf('the header is green', sI) && pf.indexOf('the header is green', sI) < pf.indexOf('tabs everywhere', sI), pf.slice(sI));
  1474. check('m029 every registered project gets its file: dev folder on this host (dummy), else the librarian folder (alpha: /srv/alpha is not here; m030: lib — workers false — always there); no archive while nothing is superseded',
  1475. rd(join(DUMMY, 'DECISIONS.md')).includes('_(no decisions recorded yet)_') && rd(join(LD, 'projects', 'alpha', 'DECISIONS.md')).startsWith('# Decisions — alpha') && !existsSync(PA) && /^LIBRARIAN FILE .*\/lib029\/projects\/lib\/DECISIONS\.md \(6 current · librarian folder \(the project takes no workers: "workers": false — nothing is written into its repo\)\)$/m.test(out), out);
  1476. // C: nothing new → no model call, files untouched
  1477. const pfBefore = rd(PF);
  1478. out = colony(['librarian', '--post-files'], lenv());
  1479. check('m029 second pass: nothing new → no model call, no file changed', /LIBRARIAN: nothing new — no model call · 0 file\(s\) written/.test(out) && nCalls() === 8 && rd(PF) === pfBefore, out);
  1480. // D: a correction supersedes — current file without it, archive with it (quote + link kept, marked)
  1481. await comment(l1.number, 'i never said "tabs everywhere" — use spaces #topic:style');
  1482. out = colony(['librarian', '--post-files'], lenv());
  1483. const pf2 = rd(PF), pa = rd(PA);
  1484. check('m029 correction ("i never said …") → the tabs entry leaves DECISIONS.md, the new one is there; ONE model call',
  1485. /→ supersedes d-\S+ \("we use tabs everywhere/.test(out) && !pf2.includes('tabs everywhere #topic') && pf2.includes('**"i never said "tabs everywhere" — use spaces #topic:style"**') && nCalls() === 9, out + pf2);
  1486. check('m029 DECISIONS-archive.md keeps the superseded entry: quote, link, reading, "superseded on <date> by <the correction>"',
  1487. pa.startsWith('# Decisions — lib (archive: superseded)') && pa.includes(`**"${C1}"** · [lib #${l1.number} — lib page style]`) && / - superseded on \d{4}-\d{2}-\d{2} by d-\S+ — \*\*"i never said "tabs everywhere" — use spaces #topic:style"\*\*/.test(pa), pa);
  1488. // E: a refused answer (project not allowed) — retried once, then given up; --retry-refused takes it again
  1489. await comment(l1.number, 'refuse me #topic:x');
  1490. out = colony(['librarian', '--post-files'], lenv({ FAKE_LIBRARIAN_MODE: 'badproject' }));
  1491. const o2 = colony(['librarian', '--post-files'], lenv({ FAKE_LIBRARIAN_MODE: 'badproject' }));
  1492. const o3 = colony(['librarian', '--post-files'], lenv());
  1493. check('m029 an answer the code refuses (project not allowed) → not stored, tried again next pass, after 2 tries given up (no further call)',
  1494. /→ REFUSED \(1 of 2 tries\) — 'project' must be one of .*got 'no-such-project'/.test(out) && /→ REFUSED \(2 of 2 tries\)/.test(o2) && /nothing new — no model call/.test(o3) && nCalls() === 11 && !rd(PF).includes('refuse me'), out + o2 + o3);
  1495. out = colony(['librarian', '--post-files', '--retry-refused'], lenv());
  1496. check('m029 --retry-refused → the refused text is classified again → stored', /→ DECISION d-\S+ · lib · x · quote "refuse me #topic:x"/.test(out) && rd(PF).includes('**"refuse me #topic:x"**') && nCalls() === 12, out);
  1497. // F: no answer (no structured output) → retried by the next pass
  1498. await comment(l1.number, 'answer me later #topic:x');
  1499. out = colony(['librarian', '--post-files'], lenv({ FAKE_LIBRARIAN_MODE: 'nostructured' }));
  1500. const o4 = colony(['librarian', '--post-files'], lenv());
  1501. check('m029 no answer from the model → NOT done, the next pass asks again → stored', /→ NO ANSWER \(1 of 3 tries\) — no structured answer/.test(out) && /→ DECISION d-\S+ · lib · x/.test(o4) && rd(PF).includes('answer me later') && nCalls() === 14, out + o4);
  1502. // F2: a reading longer than 300 characters / on two lines → ONE line, clipped (first live pass: 5 of 155 were refused for it)
  1503. await comment(l1.number, 'a decision with a long reading #topic:x #longmeans');
  1504. out = colony(['librarian', '--post-files'], lenv());
  1505. const lm = store().find(e => e.quotes[0] === 'a decision with a long reading #topic:x #longmeans');
  1506. check('m029 a reading over 300 characters on two lines → stored as ONE line of 300 characters ending in "…" + a note', lm && lm.means.length === 300 && lm.means.endsWith('…') && !lm.means.includes('\n') && /note: the reading was longer than 300 characters — clipped/.test(out), out + J(lm));
  1507. // F3: the same short answer on two different question tickets = two different decisions (never "the same words")
  1508. const qa = await mk('lib', 'Question: which font?', 'A) serif B) sans'), qb = await mk('lib', 'Question: which icon set?', 'A) round B) square');
  1509. for (const q of [qa, qb]) { await api('POST', `/api/projects/lib/tickets/${q.number}/state`, { state: 'awaiting creator', text: 'Waiting for your answer.' }); await capi('POST', `/api/projects/lib/tickets/${q.number}/state`, { state: 'confirmed', text: 'i dont care' }); await sleep(15); }
  1510. out = colony(['librarian', '--post-files'], lenv());
  1511. const pf3 = rd(PF);
  1512. check('m029 "i dont care" on two question tickets → two entries, each linked to its own question', /2 decision\(s\)/.test(out) && pf3.includes(`**"i dont care"** · [lib #${qa.number} — Question: which font?]`) && pf3.includes(`**"i dont care"** · [lib #${qb.number} — Question: which icon set?]`), out);
  1513. // F4: --redo EVENT: the librarian's own reading was wrong → its entry removed, the text classified again (no archive)
  1514. const yesEntry = store().find(e => e.quotes[0] === 'yes');
  1515. const nR = nCalls();
  1516. out = colony(['librarian', '--post-files', '--redo', yesEntry ? yesEntry.source.event : 'none'], lenv());
  1517. const again = store().filter(e => e.quotes[0] === 'yes');
  1518. check('m029 --redo <event> → "removes <id>", ONE new model call, the entry is back once (current), nothing archived for it',
  1519. new RegExp(`librarian: --redo removes ${yesEntry && yesEntry.id}`).test(out) && nCalls() === nR + 1 && again.length === 1 && again[0].status === 'current' && !rd(PA).includes('**"yes"**'), out);
  1520. out = colony(['librarian', '--redo', 'x'], lenv());
  1521. check('m029 --redo without --post-files → refused', /librarian: REFUSED — --redo writes: add --post-files/.test(out), out);
  1522. // G: the brief carries both files (global + project), paths, the rule; a UI project names the layout conventions
  1523. let nB = nCalls();
  1524. out = colony(['brief', 'lib', String(l1.number), '--out', 'brief-lib.md'], lenv());
  1525. const bl = rd(join(WORK, 'brief-lib.md'));
  1526. const dI = bl.indexOf('## Decisions of the creator — a decision here overrides anything else; ask only if it is not decided here');
  1527. check('m029 brief: "Decisions of the creator" after the conventions, before "Where"; both file paths; search before asking',
  1528. dI > bl.indexOf('## Conventions for all apps') && dI < bl.indexOf('## Where') && bl.includes(`- All projects: \`e2e-host:${LG}\``) && bl.includes(`- lib: \`e2e-host:${PF}\``) && bl.includes('**Search both files (e.g. grep your topic) before you ask a question.**') && nCalls() === nB, bl.slice(dI, dI + 1500));
  1529. check('m029 brief: the global decisions and lib\'s decisions as text (short files: whole), headings demoted under the section',
  1530. bl.includes('### For all projects') && bl.includes('**"all apps keep uuid keys #global #topic:data"**') && bl.includes('### For lib') && bl.includes('#### style') && bl.includes('**"the header is green"**') && !bl.includes('\n# Decisions — lib'), bl.slice(dI, dI + 3000));
  1531. check('m029 brief of a UI project (registry "ui": true) names the layout conventions; delta (no ui) does not',
  1532. bl.includes('6. **UI rules: `/media/STORAGE/projects/antcolony-docs/docs/layouts-conventions.md`**') && !rd(join(WORK, 'brief-delta-1.md')).includes('UI rules'), bl.slice(bl.indexOf('## Read first'), bl.indexOf('## Read first') + 900));
  1533. // H: a long file → only the topics that share a word with the ticket (+ the note to search the whole file)
  1534. const sr = await mk('lib', 'Search results', 'Show the search results under the box.');
  1535. out = colony(['brief', 'lib', String(sr.number), '--out', 'brief-lib-long.md'], lenv({ COLONY_DECISIONS_FULL_LINES: '5' }));
  1536. const bll = rd(join(WORK, 'brief-lib-long.md'));
  1537. const pI = bll.indexOf('### For lib');
  1538. check('m029 brief with a LONG decisions file: contents + only the matching topic (search), the others left out, the note to search the whole file',
  1539. pI > 0 && bll.includes('#### search', pI) && !bll.includes('#### style', pI) && !bll.includes('#### list', pI) && bll.includes('- [style](#style)', pI) && bll.includes(`only the topics that share a word with this ticket are carried here (search; 1 of 5 topics). Search the whole file \`${PF}\``, pI) && bll.includes('(none matched; 0 of 1 topics)'), bll.slice(bll.indexOf('## Decisions of the creator'), bll.indexOf('## Where')));
  1540. // O: ORDER — the librarian comes FIRST: a creator decision written a moment ago is in the brief built right after it
  1541. await comment(l1.number, 'hand briefs see this at once #global #topic:order');
  1542. nB = nCalls();
  1543. out = colony(['brief', 'lib', String(l1.number), '--out', 'brief-order.md'], lenv());
  1544. check('m029 ORDER by hand: `brief` runs a librarian pass FIRST (one model call) → the decision written a moment ago is in the brief',
  1545. nCalls() === nB + 1 && rd(join(WORK, 'brief-order.md')).includes('**"hand briefs see this at once #global #topic:order"**') && out.indexOf('→ DECISION') >= 0 && out.indexOf('→ DECISION') < out.indexOf('brief: lib#'), out);
  1546. await comment(l1.number, 'dry runs call no model #global #topic:order');
  1547. nB = nCalls();
  1548. out = colony(['work', 'lib', String(l1.number), '--dry-run'], lenv());
  1549. const o6 = colony(['brief', 'lib', String(l1.number), '--out', 'brief-order2.md', '--librarian', 'off'], lenv());
  1550. check('m029 `work --dry-run` and `brief --librarian off` run no librarian pass (no model call)', nCalls() === nB && !/LIBRARIAN/.test(out + o6), out + o6);
  1551. colony(['librarian', '--post-files'], lenv());
  1552. // run: a project with a ticket the loop can start (host e2e-host); the decision is written right before `run --once`
  1553. const LORD = join(WORK, 'lorddev');
  1554. mkdirSync(LORD, { recursive: true });
  1555. writeFileSync(join(LORD, 'CONCEPT.md'), '# lord\nA test project: the librarian runs before the loop starts a ticket.\n');
  1556. meta('lord', LORD + '/CONCEPT.md', [], { dev: { host: 'e2e-host', folder: LORD } });
  1557. await mk('lord', 'lord task 1', 'Create a file. Verify it.');
  1558. await comment(l1.number, 'every brief of this run sees this #global #topic:order');
  1559. nB = nCalls();
  1560. const oenv = lenv({ FAKE_USAGE_PERCENT: '5', FAKE_CLAUDE_MODE: 'good', FAKE_CONTROLLER_MODE: 'pass' });
  1561. const rargs = ['run', '--once', '--reserve', '50', '--week-limit', '100', '--port-pool', '8780-8799', '--ports-per-session', '10'];
  1562. out = colony(rargs, oenv, 120000);
  1563. const startedS = (out.match(/started \S+ \((s-\d{8}T\d{4}-[0-9a-f]{6}), ports 8780-8789\)/) || [])[1];
  1564. const rbrief = startedS ? rd(join(LRUNS, startedS, 'brief.md')) : '';
  1565. check('m029 ORDER in `run`: the iteration files the new creator text FIRST (→ DECISION before the ITER line that starts a worker), and that worker\'s brief has it',
  1566. nCalls() === nB + 1 && startedS && out.indexOf('→ DECISION') >= 0 && out.indexOf('→ DECISION') < out.indexOf('ITER 1 ') && rbrief.includes('**"every brief of this run sees this #global #topic:order"**') && /^RUN STOPPED — --once/m.test(out), out);
  1567. // a pass that leaves a creator text unfiled (limit 1 per pass, 2 new texts) → NOTHING starts in that iteration
  1568. await mk('lord', 'lord task 2', 'Create a file. Verify it.');
  1569. await comment(l1.number, 'first of two #global #topic:order');
  1570. await sleep(15);
  1571. await comment(l1.number, 'second of two #global #topic:order');
  1572. nB = nCalls();
  1573. out = colony([...rargs, '--librarian-max', '1'], oenv, 120000);
  1574. check('m029 ORDER in `run`: a pass that left a creator text unfiled (--librarian-max 1, 2 new) → "not filed yet → no start this iteration", nothing started',
  1575. nCalls() === nB + 1 && /ITER 1 .* · librarian: 1 creator text\(s\) not filed yet → no start this iteration/.test(out) && !/started \S+ \(s-/.test(out), out);
  1576. out = colony([...rargs, '--librarian-max', '1'], oenv, 120000);
  1577. const s2 = (out.match(/started \S+ \((s-\d{8}T\d{4}-[0-9a-f]{6}), ports/) || [])[1];
  1578. check('m029 ORDER in `run`: the next iteration files the rest first, then starts — the brief has both texts',
  1579. nCalls() === nB + 2 && s2 && rd(join(LRUNS, s2, 'brief.md')).includes('**"first of two #global #topic:order"**') && rd(join(LRUNS, s2, 'brief.md')).includes('**"second of two #global #topic:order"**'), out);
  1580. // I: `run` calls the librarian every iteration (quiet when nothing is new); --librarian off
  1581. await comment(l1.number, 'lib buttons are round #topic:style');
  1582. // mission 034: the librarian runs only while the quota is below the start threshold — so starts are held here by an
  1583. // UNKNOWN week (the librarian still runs then), no longer by 5 h 99 % ≥ reserve 50 % (was: FAKE_USAGE_PERCENT 99)
  1584. const WNONE = join(WORK, 'week-none');
  1585. writeFileSync(WNONE, 'none\n');
  1586. const runEnv = lenv({ FAKE_USAGE_PERCENT: '5', FAKE_WEEK_FILE: WNONE });
  1587. nB = nCalls();
  1588. out = colony(['run', '--once', '--reserve', '50', '--week-limit', '84', '--port-pool', '8780-8799'], runEnv, 90000);
  1589. check('m029 run --once: the librarian ran in the iteration → the new text filed (ONE model call); RUN line says "librarian on, first in every iteration"',
  1590. /^RUN: started on e2e-host .* librarian on, first in every iteration \(sonnet\)/m.test(out) && /^LIBRARIAN TEXT lib#\d+ · comment · Creator/m.test(out) && /→ DECISION d-\S+ · lib · style/.test(out) && rd(PF).includes('**"lib buttons are round #topic:style"**') && nCalls() === nB + 1 && /^RUN STOPPED — --once/m.test(out), out);
  1591. out = colony(['run', '--once', '--reserve', '50', '--week-limit', '84', '--port-pool', '8780-8799'], runEnv, 90000);
  1592. check('m029 run --once again: nothing new → no LIBRARIAN line, no model call', !/LIBRARIAN/.test(out) && nCalls() === nB + 1 && /^RUN STOPPED — --once/m.test(out), out);
  1593. await comment(l1.number, 'not while off #topic:style');
  1594. out = colony(['run', '--once', '--reserve', '50', '--week-limit', '84', '--port-pool', '8780-8799', '--librarian', 'off'], runEnv, 90000);
  1595. const o5 = colony(['librarian', '--post-files'], lenv());
  1596. check('m029 run --librarian off → no librarian; the next pass by hand files it', /librarian off · /.test(out) && !/LIBRARIAN/.test(out) && /→ DECISION d-\S+ · lib · style/.test(o5) && nCalls() === nB + 2, out + o5);
  1597. out = colony(['run', '--once', '--librarian', 'maybe'], runEnv);
  1598. check('m029 run --librarian maybe → refused', /run: REFUSED — --librarian must be on or off, got maybe/.test(out), out);
  1599. // J: the chat seed (decisions made in chat, the architect's file): quotes exact (wrapped lines joined), the reading
  1600. // marked as the ARCHITECT's, source line; items without a quote skipped; idempotent
  1601. const SEED = join(WORK, 'seed.md');
  1602. writeFileSync(SEED, [
  1603. '# Global decisions — seed', '', 'Intro text (not an entry).', '',
  1604. '## Data',
  1605. '- "in general use the mpackdb uuid when creating things" · reading: primary keys are mpackdb `@id` ids, not counters.',
  1606. '- "i wanted to suggest at first we put those things in a project summary in tickets but a local file is better to',
  1607. ' handle if it got big" · reading: decisions are kept in local DECISIONS files, not in tickets.',
  1608. '',
  1609. '## Working style (for workers and the architect)',
  1610. '- About a shortcut implementation: "dumb useless question" · "build it" · reading: build it properly.',
  1611. '- Markup/CSS rules: `layouts-conventions.md` in this folder.',
  1612. '- "avatar can come from ident"', '',
  1613. ].join('\n'));
  1614. nB = nCalls();
  1615. out = colony(['librarian', '--seed', SEED], lenv());
  1616. check('m029 --seed without --post-files → refused', /librarian: REFUSED — --seed writes: add --post-files/.test(out), out);
  1617. out = colony(['librarian', '--post-files', '--seed', SEED], lenv());
  1618. const lg2 = rd(LG);
  1619. check('m029 seed: 4 entries (all projects), the item without a quote skipped, no model call',
  1620. /^LIBRARIAN SEED — 4 new entries, 0 already there \(HAVE\), 1 item\(s\) without a quote skipped/m.test(out) && /seed item without a quote — skipped \(no reading without the quote\): working style: Markup\/CSS rules/.test(out) && nCalls() === nB, out);
  1621. check('m029 seed: a quote wrapped over two lines is joined exactly; the reading is marked as the ARCHITECT\'s; source = the chat',
  1622. lg2.includes('- 2026-09-23/24 · **"i wanted to suggest at first we put those things in a project summary in tickets but a local file is better to handle if it got big"** · source: architect session 2026-09-23/24, chat\n - means (the architect\'s reading, not the creator\'s words): decisions are kept in local DECISIONS files, not in tickets.') && lg2.includes('## working style') && lg2.includes('About a shortcut implementation: **"dumb useless question"** · **"build it"**') && lg2.includes('**"avatar can come from ident"** · source: architect session 2026-09-23/24, chat\n - means: — (no reading recorded; the quote is all there is)') && lg2.includes('**"all apps keep uuid keys #global #topic:data"**'), lg2);
  1623. out = colony(['librarian', '--post-files', '--seed', SEED], lenv());
  1624. check('m029 seed again → nothing new (HAVE), file unchanged', /LIBRARIAN SEED — 0 new entries, 4 already there \(HAVE\)/.test(out) && rd(LG) === lg2, out);
  1625. // K: the lock (one pass at a time) and --render
  1626. writeFileSync(join(LD, 'lock'), J({ ms: Date.now() }));
  1627. await comment(l1.number, 'locked out #topic:style');
  1628. nB = nCalls();
  1629. out = colony(['librarian', '--post-files'], lenv());
  1630. const o7 = colony(['brief', 'lib', String(l1.number), '--out', 'brief-busy.md'], lenv());
  1631. check('m029 a pass while another holds the lock → BUSY, nothing done; `brief` then says so and goes on with the files as they are',
  1632. /librarian: BUSY — another librarian pass holds .*lock since/.test(out) && /librarian: brief goes on WITHOUT a finished librarian pass \(another librarian pass holds/.test(o7) && existsSync(join(WORK, 'brief-busy.md')) && nCalls() === nB, out + o7);
  1633. rmSync(join(LD, 'lock'));
  1634. colony(['librarian', '--post-files'], lenv());
  1635. const full = rd(PF);
  1636. rmSync(PF);
  1637. nB = nCalls();
  1638. out = colony(['librarian', '--render'], lenv());
  1639. check('m029 --render rebuilds the files from decisions.json (no tickets, no model call), identical', rd(PF) === full && /LIBRARIAN RENDER — 1 file\(s\) written/.test(out) && nCalls() === nB, out);
  1640. check('m029 the librarian wrote nothing into tickets (READ only): every event in lib#1 is by Creator or the e2e\'s Colony setup', (await ticket('lib', l1.number)).events.every(e => e.author === 'Creator' || (e.author === 'Colony' && /Colony says|^$/.test(e.text))), J((await ticket('lib', l1.number)).events.map(e => [e.author, e.kind, e.text.slice(0, 40)])));
  1641. writeFileSync(join(WORK, 'lib029-DECISIONS.md'), full);
  1642. writeFileSync(join(WORK, 'lib029-decisions-global.md'), rd(LG));
  1643. writeFileSync(join(WORK, 'lib029-DECISIONS-archive.md'), rd(PA));
  1644. // ---- mission 034 (antcolony#25): the librarian respects the Claude usage limit. Live 2026-09-25 19:58: the 5 h limit
  1645. // was reached, yet every iteration called the librarian's model (empty answer "success", the text left unfiled) — every
  1646. // minute until the reset. Now: the quota FIRST (no librarian call / no start at or above the start threshold); a model
  1647. // call that still hits the limit (librarian, or a worker parked on it) → ONE `LIMIT:` line, no model call and no start
  1648. // until the reset, ONE line when it resumes; no reset time → 15 min. FAKE claude only (fake-claude.mjs mode `ratelimit`
  1649. // = the live answer's shape).
  1650. {
  1651. const LIM = join(WORK, 'limdev');
  1652. mkdirSync(LIM, { recursive: true });
  1653. writeFileSync(join(LIM, 'CONCEPT.md'), '# lim\nA test project: the librarian and the usage limit.\n');
  1654. meta('lim', LIM + '/CONCEPT.md', [], { dev: { host: 'e2e-host', folder: LIM } });
  1655. // test setup: every other open ticket on hold, so a run here can only start lim tickets
  1656. for (const t of (await api('GET', '/api/tickets')).json.tickets) if (t.state === 'open') await api('POST', `/api/projects/${t.project}/tickets/${t.number}/state`, { state: 'on hold', text: 'e2e: parked for the m034 tests' });
  1657. const UF34 = join(WORK, 'm034-usage'), MF34 = join(WORK, 'm034-librarian-mode'), FLD34 = join(WORK, 'm034-calls');
  1658. writeFileSync(UF34, '5\n'); writeFileSync(MF34, 'good\n');
  1659. const menv = (extra = {}) => lenv({ FAKE_USAGE_FILE: UF34, FAKE_LIBRARIAN_MODE_FILE: MF34, FAKE_LOG_DIR: FLD34, FAKE_CLAUDE_MODE: 'good', FAKE_CONTROLLER_MODE: 'pass', ...extra });
  1660. const probes = () => { try { return readdirSync(FLD34).map(f => JSON.parse(readFileSync(join(FLD34, f), 'utf8'))).filter(c => c.kind === 'usage'); } catch { return []; } };
  1661. const qargs = ['--reserve', '70', '--week-limit', '84', '--port-pool', '8780-8799', '--ports-per-session', '10'];
  1662. const iso = (ms) => new Date(ms).toISOString().slice(0, 19) + 'Z';
  1663. const bgRun = (args, extra) => {
  1664. const proc = spawn(join(CODE, 'colony'), ['run', ...args], { cwd: WORK, env: colonyEnv(menv(extra)), stdio: ['ignore', 'pipe', 'pipe'] });
  1665. const d = { proc, text: '', schedPid: null, exited: null };
  1666. d.exited = new Promise(r => proc.once('exit', (code, sig) => r({ code, sig })));
  1667. const add = (b) => { d.text += b; const m = d.text.match(/scheduler pid (\d+)/); if (m) d.schedPid = Number(m[1]); };
  1668. proc.stdout.on('data', add); proc.stderr.on('data', add);
  1669. d.waitFor = async (re, ms = 20000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { const m = d.text.match(re); if (m) return m; await sleep(100); } return null; };
  1670. d.done = async (ms = 20000) => Promise.race([d.exited, sleep(ms).then(() => null)]);
  1671. daemons.push(d);
  1672. return d;
  1673. };
  1674. const eventId = async (n, text) => ((await ticket('lib', n)).events.find(e => e.author === 'Creator' && e.text === text) || {}).id;
  1675. const lstate = () => JSON.parse(rd(join(LD, 'state.json')) || '{}');
  1676. // A (point 0): the quota is read BEFORE the librarian — at or above the start threshold no librarian call, no start
  1677. const m1 = await mk('lim', 'lim task 1', 'Create a file. Verify it.');
  1678. const A_TEXT = 'filed only below the limit #topic:limit';
  1679. await comment(l1.number, A_TEXT);
  1680. writeFileSync(UF34, '100\n');
  1681. let nB = nCalls(), nP = probes().length;
  1682. out = colony(['run', '--once', ...qargs], menv(), 90000);
  1683. writeFileSync(join(WORK, 'm034-A-quota100.txt'), out);
  1684. check('m034 run --once at 5 h 100 % (limit reached): the quota is read FIRST → NO librarian call, no start; ONE ITER line with the reset time and "librarian not run"',
  1685. nCalls() === nB && !/LIBRARIAN/.test(out) && probes().length === nP + 1 && new RegExp(`^ITER 1 \\S+ · quota 100% of 5 h \\(resets \\d{4}-\\d\\d-\\d\\dT\\d\\d:\\d\\d:\\d\\dZ\\), week 41% ≥ reserve 70% → no start \\(waiting: lim#${m1.number}\\) · running 0/1 · librarian not run: quota at or above the start threshold \\(the 5 h limit is reached \\(100%\\)\\)`, 'm').test(out) && !/started \S+ \(s-/.test(out) && /^RUN STOPPED — --once/m.test(out), out);
  1686. writeFileSync(UF34, '75\n');
  1687. nP = probes().length;
  1688. out = colony(['run', '--once', ...qargs], menv(), 90000);
  1689. writeFileSync(join(WORK, 'm034-A-quota75.txt'), out);
  1690. check('m034 run --once at 5 h 75 % ≥ reserve 70 %: no librarian call, no start, "librarian not run: … (5 h 75% ≥ reserve 70%)"; ONE probe (reused for the start check)',
  1691. nCalls() === nB && !/LIBRARIAN/.test(out) && probes().length === nP + 1 && /^ITER 1 .* → no start \(waiting: lim#\d+\) · running 0\/1 · librarian not run: quota at or above the start threshold \(5 h 75% ≥ reserve 70%\)/m.test(out), out);
  1692. writeFileSync(UF34, '100\n');
  1693. out = colony(['run', '--once', '--reserve', '100', '--week-limit', '100', '--port-pool', '8780-8799'], menv(), 90000);
  1694. writeFileSync(join(WORK, 'm034-A-quota100-checks-off.txt'), out);
  1695. check('m034 --reserve 100 --week-limit 100 (checks off) at 5 h 100 %: a REACHED limit still holds the librarian and every start',
  1696. nCalls() === nB && !/LIBRARIAN/.test(out) && new RegExp(`^ITER 1 \\S+ · quota 100% of 5 h \\([^)]+\\), week 41% → the 5 h limit is reached \\(100%\\) → no start \\(waiting: lim#${m1.number}\\) · running 0/1 · librarian not run`, 'm').test(out) && !/started \S+ \(s-/.test(out), out);
  1697. // B (points 1 + 2): the quota says 5 % (below), but the librarian's model call answers the LIMIT (the live shape) →
  1698. // recognised, the pass stops, ONE LIMIT line, no model call + no probe + no start until the reset, ONE resume line,
  1699. // then the text is filed and the ticket started
  1700. writeFileSync(UF34, '5\n'); writeFileSync(MF34, 'ratelimit\n');
  1701. nB = nCalls();
  1702. const rb = bgRun(['--interval', '1', ...qargs], { FAKE_RATELIMIT_RESET: '8' });
  1703. const lm = await rb.waitFor(/^LIMIT: usage limit reached — librarian and starts paused until (\S+) · (.*)$/m, 30000);
  1704. writeFileSync(MF34, 'good\n'); // the next call (after the reset) answers normally
  1705. const lr = await rb.waitFor(/^LIMIT: resumed — .*$/m, 30000);
  1706. const ld = await rb.waitFor(new RegExp(`^SESSION END lim#${m1.number} (s-\\S+) · posted`, 'm'), 40000);
  1707. rb.proc.kill('SIGTERM');
  1708. await rb.done(20000);
  1709. writeFileSync(join(WORK, 'm034-B-librarian-limit.txt'), rb.text);
  1710. const bt = rb.text;
  1711. const bc = calls().slice(nB);
  1712. const limCall = bc.find(c => c.mode === 'ratelimit');
  1713. const resetMs = limCall ? limCall.resetsAt * 1000 : 0;
  1714. check('m034 the librarian\'s call answered the limit (rate_limit_event rejected, synthetic "You\'ve hit your session limit", subtype success, is_error, 429) → recognised: "→ USAGE LIMIT … resets <the event\'s time>", the pass stops, "0 without an answer"',
  1715. limCall && /LIBRARIAN USAGE: \$0 · 1 turns/.test(bt) && bt.includes(` → USAGE LIMIT — You've hit your session limit · resets 8:50pm (Europe/Vienna) · resets ${iso(resetMs)} → the pass stops, the text stays unfiled (no try counted)`) && /^LIBRARIAN: 1 of 1 new text\(s\) classified \(Claude usage limit reached — the rest after the reset\) · 0 decision\(s\), 0 superseded, 0 not a decision, 0 refused, 0 without an answer/m.test(bt) && !/NO ANSWER/.test(bt), bt);
  1716. check('m034 ONE line when the pause begins: "LIMIT: usage limit reached — librarian and starts paused until <the reset>" (the librarian\'s call, 1 text not filed)',
  1717. lm && lm[1] === iso(resetMs) && /the librarian's model call hit it \(You've hit your session limit/.test(lm[2]) && /1 creator text\(s\) not filed yet/.test(lm[2]) && (bt.match(/^LIMIT: usage limit reached/mg) || []).length === 1 && !/^LIMIT: the pause is extended/m.test(bt), bt);
  1718. const pausedPart = lm && lr ? bt.slice(bt.indexOf(lm[0]) + lm[0].length, bt.indexOf(lr[0])) : 'x';
  1719. const pauseProbes = probes().filter(p => limCall && p.start > limCall.at && p.start < resetMs);
  1720. check('m034 during the pause: NO model call (the next librarian call only after the reset), NO quota probe, no start, no ITER line (silent)',
  1721. limCall && bc.filter(c => c.at > limCall.at && c.at < resetMs).length === 0 && bc.filter(c => c.at >= resetMs).length >= 1 && pauseProbes.length === 0 && !/started |^ITER /m.test(pausedPart) && (Date.now() > resetMs), J({ pausedPart, pauseProbes: pauseProbes.length, calls: bc.map(c => [c.mode, c.at]), resetMs }));
  1722. const iRes = lr ? bt.indexOf(lr[0]) : -1;
  1723. const sB = ld && ld[1];
  1724. const idB = await eventId(l1.number, A_TEXT);
  1725. check('m034 ONE line when it resumes (after the reset), then the quota → the librarian files the text (the SAME text, 1 try counted) → lim#1 started → posted',
  1726. lr && (bt.match(/^LIMIT: resumed/mg) || []).length === 1 && iRes > bt.indexOf(lm ? lm[0] : 'x') && bt.indexOf('→ DECISION', iRes) > iRes && bt.indexOf(`started lim#${m1.number} (`, iRes) > bt.indexOf('→ DECISION', iRes) && bt.indexOf(`started lim#${m1.number} (`) === bt.indexOf(`started lim#${m1.number} (`, iRes) && sB && (await ticket('lim', m1.number)).ticket.state === 'awaiting creator' && rd(PF).includes(`**"${A_TEXT}"**`) && probes().some(p => p.start >= resetMs) && store().some(e => e.quotes[0] === A_TEXT) && idB && lstate().tries[idB] === 1 && String(lstate().done[idB]).startsWith('decision'), bt + J({ tries: idB && lstate().tries[idB], done: idB && lstate().done[idB] }));
  1727. // C (point 4 + a robust check): the limit WITHOUT a reset time (no rate_limit_event, no 429 — only the synthetic message
  1728. // and its text) → still recognised; paused 15 min
  1729. await api('POST', `/api/projects/lim/tickets/${m1.number}/state`, { state: 'on hold', text: 'e2e: done for m034' }).catch(() => null);
  1730. const m2 = await mk('lim', 'lim task 2', 'Create a file. Verify it.');
  1731. const C_TEXT = 'no reset time known #topic:limit';
  1732. await comment(l1.number, C_TEXT);
  1733. writeFileSync(MF34, 'ratelimit-noreset\n');
  1734. nB = nCalls();
  1735. const tC = Date.now();
  1736. out = colony(['run', '--once', ...qargs], menv(), 90000);
  1737. writeFileSync(join(WORK, 'm034-C-noreset.txt'), out);
  1738. const lc = out.match(/^LIMIT: usage limit reached — librarian and starts paused until (\S+) \(no reset time known → 15 min, then try once\) · /m);
  1739. const untilC = lc ? Date.parse(lc[1]) : 0;
  1740. check('m034 a limit answer WITHOUT a reset time (no rate_limit_event, no api_error_status — the synthetic message only) → recognised ("resets at an unknown time"), paused 15 min, no start, ONE model call',
  1741. nCalls() === nB + 1 && /→ USAGE LIMIT — You've hit your session limit · resets 8:50pm \(Europe\/Vienna\) · resets at an unknown time → the pass stops/.test(out) && lc && untilC >= tC + 14 * 60000 && untilC <= Date.now() + 15 * 60000 + 1000 && !/started \S+ \(s-/.test(out) && /^RUN STOPPED — --once/m.test(out), out);
  1742. writeFileSync(MF34, 'good\n');
  1743. const oC = colony(['librarian', '--post-files'], menv());
  1744. const idC = await eventId(l1.number, C_TEXT);
  1745. check('m034 the text left by the limited pass is filed by the next pass; the limited call counted no try', /→ DECISION d-\S+ · lib · limit/.test(oC) && idC && lstate().tries[idC] === 1, oC + J({ tries: idC && lstate().tries[idC] }));
  1746. await api('POST', `/api/projects/lim/tickets/${m2.number}/state`, { state: 'on hold', text: 'e2e: parked after the m034 test' });
  1747. // D (point 3): a WORKER parked on the limit with a known reset → the librarian and starts pause until then too (no
  1748. // model call into a known limit); the parked session still resumes as before
  1749. const m3 = await mk('lim', 'lim task 3', 'Create a file. Verify it.');
  1750. writeFileSync(UF34, '5\n');
  1751. nB = nCalls();
  1752. const rd4 = bgRun(['--interval', '1', ...qargs], { FAKE_CLAUDE_MODE: 'ratelimit', FAKE_RATELIMIT_RESET: '8' });
  1753. const pk = await rd4.waitFor(new RegExp(`^PARKED — lim#${m3.number} session (s-\\S+) \\(worker\\) until (\\S+): Claude usage limit`, 'm'), 40000);
  1754. const lmD = await rd4.waitFor(/^LIMIT: usage limit reached — librarian and starts paused until (\S+) · (.*)$/m, 10000);
  1755. const D_TEXT = 'written while the worker is parked #topic:limit';
  1756. await comment(l1.number, D_TEXT);
  1757. const tD = Date.now();
  1758. const lrD = await rd4.waitFor(/^LIMIT: resumed — .*$/m, 30000);
  1759. const rsD = await rd4.waitFor(new RegExp(`resumed lim#${m3.number} \\((s-\\S+), ports`), 20000);
  1760. const edD = await rd4.waitFor(new RegExp(`^SESSION END lim#${m3.number} (s-\\S+) · posted`, 'm'), 40000);
  1761. rd4.proc.kill('SIGTERM');
  1762. await rd4.done(20000);
  1763. writeFileSync(join(WORK, 'm034-D-worker-parked.txt'), rd4.text);
  1764. const dt = rd4.text;
  1765. const parkMs = pk ? Date.parse(pk[2]) : 0;
  1766. const dc = calls().slice(nB).filter(c => c.text === D_TEXT);
  1767. check('m034 a worker parked on the limit (known reset) → "LIMIT: … paused until <its reset>" naming the parked session; ONE such line',
  1768. pk && lmD && lmD[1] === iso(parkMs) && lmD[2].includes(`lim#${m3.number} ${pk[1]} was parked on it (Claude usage limit (You've hit your limit`) && (dt.match(/^LIMIT: usage limit reached/mg) || []).length === 1, dt);
  1769. check('m034 while that pause lasts: the creator text written meanwhile gets NO librarian call; after "LIMIT: resumed" it is filed',
  1770. lrD && dc.length === 1 && dc[0].at >= parkMs && dc[0].at > tD && dt.indexOf('→ DECISION', dt.indexOf(lrD[0])) > dt.indexOf(lrD[0]) && store().some(e => e.quotes[0] === D_TEXT), dt + J(dc.map(c => c.at)) + ' park ' + parkMs);
  1771. check('m034 the parked session still resumes as before (after the reset, below the reserve) → `--resume` → posted',
  1772. rsD && edD && rsD[1] === pk[1] && dt.indexOf(rsD[0]) > dt.indexOf(lrD[0]) && (await ticket('lim', m3.number)).ticket.state === 'awaiting creator', dt);
  1773. check('m034 no `colony run` or fake claude of this block left running', daemons.every(d => d.proc.exitCode !== null || d.proc.signalCode !== null) && fakePids().every(p => !isOurs(p, 'fake-claude.mjs')), J(daemons.map(d => [d.proc.pid, d.schedPid])));
  1774. }
  1775. }
  1776. // ---- mission 023: every text the SCHEDULER wrote into tickets (comments, state texts, tickets it filed) follows the
  1777. // README hard limit (≤ 5 lines the creator reads, ONE machine line, last) — swept over the whole store; the texts are
  1778. // dumped to .scratch/e2e/texts.md (examples for the report). Seed / test-setup texts (by the e2e with Colony's token) are skipped.
  1779. {
  1780. const all = (await api('GET', '/api/tickets')).json.tickets;
  1781. const texts = [];
  1782. for (const t of all) {
  1783. const d = await ticket(t.project, t.number);
  1784. if ((t.source || '').startsWith('colony')) texts.push({ where: `${t.project}#${t.number} ticket (subject + summary)`, text: t.subject + '\n\n' + (t.summary || ''), subject: t.subject });
  1785. for (const e of d.events) {
  1786. if (e.author !== 'Colony' || (e.kind !== 'comment' && e.kind !== 'state') || !e.text) continue;
  1787. if (/^(seed|e2e|colony note)/.test(e.text)) continue;
  1788. texts.push({ where: `${t.project}#${t.number} ${e.kind}${e.kind === 'state' ? ' → ' + e.to : ''}`, text: e.text });
  1789. }
  1790. }
  1791. const tooLong = texts.filter(x => readableLines(x.text) - (x.subject ? 1 : 0) > 5);
  1792. const machine = texts.filter(x => { const ls = x.text.split('\n').filter(l => /^colony-[a-z-]+: /.test(l)); return ls.length > 1 || (ls.length === 1 && x.text.split('\n').at(-1) !== ls[0]); });
  1793. const jargon = texts.filter(x => /###|<details>|Worker report|Controller:|Heartbeat|Run files|lease until|sha256 [0-9a-f]{13}/.test(x.text));
  1794. writeFileSync(join(WORK, 'texts.md'), texts.map(x => `### ${x.where}\n\n\`\`\`\n${x.text}\n\`\`\`\n`).join('\n'));
  1795. check(`m023 sweep: all ${texts.length} scheduler texts in the store have ≤ 5 lines the creator reads (subject not counted)`, texts.length > 40 && tooLong.length === 0, J(tooLong));
  1796. check('m023 sweep: at most ONE machine line per text, always the last line', machine.length === 0, J(machine));
  1797. check('m023 sweep: no old report sections / details / jargon (###, <details>, "Worker report", "Controller:", "Heartbeat", "Run files", "lease until", full sha256)', jargon.length === 0, J(jargon.map(x => x.where + ': ' + x.text.slice(0, 120))));
  1798. const qsub = all.filter(t => /:question|colony:no-/.test(t.source || '') ).filter(t => !t.subject.startsWith('Question'));
  1799. check('m023 sweep: every creator question the scheduler filed has a subject starting with "Question"', qsub.length === 0, J(qsub.map(t => [t.project + t.ref, t.subject])));
  1800. }
  1801. // ---- the REAL claude (COLONY_E2E_REAL=1; costs quota) ----------------------------------------------
  1802. if (process.env.COLONY_E2E_REAL === '1') {
  1803. const realModel = process.env.COLONY_E2E_REAL_MODEL || 'sonnet';
  1804. const realEnv = { COLONY_HOST: 'e2e-host', COLONY_RUNS_DIR: RUNS, COLONY_PORTS: '8770-8779' }; // the brief's port range (this mission: 8750–8799)
  1805. // test setup: park the open dummy tickets so the cycle picks the new one (the oldest eligible on e2e-host)
  1806. for (const t of (await api('GET', '/api/projects/dummy/tickets')).json.tickets) if (t.state === 'open') await api('POST', `/api/projects/dummy/tickets/${t.number}/state`, { state: 'on hold', text: 'e2e: parked for the real cycle' });
  1807. const r1 = await mk('dummy', 'Create hello.txt', [
  1808. 'Create the file `hello.txt` in your working directory (the dev folder) with exactly the content `hello from the colony` (one line, newline at the end), then verify it with `cat hello.txt`.',
  1809. '',
  1810. 'This is a self-contained test project: the README/STATUS/antcolony README of "Read first" do not matter here — do NOT read other folders or hosts, do NOT use ssh or the network. Keep it short.',
  1811. ].join('\n'));
  1812. const before1 = (await ticket('dummy', r1.number)).events.length;
  1813. out = colony(['cycle', '--post', '--model', realModel, '--max-turns', '12', '--timeout', '600', '--controller-model', realModel, '--controller-max-turns', '10', '--controller-timeout', '600'], realEnv, 1260000);
  1814. console.log('---- cycle --post (REAL claude, worker + controller ' + realModel + ') ----\n' + out.trim() + '\n-----------------------------------');
  1815. const sr = sessionOf(out);
  1816. const dr = await ticket('dummy', r1.number), evr = dr.events.slice(before1);
  1817. const hello = join(DUMMY, 'hello.txt');
  1818. check(`REAL cycle (${realModel}): picked dummy#${r1.number}; the file the worker was asked to create exists with the content`, new RegExp(`^cycle: 1 next → dummy#${r1.number} `, 'm').test(out) && existsSync(hello) && readFileSync(hello, 'utf8') === 'hello from the colony\n', existsSync(hello) ? J(readFileSync(hello, 'utf8')) : 'missing');
  1819. check('REAL cycle: the controller PASSED it → lease, report comment with the controller section, → awaiting creator', /^VERDICT: PASS/m.test(out) && evr.length === 3 && evr[0].text.startsWith(`A worker started on this ticket.\n\ncolony-lease: ${sr} · host e2e-host · started · ms `) && evr[1].kind === 'comment' && new RegExp(`\\ncolony-report: ${sr} · sha256 [0-9a-f]{12} · controller pass$`).test(evr[1].text) && readableLines(evr[1].text) <= 5 && dr.ticket.state === 'awaiting creator', out + J(evr));
  1820. const RR = join(RUNS, sr || 'none');
  1821. const sjr = existsSync(join(RR, 'session.json')) ? JSON.parse(readFileSync(join(RR, 'session.json'), 'utf8')) : { controller: {} };
  1822. check('REAL cycle: runs/<session>/ has every worker + controller file; costs recorded; both ran in permission mode auto; CYCLE COST printed', files1.every(f => existsSync(join(RR, f))) && typeof sjr.costUsd === 'number' && typeof sjr.controller.costUsd === 'number' && sjr.permissionModeSeen === 'auto' && sjr.controller.permissionModeSeen === 'auto' && /^CYCLE COST: \$[0-9.]+ \(worker \$[0-9.]+ \+ controller \$[0-9.]+\)$/m.test(out), J({ worker: [sjr.costUsd, sjr.permissionModes, sjr.permissionDenials], controller: sjr.controller }));
  1823. console.log('REAL pass comment:\n' + (evr[1] ? evr[1].text : '(none)'));
  1824. // a report that claims something false (FAKE worker: `missing.txt`, never written) checked by the REAL controller
  1825. rmSync(join(DUMMY, 'missing.txt'), { force: true });
  1826. const r2 = await mk('dummy', 'Create missing.txt', 'Create `missing.txt` with the content `hello`. Verify it.');
  1827. const before2 = (await ticket('dummy', r2.number)).events.length;
  1828. out = colony(['work', 'dummy', String(r2.number), '--post', '--controller-model', realModel, '--controller-max-turns', '10', '--controller-timeout', '600'], { ...realEnv, COLONY_CLAUDE: FAKE, FAKE_CLAUDE_MODE: 'falseclaim', COLONY_CONTROLLER_CLAUDE: 'claude' }, 660000);
  1829. console.log('---- work (FAKE worker claiming missing.txt, REAL controller ' + realModel + ') ----\n' + out.trim() + '\n-----------------------------------');
  1830. const sf = sessionOf(out);
  1831. const df = await ticket('dummy', r2.number), evf = df.events.slice(before2);
  1832. const vf = sf && existsSync(join(RUNS, sf, 'verdict.json')) ? JSON.parse(readFileSync(join(RUNS, sf, 'verdict.json'), 'utf8')) : { findings: [] };
  1833. const sjf = sf && existsSync(join(RUNS, sf, 'session.json')) ? JSON.parse(readFileSync(join(RUNS, sf, 'session.json'), 'utf8')) : { controller: {} };
  1834. check('REAL controller FAILS the false report: verdict fail, a false / no-evidence finding on missing.txt, WORK FAILED → back to open with the findings', vf.verdict === 'fail' && vf.findings.some(f => /missing\.txt/.test(f.claim + f.check) && (f.status === 'false' || f.status === 'no evidence')) && /^WORK FAILED — controller: fail \(1 of 2\) → dummy#\d+ back to open/m.test(out) && evf.length === 3 && evf[1].text.startsWith('The result did not hold up') && df.ticket.state === 'open' && !existsSync(join(DUMMY, 'missing.txt')), out + J(vf));
  1835. check('REAL controller: cost recorded, permission mode auto', typeof sjf.controller.costUsd === 'number' && sjf.controller.permissionModeSeen === 'auto', J(sjf.controller));
  1836. console.log('REAL fail comment:\n' + (evf[1] ? evf[1].text : '(none)'));
  1837. if (process.env.COLONY_E2E_REAL_MAXTURNS === '1') {
  1838. // mission 016: max turns 1 on a harder task, cheapest model → no report → back to open (no controller)
  1839. const r3 = await mk('dummy', 'Harder task', 'Write `fib.py` printing the first 30 Fibonacci numbers, run it, and write a `fib.md` explaining the output. Verify every step. Do NOT read other folders or hosts.');
  1840. const before3 = (await ticket('dummy', r3.number)).events.length;
  1841. out = colony(['work', 'dummy', String(r3.number), '--post', '--model', 'haiku', '--max-turns', '1', '--timeout', '300'], realEnv, 360000);
  1842. console.log('---- work (REAL claude, haiku, max turns 1) ----\n' + out.trim() + '\n-----------------------------------');
  1843. const dm = await ticket('dummy', r3.number), evm = dm.events.slice(before3);
  1844. check('REAL work --max-turns 1 → no valid report → WORK FAILED, back to open with a comment, no controller', /^WORK FAILED — /m.test(out) && !/controller: started/.test(out) && evm.length === 2 && evm[1].to === 'open' && dm.ticket.state === 'open', out + J(evm));
  1845. }
  1846. }
  1847. // antcolony#18: the same `work`, but with the sandbox ON — the fake claude + controller run inside the bubblewrap box
  1848. // (the fake's code folder is read-only in it, its logs go into the project's own folder)
  1849. {
  1850. const sbx = await mk('dummy', 'sandbox task', 'A task to run inside the box.');
  1851. const SLOG = join(DUMMY, 'sbx-worker.json'), SCLOG = join(DUMMY, 'sbx-controller.json');
  1852. const o = colony(['work', 'dummy', String(sbx.number), '--post', '--model', 'haiku', '--max-turns', '7'], workEnv('good', { COLONY_SANDBOX: 'on', COLONY_SANDBOX_RO: join(CODE, 'tests'), FAKE_CLAUDE_LOG: SLOG, FAKE_CONTROLLER_LOG: SCLOG }));
  1853. console.log('---- work (fake, good, SANDBOX ON) ----\n' + o.trim() + '\n-----------------------------------');
  1854. const fl = existsSync(SLOG) ? JSON.parse(readFileSync(SLOG, 'utf8')) : {};
  1855. const sj = sessionOf(o) && existsSync(join(RUNS, sessionOf(o), 'session.json')) ? JSON.parse(readFileSync(join(RUNS, sessionOf(o), 'session.json'), 'utf8')) : {};
  1856. check('sandbox ON: work runs the worker AND the controller inside the box → WORK DONE, report posted', /^WORK DONE — dummy#\d+ report posted/m.test(o) && existsSync(SCLOG), o);
  1857. check('sandbox ON: the worker ran in the dev folder, saw no tickets token, deploy = false in session.json', fl.cwd === DUMMY && fl.tokenFileVisible === false && sj.deploy === false, J({ cwd: fl.cwd, tok: fl.tokenFileVisible, deploy: sj.deploy }));
  1858. }
  1859. // mission 030: fake claude only — the guard on PATH was never called (any REAL claude call would have been logged + refused)
  1860. if (!REAL) check('m030 isolation: the REAL claude was never called (the PATH guard logged nothing)', !existsSync(GUARD_LOG), existsSync(GUARD_LOG) ? readFileSync(GUARD_LOG, 'utf8') : '');
  1861. } catch (e) {
  1862. check('e2e ran to the end', false, e.stack || String(e));
  1863. } finally {
  1864. await cleanup();
  1865. }
  1866. console.log(`\n${passes} passed, ${failures} failed (tickets code: ${TICKETS_DIR}; ports ${IDENT_PORT}/${TICKETS_PORT}; logs ${WORK}/tickets.log, ident.log)`);
  1867. process.exit(failures ? 1 : 0);

Branches

Latest commits

  • 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