antcolony
All repositories: gitoria
22.9 KB
// controller.hl — the controller step (ticket antcolony#1, mission 019; concept docs/scheduler-agent.md// §1 "controller = one-shot LLM session that checks a report against what happened" and §2 "Report// handling": validate → controller session → post or send back).//// runController: a SECOND one-shot Claude Code session after `work` got a schema-valid report, BEFORE// anything is posted; same safety settings as the worker (permission mode, never bypass,// `--permission-prompts none`, `--strict-mcp-config`, no token) + Edit/Write/NotebookEdit disallowed;// in the same dev folder; stdin = templates/controller.md + the brief + the report + the worker's// session as a compact transcript (from claude-result.json). `--json-schema`// templates/verdict.schema.json → { ticket, session, verdict: pass|fail, findings[], summary }.// Files: runs/<session>/controller-prompt.md, controller-result.json, controller-stderr.log,// verdict.json; session.json gets `controller` (cost, usage, verdict).// The CODE decides: a `pass` with a `false` / `no evidence` finding counts as `fail` ("overruled").// postByVerdict: pass → `report --post`, the comment's machine line ends with `· controller pass` (mission 023:// summary + findings stay in verdict.json); fail → a SHORT comment (what happened, ≤ 3 findings, the report's// issues NOT filed), machine line `colony-verdict: <session> · fail · k of N`, ticket → `open`; the Nth fail on// the same ticket (N = --max-fails / COLONY_MAX_FAILS, default 2; counted from the fail markers in// the ticket's comments, one per session) → a creator question (inbox) instead, ticket → `on hold`.// Idempotent: a session's fail comment is written once.import { readFile, writeFile, exists } from 'hl:fs'import { env } from 'hl:proc'import { now, timestamp } from 'hl:time'import { loadRegistry } from './registry.hl'import { getJson, postJson, readToken, ticketPath, pageUrl, fileQuestion } from './tickets.hl'import { postReport, parseRef, oneLine, refLabel, deployGate } from './report.hl'import { runClaude, readClaude, usageLine, usageMeta, uuid, money, rateLimitOf, portEnv } from './claude.hl'import { NL, readJson, isList, isObject, isString, nonEmpty, fenceFor } from './util.hl'static VERDICT_KEYS = ['ticket' 'session' 'verdict' 'findings' 'summary' 'questions']static REQUIRED_KEYS = ['ticket' 'session' 'verdict' 'findings' 'summary']// antcolony#24: the check's ruling on each question of the reportstatic QUESTION_KEYS = ['index' 'ruling' 'reason' 'answer']static RULINGS = ['ask' 'decided' 'trivial' 'internal' 'packed']static FINDING_KEYS = ['claim' 'about' 'status' 'check']static ABOUT = ['verified' 'done' 'other']static STATUS = ['ok' 'false' 'no evidence' 'not checked']static BAD = ['false' 'no evidence']static TRANSCRIPT_MAX = 80000static clip = (s, n) => {if (s.length <= n) { return s }return s.slice(0, n) + NL + '…[' + (s.length - n) + ' more characters]'}static fenced = (o, text) => {let f = fenceFor(text)o.push(f)o.push(text)o.push(f)}// the worker's session (claude-result.json with verbose output = the message list) as compact// Markdown: its text, every tool call and every result, each clippedstatic transcriptOf = (value) => {if (!isList(value)) { return '_(the worker output is not a message list — no transcript; only the final result was kept)_' }let o = []let n = 0for (m of value) {if (isObject(m) && (m.type == 'assistant' || m.type == 'user') && m.message != null && isList(m.message.content)) {for (c of m.message.content) {if (isObject(c)) {if (c.type == 'text' && nonEmpty(c.text)) {n = n + 1o.push('#### ' + n + ' · ' + m.type + ' text')fenced(o, clip(c.text, 1500))} else if (c.type == 'tool_use') {n = n + 1o.push('#### ' + n + ' · tool call `' + c.name + '`')fenced(o, clip(JSON.stringify(c.input), 2000))} else if (c.type == 'tool_result') {n = n + 1let t = isString(c.content) ? c.content : JSON.stringify(c.content)o.push('#### ' + n + ' · tool result' + (c.is_error == true ? ' (ERROR)' : ''))fenced(o, clip(t, 2000))}}}} else if (isObject(m) && m.type == 'result') {n = n + 1o.push('#### ' + n + ' · end: ' + m.subtype + ', ' + m.num_turns + ' turns' + (m.permission_denials != null && m.permission_denials.length > 0 ? ', ' + m.permission_denials.length + ' permission denial(s): ' + JSON.stringify(m.permission_denials) : ''))}}if (n == 0) { return '_(no messages in the worker output)_' }let t = o.join(NL)// mission 025: a long session keeps its START and its END (the end is where the final checks are — the first live// worker's session was 1.3 MB); before, only the first TRANSCRIPT_MAX characters were keptif (t.length > TRANSCRIPT_MAX) {let head = 30000let tail = TRANSCRIPT_MAX - headt = t.slice(0, head) + NL + NL + '…[' + (t.length - head - tail) + ' characters of the transcript left out here — the full session is in the run files (claude-result*.json)]' + NL + NL + t.slice(t.length - tail)}return t}static fill = (text, vars) => {let s = textfor (k of vars.keys()) { s = s.replaceAll('{{' + k + '}}', vars[k]) }return s}// the controller's whole input (stdin) → textstatic promptOf = (c) => {let o = []o.push(fill(readFile('./templates/controller.md').trim(), { TICKET = c.ref SESSION = c.session FOLDER = c.folder RUNDIR = c.dir PORTS = c.ports }))o.push('')// mission 025: e.g. `salvage` — re-run nothing that writesif (c.note != null && c.note != '') {o.push('**' + c.note + '**')o.push('')}o.push('## The worker report (report.json)')o.push('')fenced(o, readFile(c.dir + '/report.json'))o.push('')o.push("## The worker's session (compact transcript of claude-result.json)")o.push('')// a session that was parked and resumed (mission 020): the parts before each park firstlet k = 1while (exists(c.dir + '/claude-result.parked-' + k + '.json')) {let pj = readJson(c.dir + '/claude-result.parked-' + k + '.json')o.push('### Part ' + k + ' — until the session was parked')o.push('')o.push(pj.error != null ? '_(' + pj.error + ')_' : transcriptOf(pj.value))o.push('')o.push('### Part ' + (k + 1) + ' — resumed')o.push('')k = k + 1}// mission 025: a worker that ran out of steps / time, then the finalize step that only wrote the reportif (exists(c.dir + '/claude-result.before-finalize.json')) {let bj = readJson(c.dir + '/claude-result.before-finalize.json')o.push('### Part ' + k + ' — the worker, until it ran out of steps / time (claude-result.before-finalize.json)')o.push('')o.push(bj.error != null ? '_(' + bj.error + ')_' : transcriptOf(bj.value))o.push('')o.push('### Part ' + (k + 1) + ' — the finalize step: the same session resumed ONLY to write the report (it was told to change nothing)')o.push('')}let rj = exists(c.dir + '/claude-result.json') ? readJson(c.dir + '/claude-result.json') : { error = 'no claude-result.json' }o.push(rj.error != null ? '_(' + rj.error + ')_' : transcriptOf(rj.value))o.push('')o.push('## The brief the worker got (brief.md)')o.push('')fenced(o, readFile(c.dir + '/brief.md'))o.push('')o.push('(End of input. Check, then answer with the JSON verdict.)')return o.join(NL)}// the verdict (the code is the authority, not the schema) → problemsstatic validateVerdict = (v, ref, session, nq) => {let p = []if (!isObject(v)) { return ['the verdict must be one JSON object'] }for (k of v.keys()) { if (!VERDICT_KEYS.includes(k)) { p.push("(top): unknown field '" + k + "'") } }for (k of REQUIRED_KEYS) { if (v[k] == null) { p.push("(top): field '" + k + "' is missing") } }// antcolony#24: one ruling per question of the report (an old verdict.json without `questions` is fine when the report has none)let qr = v.questions == null ? [] : v.questionsif (!isList(qr)) { p.push('questions: must be a list') } else {let seen = []let qi = 0while (qi < qr.length) {let q = qr[qi]let at = 'questions[' + qi + ']'if (!isObject(q)) { p.push(at + ': must be an object') } else {for (k of q.keys()) { if (!QUESTION_KEYS.includes(k)) { p.push(at + ": unknown field '" + k + "'") } }if (hlTypeName(q.index) != 'Number' || q.index < 0 || (nq != null && q.index >= nq)) { p.push(at + ": 'index' must be the number of a question of the report (0-based)") } else { seen.push(q.index) }if (!RULINGS.includes(q.ruling)) { p.push(at + ": 'ruling' must be one of " + RULINGS.join(', ')) }if (!nonEmpty(q.reason)) { p.push(at + ": 'reason' must be a non-empty string") }if ((q.ruling == 'decided' || q.ruling == 'trivial' || q.ruling == 'internal') && !nonEmpty(q.answer)) { p.push(at + ": 'answer' (the decision, one line) is needed when the question is dropped") }}qi = qi + 1}if (nq != null) {let n = 0while (n < nq) { if (!seen.includes(n)) { p.push('questions: no ruling for question ' + n + ' of the report') } n = n + 1 }}}if (v.ticket != null && v.ticket != ref) { p.push('ticket: "' + v.ticket + '" but the report is for ' + ref) }if (v.session != null && v.session != session) { p.push('session: "' + v.session + '" but the report is session ' + session) }if (v.verdict != null && v.verdict != 'pass' && v.verdict != 'fail') { p.push('verdict: must be pass or fail, not ' + JSON.stringify(v.verdict)) }if (v.summary != null && !nonEmpty(v.summary)) { p.push('summary: must be a non-empty string') }if (v.findings != null) {if (!isList(v.findings)) { p.push('findings: must be a list') } else {let i = 0while (i < v.findings.length) {let f = v.findings[i]let at = 'findings[' + i + ']'if (!isObject(f)) { p.push(at + ': must be an object') } else {for (k of f.keys()) { if (!FINDING_KEYS.includes(k)) { p.push(at + ": unknown field '" + k + "'") } }for (k of ['claim' 'check']) { if (!nonEmpty(f[k])) { p.push(at + ": '" + k + "' must be a non-empty string") } }if (!ABOUT.includes(f.about)) { p.push(at + ": 'about' must be one of " + ABOUT.join(', ')) }if (!STATUS.includes(f.status)) { p.push(at + ": 'status' must be one of " + STATUS.join(', ')) }}i = i + 1}}}return p}// how many questions the worker's report has (null when unreadable)static questionCount = (dir) => {let rj = readJson(dir + '/report.json')if (rj.error != null || !isObject(rj.value) || !isList(rj.value.questions)) { return null }return rj.value.questions.length}static packedOf = (v) => {let o = []if (v.questions != null) { for (q of v.questions) { if (q.ruling == 'packed') { o.push(q) } } }return o}// antcolony#24: the report as it is posted — only the questions the check ruled `ask` become tickets; a dropped one// (already decided / trivial / internal) becomes a `decided` line with the check's answerstatic filterQuestions = (r, v) => {if (v == null || v.questions == null || v.questions.length == 0) { return r }let rulings = {}for (q of v.questions) { rulings['' + q.index] = q }let keep = []let decided = []if (r.decided != null) { for (d of r.decided) { decided.push(d) } }let i = 0while (i < r.questions.length) {let q = rulings['' + i]if (q == null || q.ruling == 'ask') { keep.push(r.questions[i]) }else if (q.ruling != 'packed') { decided.push(oneLine(q.answer, 120)) }i = i + 1}r.questions = keepr.decided = decidedreturn r}// the effective verdict: a pass with a false / no-evidence finding is a fail → { verdict, overruled, bad }static decide = (v) => {let bad = []for (f of v.findings) { if (BAD.includes(f.status)) { bad.push(f) } }// antcolony#24: a packed question (more than one decision) sends the work back to be splitif (v.verdict == 'pass' && packedOf(v).length > 0) {for (q of packedOf(v)) { bad.push({ claim = 'question ' + q.index + ' is packed: ' + q.reason about = 'other' status = 'false' check = q.reason }) }return { verdict = 'fail' overruled = true bad = bad }}if (v.verdict == 'pass' && bad.length > 0) { return { verdict = 'fail' overruled = true bad = bad } }return { verdict = v.verdict overruled = false bad = bad }}// runs the controller on a run folder. c = { dir, folder, ref, session, host, ports, bin, model,// maxTurns, mode, timeout, budget }; meta = the session.json object (gets `controller`, written back).// Calls done({ verdict (the JSON) | null, effective, overruled, reason, costUsd }) after the exit.static runController = (c, meta, done) => {let promptFile = c.dir + '/controller-prompt.md'let outFile = c.dir + '/controller-result.json'let errFile = c.dir + '/controller-stderr.log'let verdictFile = c.dir + '/verdict.json'writeFile(promptFile, promptOf(c), 384)let cs = uuid()let cm = { model = c.model maxTurns = c.maxTurns permissionMode = c.mode timeoutSeconds = c.timeout claude = c.bin claudeSession = cs started = timestamp(now()) outcome = 'running' }meta.controller = cmwriteFile(c.dir + '/session.json', JSON.stringify(meta), 384)let args = ['-p' '--output-format' 'json' '--json-schema' readFile('./templates/verdict.schema.json') '--model' c.model '--max-turns' c.maxTurns '--permission-mode' c.mode '--permission-prompts' 'none' '--strict-mcp-config' '--disallowedTools' 'Edit,Write,NotebookEdit' '--session-id' cs]if (c.budget != null && c.budget != '') {args.push('--max-budget-usd')args.push(c.budget)}let p = runClaude({ label = 'controller' folder = c.folder inFile = promptFile outFile = outFile errFile = errFile bin = c.bin args = args timeout = c.timeout env = portEnv(c.ports) session = c.session ports = c.ports sandbox = c.sandbox }, (x) => {cm.ended = timestamp(now())cm.exit = x.ev.exit// mission 026: what the controller left listening on its ports was stopped (lib/cleanup.sh) — logged hereif (x.cleanup.length > 0) { cm.cleanup = x.cleanup }for (l of x.lines) { console.log(' controller ' + l) }let rc = readClaude(outFile, x.ev.exit, x.timedOut, c.timeout, 'controller')let reason = rc.reasonlet rl = rateLimitOf(rc.value, rc.res)let v = nullif (rc.cr != null) {cm.permissionModeSeen = rc.cr.modeif (rc.cr.mode != null && rc.cr.mode != c.mode) { console.log('controller: WARNING — asked for permission mode "' + c.mode + '", Claude reported "' + rc.cr.mode + '" (' + rc.cr.modes.join(', ') + ')') }}if (rc.res != null) {let um = usageMeta(rc.res)for (k of um.keys()) { cm[k] = um[k] }console.log(usageLine('CONTROLLER USAGE', rc.res))if (rc.res.structured_output == null) {if (reason == null) { reason = 'no structured verdict (Claude: ' + rc.res.subtype + (rc.res.is_error ? ', error' : '') + ', ' + rc.res.num_turns + ' turns, exit ' + x.ev.exit + ')' }} else {v = rc.res.structured_outputwriteFile(verdictFile, JSON.stringify(v), 384)}}let eff = nullif (reason == null) {let problems = validateVerdict(v, c.ref, c.session, questionCount(c.dir))if (problems.length > 0) {reason = 'the verdict was refused (' + problems.join('; ') + ')'v = null} else {eff = decide(v)}}if (reason != null) {cm.outcome = 'no verdict: ' + reasonmeta.controller = cmwriteFile(c.dir + '/session.json', JSON.stringify(meta), 384)console.log('controller: NO VERDICT — ' + reason)done({ verdict = null reason = reason costUsd = cm.costUsd limited = rl.limited resetMs = rl.resetMs limitText = rl.text })return null}cm.verdict = v.verdictcm.effective = eff.verdictcm.overruled = eff.overruledcm.outcome = 'verdict ' + eff.verdictmeta.controller = cmwriteFile(c.dir + '/session.json', JSON.stringify(meta), 384)printVerdict(v, eff)done({ verdict = v effective = eff.verdict overruled = eff.overruled reason = null costUsd = cm.costUsd limited = false })return null})console.log('controller: started — ' + c.bin + ' -p --model ' + c.model + ' --max-turns ' + c.maxTurns + ' --permission-mode ' + c.mode + ' (Edit/Write off) in ' + c.folder + ' · pid ' + p.pid + ' · claude session ' + cs)return p}static printVerdict = (v, eff) => {console.log('VERDICT: ' + eff.verdict.toUpperCase() + (eff.overruled ? ' (the controller said pass, but ' + eff.bad.length + ' finding(s) are false / no evidence → fail)' : '') + ' — ' + v.findings.length + ' finding(s)')for (f of v.findings) { console.log(' ' + f.status + ' — ' + f.about + ': ' + f.claim) }}// a verdict loaded from verdict.json (`check` on a run that has one) → the done() shape, or { reason }static loadVerdict = (dir, ref, session) => {let rj = readJson(dir + '/verdict.json')if (rj.error != null) { return { verdict = null reason = rj.error } }let problems = validateVerdict(rj.value, ref, session, questionCount(dir))if (problems.length > 0) { return { verdict = null reason = 'verdict.json refused: ' + problems.join('; ') } }let eff = decide(rj.value)printVerdict(rj.value, eff)return { verdict = rj.value effective = eff.verdict overruled = eff.overruled reason = null }}// ---- posting by the verdict ---------------------------------------------------------------------// pass → the report comment's machine line ends with this (mission 023: the controller's summary + findings stay in// runs/<session>/verdict.json — the creator reads the result, not the check)static passNote = () => { return 'controller pass' }// 1 → 'once', n → 'n times'static times = (n) => { return n == 1 ? 'once' : n + ' times' }// the fail markers in a ticket's comments → { sessions (distinct, in order), mine }static failSessions = (events, session) => {let out = []let mine = falsefor (e of events) {if (e.kind == 'comment' && e.text != null) {let i = e.text.indexOf('colony-verdict: ')if (i >= 0) {let rest = e.text.slice(i + 16)let j = rest.indexOf(' · fail')if (j > 0) {let s = rest.slice(0, j)if (s == session) { mine = true }if (!out.includes(s)) { out.push(s) }}}}}return { sessions = out mine = mine }}static maxFailsOf = (text) => {let n = toNumber(text)if (n == null || n < 1) { return 2 }return n}// → { ok, outcome: 'posted' | 'ready to deploy' (mission 027) | 'half done' | 'sent back' | 'question' | 'nothing new' | 'post failed' | 'post refused', question }// (mission 026: a passed report with `complete: false` → 'half done': short comment, ticket back to open)static postByVerdict = (r, cv, meta, maxFails) => {let reg = loadRegistry()if (cv.effective == 'pass') {let ok = postReport(filterQuestions(r, cv.verdict), reg, passNote())// mission 027: a project with a live site → 'ready to deploy' (built; `./colony deployed` after the deploy)return { ok = ok outcome = !ok ? 'post failed' : r.complete == false ? 'half done' : deployGate(r, reg) ? 'ready to deploy' : 'posted' }}return postFail(r, cv, meta, maxFails)}static postFail = (r, cv, meta, maxFails) => {let v = cv.verdictlet tk = readToken()if (tk.error != null) {console.log('POST REFUSED — ' + tk.error)return { ok = false outcome = 'post refused' }}let token = tk.tokenlet ref = parseRef(r.ticket)let got = getJson(ticketPath(ref.project, ref.number))if (got.status != 200) {console.log('POST REFUSED — ticket ' + r.ticket + ' → ' + got.status)return { ok = false outcome = 'post refused' }}let ticket = got.json.ticketlet url = pageUrl(ticket)let fs = failSessions(got.json.events, r.session)if (fs.mine) {console.log(' HAVE the controller fail comment of session ' + r.session + ' on ' + r.ticket + ' — nothing written')console.log('POSTED — nothing new (fail already filed)')return { ok = true outcome = 'nothing new' }}let n = fs.sessions.length + 1let all = []for (s of fs.sessions) { all.push(s) }all.push(r.session)let question = nullif (n >= maxFails) {// mission 023: a creator question like every other — subject "Question (<ref>): …", ≤ 5 short lineslet qs = []qs.push('[' + refLabel(r.ticket) + '](' + url + ') failed its check ' + times(n) + ', so no worker starts on it any more (it is on hold).')qs.push('')qs.push('**What to do:** say in a comment there what you want, then set it back to open — or reject it.')qs.push('')qs.push('- Last check: ' + oneLine(v.summary, 150))let q = fileQuestion(token, 'colony-verdict:' + r.session + ':question', 'Question (' + r.ticket + '): the work failed its check ' + times(n) + ' — how should it go on?', qs.join(NL), r.ticket)if (q.error != null) {console.log('POST FAILED — creator question: ' + q.error + ' — re-run to continue (idempotent)')return { ok = false outcome = 'post failed' }}question = q.ticketconsole.log((q.created ? ' FILED ' : ' HAVE ') + 'creator question ' + question.project + question.ref + ' ' + pageUrl(question))}// mission 023: the fail comment is short — what happened, what comes next, ≤ 3 findings; the checked report,// all findings and the controller's summary stay in runs/<session>/ (verdict.json, report.json)let o = []if (question == null) { o.push((cv.overruled ? 'The result could not be confirmed' : 'The result did not hold up') + ' when it was checked — back to open for another try.') }else { o.push('The result failed its check ' + times(n) + ' — on hold until you answer [' + question.project + ' ' + question.ref + '](' + pageUrl(question) + ').') }let bad = []for (f of v.findings) { if (f.status == 'false' || f.status == 'no evidence') { bad.push(f) } }if (bad.length == 0) { bad = v.findings }if (bad.length > 0) {o.push('')let k = 0while (k < bad.length && k < 3) {o.push('- ' + (bad[k].status == 'no evidence' ? 'Not shown: ' : bad[k].status == 'false' ? 'Not true: ' : '') + oneLine(bad[k].claim, 90))k = k + 1}}o.push('')o.push('colony-verdict: ' + r.session + ' · fail · ' + n + ' of ' + maxFails + (cv.overruled ? ' · overruled' : ''))let c = postJson(ticketPath(ref.project, ref.number) + '/comments', { text = o.join(NL) }, token)if (c.status != 201) {console.log('POST FAILED — comment on ' + r.ticket + ': ' + c.status + ' ' + c.text)return { ok = false outcome = 'post failed' }}console.log(' POSTED the controller fail comment on ' + r.ticket + ' ' + url)let st = c.json.ticket.statelet want = question == null ? 'open' : 'on hold'if (st == want || st == 'confirmed' || st == 'rejected') {console.log(' STATE left as "' + st + '"')} else {let text = question == null ? 'Back to open: the check failed (' + n + ' of ' + maxFails + ' tries).' : 'On hold: the check failed ' + times(n) + ' — waiting for your answer.'let s = postJson(ticketPath(ref.project, ref.number) + '/state', { state = want text = text }, token)if (s.status != 201) {console.log('POST FAILED — state of ' + r.ticket + ': ' + s.status + ' ' + s.text)return { ok = false outcome = 'post failed' }}console.log(' STATE ' + r.ticket + ': ' + st + ' → ' + want)}console.log('POSTED')return { ok = true outcome = question == null ? 'sent back' : 'question' question = question fails = n }}
Branches
- mainmain branch
Latest commits
- 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
- a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
- c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
- 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
- 7f9660eeState of 2026-09-27, before the move to gitoriamre