antcolony
All repositories: gitoria
55.3 KB
// work.hl — `work <project> <n>`: agent v0 (ticket antcolony#2, mission 016, concept// docs/scheduler-agent.md §3 + build order 3) + the controller step (mission 019, build order 4).// Run BY HAND (or by `cycle`) on the host that holds the project's dev folder; ONE worker, no daemon.//// 1. refuse unless: the project takes workers, has a concept, the ticket is `open` (or `rejected` with a// rejection newer than the last colony session — mission 022) and neither blocked nor a parent, the dev folder is// on THIS host and exists, a token is there (COLONY_TOKEN_FILE) — nothing is written before that;// `--dry-run` stops here (prints what would run; no write, no Claude session);// 2. the brief (`brief`, same text) → runs/<session>/brief.md;// 3. the LEASE: ticket → `in progress` with the text "A worker started on this ticket." + the colony-lease line;// 4. Claude Code headless in the dev folder: `claude -p` (brief on stdin) `--output-format json`// `--json-schema` templates/report.schema.json, model / max turns / permission mode configurable// (never bypassPermissions), `--permission-prompts none` (whatever would ask is denied),// `--strict-mcp-config` (no MCP servers), a fixed Claude `--session-id` (recorded before the start);// 5. on exit: raw result → runs/<session>/claude-result.json, the structured output →// runs/<session>/report.json, `report` validation (+ ticket/session must be this session's);// 6. no valid report (no structured output, max turns, timeout, crash, refused by validation) →// ticket back to `open` with the reason (the lease is given back);// 7. a valid report → the CONTROLLER (controller.hl) → with --post: posted by the verdict (pass →// report comment + awaiting creator; fail → findings + open; Nth fail → creator question + on hold).// No verdict (controller crashed / timed out / refused) → nothing posted, ticket stays leased,// `./colony check runs/<session> --post` retries.// Mission 020: the lease carries an expiry (lease.hl) and a heartbeat renews it while the session runs;// a session that ends on the Claude usage limit — or is stopped by `run`'s second SIGTERM — is PARKED// (ticket stays leased, `colony-parked:` marker) and `runResume` continues it later with `claude --resume`// (worker) or a new controller session; `hooks` = { onProc(process, phase), parkReason() } let `run` see// and stop the running process; the port range goes to the session as env COLONY_PORTS / _FROM / _TO.import { env } from 'hl:proc'import { readFile, writeFile, mkDir, exists, remove } from 'hl:fs'import { now, timestamp } from 'hl:time'import { buildBrief, portsOf, PORT_FROM_MARKER, PORT_TO_MARKER } from './brief.hl'import { reportFile, parseRef } from './report.hl'import { readToken, postJson, getJson, ticketPath, pageUrl, baseUrl } from './tickets.hl'import { sandboxFor, deployMarked } from './sandbox.hl'import { creatorNames } from './librarian.hl'import { runClaude, readClaude, usageLine, usageMeta, setting, isWholeNumber, uuid, round4, MODES, rateLimitOf, lastRateInfo, portEnv } from './claude.hl'import { leaseSettings, leaseLine, parkLine, startHeartbeat, postLease, clock } from './lease.hl'import { runController, postByVerdict, loadVerdict, maxFailsOf } from './controller.hl'import { NL, fromCwd, positionals, readJson, option } from './util.hl'import { loadRegistry } from './registry.hl'import { blockReason, questionWhy } from './relations.hl'static hostName = () => {let h = env('COLONY_HOST')if (h == null || h == '') { h = exists('/etc/hostname') ? readFile('/etc/hostname').trim() : '' }return h}static runsDir = (home) => {let runsText = env('COLONY_RUNS_DIR')return runsText == null || runsText == '' ? home + '/runs' : fromCwd(runsText)}// no daemon around: nothing watches the process, nothing asks to parkstatic noHooks = () => {return { onProc = (p, phase) => { return null } parkReason = () => { return null } }}// costs of a session over all its parts (a parked + resumed session has several)static workerCostOf = (meta) => {let a = meta.costEarlier == null ? 0 : meta.costEarlierif (meta.costUsd == null) { return meta.costEarlier }return a + meta.costUsd}static controllerCostOf = (meta) => {let a = meta.controllerCostEarlierlet c = meta.controller == null ? null : meta.controller.costUsdif (c == null) { return a }if (a == null) { return c }return a + c}// antcolony#18: the box a session of this run lives in (sandbox.hl). No box possible → a command that fails at once// (never a session with host access because the box could not be built).static boxOf = (meta, mayDeploy, more) => {let r = sandboxFor({ project = meta.project folder = meta.folder host = meta.host deploy = mayDeploy extraRo = more })if (r.error != null) {console.log('work: SANDBOX — ' + r.error + ' — the session cannot start')return ['/usr/bin/false' '--']}return r.args}// `box <project> [--deploy] [--folder F]` (antcolony#18): the argv prefix of the sandbox a session of that project runs in,// one argument per line (a test / the architect runs `<prefix> cmd …` to see what a worker sees). --folder = a dev folder// other than the registry's.static runBox = (argv) => {let pos = positionals(argv)if (pos.length != 2) {console.log('usage: box <project> [--deploy] [--folder F]')return false}let reg = loadRegistry()let m = reg.projects[pos[1]]let folder = option(argv, '--folder')if (folder == null || folder == '') { folder = m == null || m.dev == null ? null : m.dev.folder }if (folder == null) {console.log('box: REFUSED — project ' + pos[1] + ' has no dev folder')return false}let r = sandboxFor({ project = pos[1] folder = folder host = hostName() deploy = argv.includes('--deploy') extraRo = null })if (r.error != null) {console.log('box: REFUSED — ' + r.error)return false}for (x of r.args) { console.log(x) }return true}// PARK a session (mission 020): the ticket stays leased (`in progress`), a comment with the reason and the// `colony-parked:` marker (resume time, lease expiry = resume + lease ttl); `run` on this host resumes it// after `resumeMs` once the quota is below the reserve. phase = worker (→ claude --resume) | controller// (→ a new controller session on the kept report). Calls done({ outcome: 'parked', … }).static parkRun = (meta, phase, reason, resumeMs, resetKnown, done) => {let prev = meta.parked == null ? 0 : meta.parked.countif (phase == 'worker') {meta.costEarlier = workerCostOf(meta)meta.costUsd = null} else if (meta.controller != null && meta.controller.costUsd != null) {meta.controllerCostEarlier = controllerCostOf(meta)meta.controller.costUsd = null}let count = prev + 1meta.parked = { phase = phase reason = reason resumeMs = resumeMs resumeAfter = timestamp(resumeMs) count = count at = timestamp(now()) }meta.outcome = 'parked (' + phase + ')'writeFile(meta.runDir + '/session.json', JSON.stringify(meta), 384)let ttl = meta.leaseTtl == null ? 3600 : meta.leaseTtllet exp = resumeMs + ttl * 1000// mission 023: one plain line for the creator + the machine line (the Claude session id is in session.json)let why = reason.startsWith('Claude usage limit') ? 'the Claude usage limit is reached' : 'the scheduler was stopped'let text = 'Paused: ' + why + ' — work continues after ' + clock(resumeMs) + '.' + NL + NL + parkLine(meta.session, meta.host, phase, resumeMs, exp)let ref = parseRef(meta.ticket)let tk = readToken()let posted = 'no token: ' + tk.errorif (tk.error == null) {let r = postLease(ref.project, ref.number, text, tk.token)posted = r.ok ? 'marker posted' : 'POSTING THE MARKER FAILED: ' + r.text}console.log('PARKED — ' + meta.ticket + ' session ' + meta.session + ' (' + phase + ') until ' + timestamp(resumeMs) + ': ' + reason + ' · ' + posted + ' · files ' + meta.runDir + '/')// mission 034: `run` pauses the librarian + starts until the reset when the park was the usage limitlet lim = reason.startsWith('Claude usage limit')done({ outcome = 'parked' session = meta.session workerCost = workerCostOf(meta) controllerCost = controllerCostOf(meta) limited = lim resetMs = lim && resetKnown ? resumeMs : null limitText = lim ? reason : null })return null}// the controller's settings (options / env / defaults; the worker's permission mode and claude binary)static controllerSettings = (argv) => {let claudeBin = setting(argv, '--claude', 'COLONY_CLAUDE', 'claude')return {bin = setting(argv, '--controller-claude', 'COLONY_CONTROLLER_CLAUDE', claudeBin)model = setting(argv, '--controller-model', 'COLONY_CONTROLLER_MODEL', 'sonnet')maxTurns = setting(argv, '--controller-max-turns', 'COLONY_CONTROLLER_MAX_TURNS', '20')timeout = setting(argv, '--controller-timeout', 'COLONY_CONTROLLER_TIMEOUT', '900')mode = setting(argv, '--permission-mode', 'COLONY_PERMISSION_MODE', 'auto')budget = setting(argv, '--max-budget-usd', 'COLONY_MAX_BUDGET_USD', '')maxFails = maxFailsOf(setting(argv, '--max-fails', 'COLONY_MAX_FAILS', '2'))}}// refusals shared by work and check → text or nullstatic controllerRefusal = (cs) => {if (!MODES.includes(cs.mode)) { return 'permission mode "' + cs.mode + '" is not allowed (allowed: ' + MODES.join(', ') + '; never bypassPermissions)' }if (!isWholeNumber(cs.maxTurns) || cs.maxTurns == '0') { return '--controller-max-turns must be a whole number ≥ 1, got ' + cs.maxTurns }if (!isWholeNumber(cs.timeout) || cs.timeout == '0') { return '--controller-timeout must be whole seconds ≥ 1, got ' + cs.timeout }return null}// 7. the controller's answer → post by it (or not) → the last line; calls done(result)// x = { ref, dir, post, meta, report, cs, label ('WORK' / 'CHECK') }static afterVerdict = (x, cv, done) => {let meta = x.metalet pr = x.hooks == null ? null : x.hooks.parkReason()if (pr != null) {parkRun(meta, 'controller', pr, now(), false, done)return null}if (cv.limited == true) {parkRun(meta, 'controller', 'Claude usage limit hit by the controller (' + cv.limitText + ')', cv.resetMs == null ? now() + 3600000 : cv.resetMs, cv.resetMs != null, done)return null}let res = { outcome = null session = meta.session workerCost = workerCostOf(meta) controllerCost = controllerCostOf(meta) }if (cv.verdict == null) {meta.outcome = 'valid report, no verdict: ' + cv.reasonwriteFile(x.dir + '/session.json', JSON.stringify(meta), 384)console.log(x.label + ' OK, CONTROLLER FAILED — ' + cv.reason + '; nothing posted, ' + x.ref + ' stays "in progress" (leased). Retry: ./colony check ' + x.dir + (x.post ? ' --post' : ''))res.outcome = 'no verdict'done(res)return null}if (!x.post) {meta.outcome = 'valid report, verdict ' + cv.effective + ', not posted'writeFile(x.dir + '/session.json', JSON.stringify(meta), 384)console.log(x.label + ' OK — valid report, controller: ' + cv.effective + ', NOT posted (no --post); ' + x.ref + ' stays "in progress" (leased). Post it by the verdict: ./colony check ' + x.dir + ' --post')res.outcome = 'verdict ' + cv.effective + ', not posted'done(res)return null}let p = postByVerdict(x.report, cv, meta, x.cs.maxFails)meta.outcome = p.outcomewriteFile(x.dir + '/session.json', JSON.stringify(meta), 384)res.outcome = p.outcomeif (!p.ok) {console.log(x.label + ' OK, POST FAILED — controller: ' + cv.effective + '; re-run ./colony check ' + x.dir + ' --post (idempotent). ' + x.ref + ' stays "in progress".')} else if (p.outcome == 'posted') {console.log(x.label + ' DONE — ' + x.ref + ' report posted (controller: pass) · files ' + x.dir + '/')} else if (p.outcome == 'ready to deploy') {// mission 027: the deploy gate — the creator tests on the live site, so not "awaiting creator" before the deploylet rr = parseRef(x.ref)console.log(x.label + ' DONE — ' + x.ref + ' built, waiting for the deploy (controller: pass): "Built — goes live with the next deploy.", stays in progress; after the deploy: ./colony deployed ' + rr.project + ' ' + rr.number + ' · files ' + x.dir + '/')} else if (p.outcome == 'half done') {console.log(x.label + ' HALF DONE — ' + x.ref + ': the report says the work is not complete → "Half done" comment, back to open, the next worker continues (controller: pass) · files ' + x.dir + '/')} else if (p.outcome == 'sent back') {console.log(x.label + ' FAILED — controller: fail (' + p.fails + ' of ' + x.cs.maxFails + ') → ' + x.ref + ' back to open with the findings · files ' + x.dir + '/')} else if (p.outcome == 'question') {console.log(x.label + ' FAILED — controller: fail (' + p.fails + ' of ' + x.cs.maxFails + ') → creator question ' + p.question.project + p.question.ref + ', ' + x.ref + ' on hold · files ' + x.dir + '/')} else {console.log(x.label + ' — controller: ' + cv.effective + ', ' + p.outcome + ' · files ' + x.dir + '/')}done(res)return null}// ---- mission 025: the FINALIZE step ------------------------------------------------------------------------------// A worker that ran out of steps (`error_max_turns`) or time (killed by --timeout) did work but wrote no report (first// live cycle, ident.worldapi.org#2: all gates green in its dev copy, then max turns before the copy-back + report →// $4.67 for nothing). Before the lease goes back, the SAME Claude session is resumed ONCE (`claude -p --resume <id>`,// same model / permission mode / --json-schema, a small --max-turns) with a short instruction: stop, start nothing new,// change nothing, write the report about what is done / verified / open.// Mission 026: NO `--disallowedTools` any more — it changes the tool list, i.e. the cached prompt prefix, so the first// real finalize re-sent the whole 207 k context without cache ($1.05). The "change nothing" instruction + auto mode stay.// A valid report → controller → post as usual; none → back to open as before (once). The cost is summed.// --finalize-max-turns (default 8; 0 = no finalize step) / --finalize-timeout (default 600 s, never more than the// worker's own --timeout) → { maxTurns, timeout, error }static finalizeSettings = (argv, workerTimeout) => {let maxTurns = setting(argv, '--finalize-max-turns', 'COLONY_FINALIZE_MAX_TURNS', '8')let wt = isWholeNumber(workerTimeout) ? toNumber(workerTimeout) : 600let deflt = wt < 600 ? '' + wt : '600'let timeout = setting(argv, '--finalize-timeout', 'COLONY_FINALIZE_TIMEOUT', deflt)let error = nullif (!isWholeNumber(maxTurns)) { error = '--finalize-max-turns must be a whole number (0 = no finalize step), got ' + maxTurns }else if (!isWholeNumber(timeout) || timeout == '0') { error = '--finalize-timeout must be whole seconds ≥ 1, got ' + timeout }return { maxTurns = maxTurns timeout = timeout error = error }}// why the worker stopped, when a finalize step can help → text or nullstatic finalizeWhy = (res, timedOut) => {if (timedOut) { return 'ran out of time' }if (res != null && res.subtype == 'error_max_turns') { return 'ran out of steps' }return null}// mission 027 ("validate → reject incomplete reports back to the worker (one retry)", concept §2): a report the validation// refused (e.g. a result over 120 characters, a test step on a work copy, 4 questions) gets the SAME Claude session resumed// once — like the finalize step, with the problems — to answer the corrected report; the work is not lost for a long linestatic REFUSED_WHY = 'had its report refused'static fixPrompt = (meta, maxTurns, problems) => {let o = []o.push('# FIX YOUR REPORT — the scheduler refused it')o.push('')o.push('Your report was refused by the validation (' + problems.length + ' problem' + (problems.length == 1 ? '' : 's') + '):')for (p of problems) { o.push('- ' + p) }o.push('')o.push('- **Do not continue the task.** Start nothing new, change no file (no Edit/Write, nothing through the shell). You have at most ' + maxTurns + ' steps — answer right away.')o.push('- Answer the SAME report again with only these problems fixed: shorten `result` (≤ 120 characters) / the `test` steps (2–4, ≤ 90 characters each, for the creator on the real / live site, never a work copy), ask at most 3 short questions (decide small things yourself and list them in `decided`), one line per `decided` entry. Everything else stays as it was.')o.push('')o.push('Answer with the JSON report exactly as the brief (the first message of this session) says, with')o.push(' "ticket": "' + meta.ticket + '",')o.push(' "session": "' + meta.session + '",')o.push('')o.push('colony-finalize: ' + meta.session)return o.join(NL)}// the finalize instruction (stdin of the resumed session) — kept as runs/<S>/finalize.mdstatic finalizePrompt = (meta, why, maxTurns) => {let o = []o.push('# STOP WORKING — write your report now')o.push('')o.push('Your session ' + why + ' (' + (why == 'ran out of steps' ? 'the step limit of ' + meta.maxTurns + ' was reached' : 'killed after ' + meta.timeoutSeconds + ' s') + ') before you wrote your report. It is resumed ONLY so that you write it — everything you did is lost for the creator unless it is in the report.')o.push('')o.push('- **Do not continue the task.** Start nothing new: no edits, no copying, no deleting, no servers, no tests, no git. Do not use the Edit/Write tools and do not change any file with the shell either.')o.push('- At most a few quick reads (`ls`, `cat`, `grep`) if you must check what exists. You have at most ' + maxTurns + ' steps — better answer right away.')o.push('- Report the truth about THIS session: `done` = what you really did and WHERE (e.g. only in a dev copy, not copied back); `verified` = only checks you really ran earlier in this session, with the command and the output you saw then; everything unfinished goes into `open` (e.g. "the changes are still only in the dev copy", "README/STATUS not updated"). `result` = one plain sentence saying what is done and what is not.')o.push('- `complete`: `true` only if the goal of the ticket is reached AND what the creator should test is really in place (copied into the app, not only a work copy); else `false` — then say in `open` what is missing and WHERE the unfinished work is (e.g. the folder of the work copy), so the next worker can continue.')o.push('- Anything you started that is still running goes into `running`.')o.push('')o.push('Answer with the JSON report exactly as the brief (the first message of this session) says, with')o.push(' "ticket": "' + meta.ticket + '",')o.push(' "session": "' + meta.session + '",')o.push('')o.push('colony-finalize: ' + meta.session)return o.join(NL)}// start the finalize step for a worker that ended without a report; `finish` runs again on its exit (w.finalized)// (`meta` = the caller's session object: Hybriel copies objects stored in fields, so w.meta may be stale — hybriel#38)static startFinalize = (w, meta, why, reason, problems) => {let dir = w.dirmeta.finalize = { why = why workerReason = reason workerSubtype = meta.subtype workerTurns = meta.numTurns workerCostUsd = meta.costUsd maxTurns = w.fs.maxTurns timeoutSeconds = w.fs.timeout started = timestamp(now()) outcome = 'running' }if (meta.claudeCostSeen == null) { meta.claudeCostSeen = meta.costUsd }meta.costEarlier = workerCostOf(meta)meta.costUsd = nullmeta.outcome = 'finalizing (the worker ' + why + ')'// the worker's own output stays beside the finalize output (the controller sees both parts)if (exists(w.outFile)) {writeFile(dir + '/claude-result.before-finalize.json', readFile(w.outFile), 384)remove(w.outFile)}if (exists(w.errFile)) {writeFile(dir + '/claude-stderr.before-finalize.log', readFile(w.errFile), 384)remove(w.errFile)}if (exists(w.reportPath)) { remove(w.reportPath) }writeFile(dir + '/session.json', JSON.stringify(meta), 384)let promptFile = dir + '/finalize.md'writeFile(promptFile, why == REFUSED_WHY ? fixPrompt(meta, w.fs.maxTurns, problems) : finalizePrompt(meta, why, w.fs.maxTurns), 384)let args = ['-p' '--resume' meta.claudeSession '--output-format' 'json' '--json-schema' readFile('./templates/report.schema.json') '--model' meta.model '--max-turns' w.fs.maxTurns '--permission-mode' meta.permissionMode '--permission-prompts' 'none' '--strict-mcp-config']if (meta.maxBudgetUsd != null && meta.maxBudgetUsd != '') {args.push('--max-budget-usd')args.push(meta.maxBudgetUsd)}w.finalized = truew.resumedPart = truew.firstReason = reasonw.firstProblems = problems == null ? [] : problemsw.firstTimedOut = why == 'ran out of time'w.timeoutText = w.fs.timeoutw.meta = metaconsole.log('work: FINALIZE — the worker ' + (why == REFUSED_WHY ? why + ' (' + reason + '); resuming claude session ' + meta.claudeSession + ' once to FIX it' : why + ' without a report (' + reason + '); resuming claude session ' + meta.claudeSession + ' once to write it') + ': --max-turns ' + w.fs.maxTurns + ', timeout ' + w.fs.timeout + ' s, same tools as the worker (prompt cache) · ' + promptFile)let boxed = boxOf(meta, meta.deploy == true, null)let p = runClaude({ label = 'finalize' folder = meta.folder inFile = promptFile outFile = w.outFile errFile = w.errFile bin = meta.claude args = args timeout = w.fs.timeout env = portEnv(meta.ports) session = meta.session ports = meta.ports sandbox = boxed }, (x) => {finish(w, x)return null})w.hooks.onProc(p, 'worker')return p}// 5./6. — after the worker's exit (or, mission 025, after the finalize step's exit: w.finalized)static finish = (w, x) => {let meta = w.metalet fin = w.finalized == truemeta.ended = timestamp(now())meta.exit = x.ev.exitfor (l of x.lines) { console.log((fin ? ' finalize ' : ' worker ') + l) }// mission 026: what this part left listening on its ports was stopped (claude.hl cleanupPorts) — kept in session.jsonif (x.cleanup.length > 0) {let cl = []if (meta.cleanup != null) { for (c of meta.cleanup) { cl.push(c) } }cl.push({ part = fin ? 'finalize' : 'worker' at = timestamp(now()) lines = x.cleanup })meta.cleanup = cl}let rc = readClaude(w.outFile, x.ev.exit, x.timedOut, w.timeoutText == null ? meta.timeoutSeconds : w.timeoutText, fin ? 'finalize step' : 'worker')let reason = rc.reasonif (rc.cr != null) {meta.permissionModeSeen = rc.cr.modemeta.permissionModes = rc.cr.modesif (rc.cr.mode != null && rc.cr.mode != w.mode) { console.log('work: WARNING — asked for permission mode "' + w.mode + '", Claude reported "' + rc.cr.mode + '" (' + rc.cr.modes.join(', ') + ') — e.g. auto is not available for every model') }}let res = rc.resif (res != null) {let um = usageMeta(res)for (k of um.keys()) { meta[k] = um[k] }console.log(usageLine(fin ? 'FINALIZE USAGE' : 'USAGE', res))// mission 025 (seen on the first real finalize, Claude Code 2.1.280): a RESUMED session reports the WHOLE session's// cost in total_cost_usd / modelUsage (cumulative over all its parts), not this part's → this part = the total now// minus the total seen after the previous part (meta.claudeCostSeen); a smaller total → taken as this part's ownlet total = res.total_cost_usdif (w.resumedPart == true && total != null && meta.claudeCostSeen != null) {let inc = round4(total - meta.claudeCostSeen)if (inc < 0) { inc = total }meta.costUsd = incmeta.claudeCostTotal = totalconsole.log((fin ? 'FINALIZE' : 'RESUME') + ' COST: $' + inc + ' for this part (Claude reports $' + total + ' for the whole session so far)')}if (total != null) { meta.claudeCostSeen = total }if (res.permission_denials != null && res.permission_denials.length > 0) { console.log('work: ' + res.permission_denials.length + ' permission denial(s) — see ' + w.dir + '/session.json') }if (res.structured_output == null) {if (reason == null) { reason = 'no structured report (Claude: ' + res.subtype + (res.is_error ? ', error' : '') + ', ' + res.num_turns + ' turns, exit ' + x.ev.exit + ')' }} else {writeFile(w.reportPath, JSON.stringify(res.structured_output), 384)}}// mission 020: stopped by `run` (second SIGTERM) or evicted by the usage limit → PARK, not give backlet ri = lastRateInfo(rc.value)if (ri != null) { meta.rateLimit = ri }let pr = w.hooks.parkReason()let rl = rateLimitOf(rc.value, res)if (reason != null && pr != null) {parkRun(meta, 'worker', pr, now(), false, w.done)return false}if (reason != null && rl.limited) {parkRun(meta, 'worker', 'Claude usage limit (' + rl.text + ')', rl.resetMs == null ? now() + 3600000 : rl.resetMs, rl.resetMs != null, w.done)return false}// mission 025: out of steps / time → resume the same session ONCE to write the reportif (reason != null && !fin && w.fs != null && w.fs.maxTurns != '0' && meta.claudeSession != null) {let fw = finalizeWhy(res, x.timedOut)if (fw != null) {startFinalize(w, meta, fw, reason, null)return true}}if (fin) {meta.finalize.ended = timestamp(now())meta.finalize.costUsd = meta.costUsdmeta.finalize.numTurns = res == null ? null : res.num_turnsmeta.finalize.subtype = res == null ? null : res.subtypemeta.finalize.outcome = reason == null ? 'report written' : 'no report: ' + reasonif (reason != null) { console.log('work: FINALIZE FAILED — ' + reason + ' (the worker ' + meta.finalize.why + ' before)') }}let problems = []if (reason == null) {let rep = reportFile(w.reportPath, false, { ticket = w.ref session = w.session })if (fin) { meta.finalize.outcome = rep.valid ? 'valid report' : 'report refused by the validation' }if (!rep.valid) {reason = 'the report was refused by the validation (' + rep.problems.length + ' problem' + (rep.problems.length == 1 ? '' : 's') + ')'problems = rep.problems// mission 027: ONE retry — the same session fixes its report (not after a finalize / fix step: one per session)if (!fin && pr == null && w.fs != null && w.fs.maxTurns != '0' && meta.claudeSession != null) {startFinalize(w, meta, REFUSED_WHY, reason, problems)return true}} else if (pr != null) {parkRun(meta, 'controller', pr + ' (the worker had finished with a valid report; the controller runs on resume)', now(), false, w.done)return false} else {// 7. the controllermeta.outcome = 'valid report, controller running'writeFile(w.dir + '/session.json', JSON.stringify(meta), 384)let report = readJson(w.reportPath).valuelet cs = w.cslet boxed = boxOf(meta, false, [w.dir])let cp = runController({ dir = w.dir folder = meta.folder ref = w.ref session = w.session host = w.host ports = meta.ports bin = cs.bin model = cs.model maxTurns = cs.maxTurns mode = cs.mode timeout = cs.timeout budget = cs.budget note = w.controllerNote sandbox = boxed }, meta, (cv) => {afterVerdict({ ref = w.ref dir = w.dir post = w.post meta = meta report = report cs = cs label = w.label == null ? 'WORK' : w.label hooks = w.hooks }, cv, w.done)return null})w.hooks.onProc(cp, 'controller')return true}}// no valid report → the lease goes back: ticket → open, with the reason// (mission 025: after a failed finalize step the WORKER's reason counts; the finalize step's is added)let finReason = nulllet timedOut = x.timedOutif (fin) {finReason = reasonreason = w.firstReasontimedOut = w.firstTimedOutif (problems.length == 0 && w.firstProblems != null) { problems = w.firstProblems }}meta.outcome = 'no valid report: ' + reason + (fin ? ' · finalize step: ' + finReason : '')meta.problems = problemswriteFile(w.dir + '/session.json', JSON.stringify(meta), 384)// mission 023: plain words for the creator; the reason in full + the validation problems stay in session.jsonlet why = 'it ended without a result'if (timedOut) { why = 'it ran out of time' }else if (reason.includes('error_max_turns')) { why = 'it ran out of steps' }else if (problems.length > 0) { why = 'its report was incomplete' }let text = 'Back to open: the worker stopped without a usable result (' + why + ') — the next worker tries again.' + NL + NL + 'colony-run: ' + w.session + ' · host ' + w.host + ' · no report'let s = postJson(ticketPath(w.project, w.number) + '/state', { state = 'open' text = text }, w.token)// `salvage` (mission 025; since mission 026 it holds a lease like work → gives it back the same way)let head = w.salvage == true ? 'SALVAGE FAILED — the finalize step gave no valid report (' + finReason + '); the worker: ' + reason : 'WORK FAILED — ' + reasonif (s.status != 201) {console.log(head + '; AND giving the lease back failed: ' + s.status + ' ' + s.text + ' — set ' + w.ref + ' to open by hand')} else {console.log(head + ' → ' + w.ref + ' back to open · files ' + w.dir + '/')}w.done({ outcome = 'no valid report' session = w.session workerCost = workerCostOf(meta) controllerCost = null })return false}static noop = (r) => { return null }// `work`; `done(result)` is called once the whole run is over (result = { outcome, workerCost,// controllerCost }) — not on a refusal / dry run. → true when a worker was startedstatic runWork = (argv, whenDone, hooksIn) => {let done = whenDone == null ? noop : whenDonelet hooks = hooksIn == null ? noHooks() : hooksInlet pos = positionals(argv)if (pos.length != 3) {console.log('usage: work <project> <number> [--post] [--dry-run] [--model M] [--max-turns N] [--permission-mode MODE] [--timeout SECONDS] [--max-budget-usd X] [--ports A-B] [--session ID] [--controller-model M] [--controller-max-turns N] [--controller-timeout S] [--max-fails N]')return false}let project = pos[1]let number = pos[2]let post = argv.includes('--post')let dry = argv.includes('--dry-run')let model = setting(argv, '--model', 'COLONY_MODEL', 'sonnet')let maxTurns = setting(argv, '--max-turns', 'COLONY_MAX_TURNS', '40')let mode = setting(argv, '--permission-mode', 'COLONY_PERMISSION_MODE', 'auto')let timeoutText = setting(argv, '--timeout', 'COLONY_WORK_TIMEOUT', '3600')let budget = setting(argv, '--max-budget-usd', 'COLONY_MAX_BUDGET_USD', '')let claudeBin = setting(argv, '--claude', 'COLONY_CLAUDE', 'claude')let cs = controllerSettings(argv)let home = env('COLONY_HOME')if (home == null || home == '') {console.log('work: REFUSED — COLONY_HOME is not set (run through ./colony, which sets it)')return false}if (!MODES.includes(mode)) {console.log('work: REFUSED — permission mode "' + mode + '" is not allowed (allowed: ' + MODES.join(', ') + '; never bypassPermissions)')return false}if (!isWholeNumber(maxTurns) || maxTurns == '0') {console.log('work: REFUSED — --max-turns must be a whole number ≥ 1, got ' + maxTurns)return false}if (!isWholeNumber(timeoutText) || timeoutText == '0') {console.log('work: REFUSED — --timeout must be whole seconds ≥ 1, got ' + timeoutText)return false}let cr = controllerRefusal(cs)if (cr != null) {console.log('work: REFUSED — ' + cr)return false}let ls = leaseSettings(argv)if (ls.error != null) {console.log('work: REFUSED — ' + ls.error)return false}let fs = finalizeSettings(argv, timeoutText)if (fs.error != null) {console.log('work: REFUSED — ' + fs.error)return false}let tk = readToken()if (tk.error != null) {console.log('work: REFUSED — ' + tk.error + ' (work writes the lease, so it always needs the token)')return false}let b = buildBrief(project, number, argv)if (b.error != null) {console.log('work: ' + b.error)return false}// mission 035 (antcolony#14): the scheduler assembles the brief (it holds the creator's decisions); an agent's `work`// takes that text and fills in ITS port range. The ticket checks below still run here, on this host.let briefFrom = option(argv, '--brief-file')if (briefFrom != null && briefFrom != '') {if (!exists(briefFrom)) {console.log('work: REFUSED — --brief-file ' + briefFrom + ' does not exist')return false}let pr = portsOf(argv)b.text = readFile(briefFrom).replaceAll(PORT_FROM_MARKER, '' + pr.from).replaceAll(PORT_TO_MARKER, '' + pr.to)b.ports = pr}let m = b.metaif (!m.takesWorkers) {console.log('work: REFUSED — project ' + project + ' takes no workers ("workers": false in the registry)')return false}if (b.ticket.state == 'rejected') {// mission 022 "Rejections are work": only while the last rejection is newer than the last colony sessionif (!b.rejection.eligible) {console.log('work: REFUSED — ' + b.ref + ' is "rejected", and no rejection is newer than the last colony session on it')return false}console.log('work: ' + b.ref + ' was REJECTED by the creator (' + b.rejection.rejection.when + ') — reworking it; the brief starts with the rejection')} else if (b.ticket.state != 'open') {console.log('work: REFUSED — ' + b.ref + ' is "' + b.ticket.state + '", not open (in progress = leased by another session)')return false}let br = blockReason(b.rel, b.rejection != null && b.ticket.state == 'rejected')if (br != null) {console.log('work: REFUSED — ' + b.ref + ' is not eligible: ' + br)return false}let host = hostName()if (m.dev == null || m.dev.folder == null || m.dev.folder == '') {console.log('work: REFUSED — project ' + project + ' has no dev folder in the registry')return false}if (m.dev.host != null && m.dev.host.toLowerCase() != host.toLowerCase()) {console.log('work: REFUSED — the dev folder of ' + project + ' is on ' + m.dev.host + ', this host is ' + host + ' (COLONY_HOST overrides the name)')return false}if (!exists(m.dev.folder)) {console.log('work: REFUSED — dev folder ' + m.dev.folder + ' does not exist on ' + host)return false}let session = b.sessionlet dir = runsDir(home) + '/' + sessionif (exists(dir)) {console.log('work: REFUSED — ' + dir + ' exists already (one session, one run)')return false}if (dry) {console.log('work: DRY RUN — would lease ' + b.ref + ' ("session ' + session + ' started on ' + host + '"), write ' + dir + '/, run the worker ' + claudeBin + ' -p --model ' + model + ' --max-turns ' + maxTurns + ' --permission-mode ' + mode + ' in ' + m.dev.folder + ' (brief ' + b.text.length + ' characters, ports ' + b.ports.from + '-' + b.ports.to + '), ' + (fs.maxTurns == '0' ? 'no finalize step' : 'out of steps / time → finalize step (--max-turns ' + fs.maxTurns + ', ' + fs.timeout + ' s)') + ', then the controller ' + cs.bin + ' -p --model ' + cs.model + ' --max-turns ' + cs.maxTurns + ', then ' + (post ? 'post by the verdict (max fails ' + cs.maxFails + ')' : 'NOT post (no --post)') + '. Nothing written, no Claude session started.')return false}let box = sandboxFor({ project = project folder = m.dev.folder host = host deploy = false extraRo = null })if (box.error != null) {console.log('work: REFUSED — ' + box.error)return false}mkDir(dir, 448)let briefFile = dir + '/brief.md'let outFile = dir + '/claude-result.json'let errFile = dir + '/claude-stderr.log'let reportPath = dir + '/report.json'writeFile(briefFile, b.text, 384)let claudeSession = uuid()let marked = deployMarked(project, b.ticket, b.events, creatorNames())let meta = { ticket = b.ref project = project number = number post = post session = session host = host folder = m.dev.folder runDir = dir ports = b.ports.from + '-' + b.ports.to model = model maxTurns = maxTurns permissionMode = mode timeoutSeconds = timeoutText maxBudgetUsd = budget claude = claudeBin claudeSession = claudeSession leaseTtl = ls.ttl heartbeatSeconds = ls.heartbeat started = timestamp(now()) outcome = 'running' deploy = marked }writeFile(dir + '/session.json', JSON.stringify(meta), 384)// 3. the lease (mission 020: with an expiry; the heartbeat renews it while the session runs)let expiry = now() + ls.ttl * 1000let lease = postJson(ticketPath(project, number) + '/state', { state = 'in progress' text = 'A worker started on this ticket.' + NL + NL + leaseLine(session, host, 'started', expiry) }, tk.token)if (lease.status != 201) {meta.outcome = 'lease failed: ' + lease.statuswriteFile(dir + '/session.json', JSON.stringify(meta), 384)console.log('work: FAILED — the lease (' + b.ref + ' → in progress) was refused: ' + lease.status + ' ' + lease.text + ' — no worker started')return false}console.log('work: ' + b.ref + ' leased — "session ' + session + ' started on ' + host + '" until ' + timestamp(expiry) + ' (heartbeat every ' + ls.heartbeat + ' s) ' + pageUrl(b.ticket))let hb = startHeartbeat({ project = project number = number ref = b.ref session = session host = host ttl = ls.ttl heartbeat = ls.heartbeat })let userDone = donedone = (r) => {hb.stop()userDone(r)return null}// 4. the workerlet args = ['-p' '--output-format' 'json' '--json-schema' readFile('./templates/report.schema.json') '--model' model '--max-turns' maxTurns '--permission-mode' mode '--permission-prompts' 'none' '--strict-mcp-config' '--session-id' claudeSession]if (budget != '') {args.push('--max-budget-usd')args.push(budget)}let w = { meta = meta dir = dir outFile = outFile errFile = errFile reportPath = reportPath post = post ref = b.ref session = session host = host project = project number = number token = tk.token ticket = b.ticket mode = mode cs = cs done = done hooks = hooks fs = fs }let boxed = boxOf(meta, meta.deploy == true, null)let p = runClaude({ label = 'work' folder = m.dev.folder inFile = briefFile outFile = outFile errFile = errFile bin = claudeBin args = args timeout = timeoutText env = portEnv(meta.ports) session = session ports = meta.ports sandbox = boxed }, (x) => {finish(w, x)return null})hooks.onProc(p, 'worker')console.log('work: worker started — ' + claudeBin + ' -p --model ' + model + ' --max-turns ' + maxTurns + ' --permission-mode ' + mode + ' in ' + m.dev.folder + ' · pid ' + p.pid + ' · claude session ' + claudeSession + ' · ports ' + meta.ports + ' · files ' + dir + '/')return true}// RESUME a parked session (mission 020; called by `run` once the park time is over and the quota is below the// reserve): lease comment `resumed` + heartbeat, then phase worker → `claude -p --resume <claude session>` with a// short prompt (the brief is in the session already; the port range may be new) → the normal finish (report →// controller → post); phase controller → a new controller session on the kept report.json → post.// `ports` = the range for this part ('A-B', null = keep). → true when a session was started.static runResume = (dir, argv, whenDone, hooksIn, ports) => {let done = whenDone == null ? noop : whenDonelet hooks = hooksIn == null ? noHooks() : hooksInlet sj = readJson(dir + '/session.json')if (sj.error != null) {console.log('resume: REFUSED — ' + sj.error)return false}let meta = sj.valueif (meta.parked == null) {console.log('resume: REFUSED — ' + dir + ' is not parked (outcome: ' + meta.outcome + ')')return false}let host = hostName()if (meta.host != host) {console.log('resume: REFUSED — session ' + meta.session + ' ran on ' + meta.host + ', this host is ' + host + ' (its Claude session lives there)')return false}let tk = readToken()if (tk.error != null) {console.log('resume: REFUSED — ' + tk.error)return false}let cs = controllerSettings(argv)let k = meta.parked.countlet phase = meta.parked.phaselet reason = meta.parked.reasonif (ports != null) { meta.ports = ports }let ttl = meta.leaseTtl == null ? 3600 : meta.leaseTtllet hbs = meta.heartbeatSeconds == null ? 1200 : meta.heartbeatSecondslet tref = parseRef(meta.ticket)let expiry = now() + ttl * 1000let lr = postLease(tref.project, tref.number, 'Work continues after a pause.' + NL + NL + leaseLine(meta.session, host, 'resumed (park ' + k + ')', expiry), tk.token)if (!lr.ok) {console.log('resume: FAILED — the lease comment on ' + meta.ticket + ' was refused: ' + lr.text + ' — nothing started')return false}meta.resumed = kmeta.outcome = 'resumed (' + phase + ')'writeFile(dir + '/session.json', JSON.stringify(meta), 384)let hb = startHeartbeat({ project = tref.project number = tref.number ref = meta.ticket session = meta.session host = host ttl = ttl heartbeat = hbs })let done2 = (r) => {hb.stop()done(r)return null}if (phase == 'controller') {let report = readJson(dir + '/report.json').valuelet boxed = boxOf(meta, false, [dir])let cp = runController({ dir = dir folder = meta.folder ref = meta.ticket session = meta.session host = host ports = meta.ports bin = cs.bin model = cs.model maxTurns = cs.maxTurns mode = cs.mode timeout = cs.timeout budget = cs.budget sandbox = boxed }, meta, (cv) => {afterVerdict({ ref = meta.ticket dir = dir post = meta.post meta = meta report = report cs = cs label = 'WORK' hooks = hooks }, cv, done2)return null})hooks.onProc(cp, 'controller')console.log('work: RESUMED ' + meta.ticket + ' session ' + meta.session + ' — a new controller session (park ' + k + ') · ports ' + meta.ports)return true}// the worker: keep the output of the part before the park, continue the same Claude sessionlet outFile = dir + '/claude-result.json'let errFile = dir + '/claude-stderr.log'if (exists(outFile)) {writeFile(dir + '/claude-result.parked-' + k + '.json', readFile(outFile), 384)remove(outFile)}if (exists(errFile)) {writeFile(dir + '/claude-stderr.parked-' + k + '.log', readFile(errFile), 384)remove(errFile)}if (exists(dir + '/report.json')) { remove(dir + '/report.json') }let promptFile = dir + '/resume-' + k + '.md'let pt = 'Your session was interrupted: ' + reason + '. It is resumed now (' + timestamp(now()) + ').' + NL + NL + 'Continue the task of your brief (the first message of this session) from where you stopped — do not start over; check what is already done.' + NL + NL + '**Ports for everything you start: ' + meta.ports + ' only** (also in the env as `COLONY_PORTS`; they may differ from the range in the brief).' + NL + NL + 'When you are done, answer with the JSON report exactly as the brief says, with' + NL + ' "ticket": "' + meta.ticket + '",' + NL + ' "session": "' + meta.session + '",' + NLwriteFile(promptFile, pt, 384)let args = ['-p' '--resume' meta.claudeSession '--output-format' 'json' '--json-schema' readFile('./templates/report.schema.json') '--model' meta.model '--max-turns' meta.maxTurns '--permission-mode' meta.permissionMode '--permission-prompts' 'none' '--strict-mcp-config']if (meta.maxBudgetUsd != null && meta.maxBudgetUsd != '') {args.push('--max-budget-usd')args.push(meta.maxBudgetUsd)}let fs = finalizeSettings(argv, meta.timeoutSeconds)let w = { meta = meta dir = dir outFile = outFile errFile = errFile reportPath = dir + '/report.json' post = meta.post ref = meta.ticket session = meta.session host = host project = tref.project number = tref.number token = tk.token ticket = null mode = meta.permissionMode cs = cs done = done2 hooks = hooks fs = fs.error == null ? fs : null resumedPart = true }let boxed = boxOf(meta, meta.deploy == true, null)let p = runClaude({ label = 'work' folder = meta.folder inFile = promptFile outFile = outFile errFile = errFile bin = meta.claude args = args timeout = meta.timeoutSeconds env = portEnv(meta.ports) session = meta.session ports = meta.ports sandbox = boxed }, (x) => {finish(w, x)return null})hooks.onProc(p, 'worker')console.log('work: RESUMED ' + meta.ticket + ' session ' + meta.session + ' — ' + meta.claude + ' -p --resume ' + meta.claudeSession + ' (park ' + k + ') in ' + meta.folder + ' · pid ' + p.pid + ' · ports ' + meta.ports)return true}// `check <run dir> [--post]`: the controller on a finished `work` run (a valid report.json) — or its// verdict.json when there is one (`--again` runs the controller anew) — then, with --post, posting// by the verdict. For runs made without --post, or when the controller / the post failed.static runCheck = (argv, whenDone) => {let done = whenDone == null ? noop : whenDonelet pos = positionals(argv)if (pos.length != 2) {console.log('usage: check <runs/session dir> [--post] [--again] [--controller-model M] [--controller-max-turns N] [--controller-timeout S] [--max-fails N]')return false}let dir = fromCwd(pos[1])while (dir.endsWith('/')) { dir = dir.slice(0, dir.length - 1) }let post = argv.includes('--post')let cs = controllerSettings(argv)let cr = controllerRefusal(cs)if (cr != null) {console.log('check: REFUSED — ' + cr)return false}let sj = readJson(dir + '/session.json')if (sj.error != null) {console.log('check: REFUSED — ' + sj.error + ' (a runs/<session> folder of `work`)')return false}let meta = sj.valueif (meta.runDir == null) { meta.runDir = dir }if (meta.ports == null) { meta.ports = setting(argv, '--ports', 'COLONY_PORTS', '8700-8749') }let rep = reportFile(dir + '/report.json', false, { ticket = meta.ticket session = meta.session })if (!rep.valid) {console.log('check: REFUSED — no valid report in ' + dir)return false}if (post) {let tk = readToken()if (tk.error != null) {console.log('check: REFUSED — ' + tk.error + ' (--post writes to tickets)')return false}}let report = readJson(dir + '/report.json').valuelet x = { ref = meta.ticket dir = dir post = post meta = meta report = report cs = cs label = 'CHECK' }if (exists(dir + '/verdict.json') && !argv.includes('--again')) {console.log('check: using the verdict of ' + dir + '/verdict.json (--again runs the controller anew)')let cv = loadVerdict(dir, meta.ticket, meta.session)afterVerdict(x, cv, done)return true}let boxed = boxOf(meta, false, [dir])runController({ dir = dir folder = meta.folder ref = meta.ticket session = meta.session host = meta.host ports = meta.ports bin = cs.bin model = cs.model maxTurns = cs.maxTurns mode = cs.mode timeout = cs.timeout budget = cs.budget sandbox = boxed }, meta, (cv) => {afterVerdict(x, cv, done)return null})return true}// mission 025: the controller of a salvaged run re-runs nothing that writes (the run is over; its folder may be in use)static SALVAGE_NOTE = 'This is a SALVAGED run: the worker ran out of steps / time, and its report was written afterwards by a resumed session told to stop working. Re-run NOTHING that writes a file or starts a server or test (not even a gate): only read — `cat`, `ls`, `grep`, `diff`, `test -f` — and compare with the worker session. The working directory must not change. A claim whose command you may not re-run: check it in the worker session (the run files) instead.'// `salvage <runs/session dir> [--post] [--finalize-max-turns N] [--finalize-timeout S] [--ports A-B] [controller options]`// (mission 025): the finalize step for a FINISHED run whose worker ran out of steps / time without a report — resume// its Claude session once, then the controller, then (with --post) post by the verdict like `check`.// Mission 026: salvage takes a LEASE like `work` (ticket must be `open` → `in progress` + colony-lease marker + heartbeat),// so `run` cannot pick the ticket meanwhile; no valid report → the lease goes back (`open`) like work's give-back;// without --post the ticket stays leased (`./colony check <dir> --post` posts later) — exactly like `work`.static runSalvage = (argv, whenDone) => {let userDone = whenDone == null ? noop : whenDonelet done = (r) => {console.log('SALVAGE COST: finalize ' + (r.finalizeCost == null ? '$?' : '$' + r.finalizeCost) + ' + controller ' + (r.controllerCost == null ? '–' : '$' + r.controllerCost) + ' (the worker before: $' + r.workerBefore + '; whole session now $' + round4((r.workerCost == null ? 0 : r.workerCost) + (r.controllerCost == null ? 0 : r.controllerCost)) + ')')console.log('SALVAGE END — ' + r.ref + ': ' + r.outcome)userDone(r)return null}let pos = positionals(argv)if (pos.length != 2) {console.log('usage: salvage <runs/session dir> [--post] [--finalize-max-turns N] [--finalize-timeout S] [--ports A-B] [--controller-model M] [--controller-max-turns N] [--controller-timeout S] [--max-fails N]')return false}let dir = fromCwd(pos[1])while (dir.endsWith('/')) { dir = dir.slice(0, dir.length - 1) }let post = argv.includes('--post')let cs = controllerSettings(argv)let cr = controllerRefusal(cs)if (cr != null) {console.log('salvage: REFUSED — ' + cr)return false}let sj = readJson(dir + '/session.json')if (sj.error != null) {console.log('salvage: REFUSED — ' + sj.error + ' (a runs/<session> folder of `work`)')return false}let meta = sj.valuemeta.runDir = dirlet fs = finalizeSettings(argv, meta.timeoutSeconds)if (fs.error != null) {console.log('salvage: REFUSED — ' + fs.error)return false}if (fs.maxTurns == '0') {console.log('salvage: REFUSED — --finalize-max-turns 0 means no finalize step')return false}if (meta.finalize != null && meta.finalize.why == REFUSED_WHY) {// mission 027: its report was refused and the fix step ran — the worker did not run out of steps or timeconsole.log('salvage: REFUSED — the worker of ' + dir + ' did not run out of steps or time (outcome: ' + meta.outcome + ') — nothing to salvage')return false}if (meta.finalize != null) {console.log('salvage: REFUSED — ' + dir + ' had its finalize step already (' + meta.finalize.outcome + '); one finalize per session. Its report (if any): ./colony check ' + dir + (post ? ' --post' : ''))return false}if (meta.parked != null && (meta.outcome == null || meta.outcome.startsWith('parked'))) {console.log('salvage: REFUSED — ' + dir + ' is parked (' + meta.outcome + '); `run` resumes it')return false}let outFile = dir + '/claude-result.json'let rc = readClaude(outFile, meta.exit, false, meta.timeoutSeconds, 'worker')let timedOut = meta.outcome != null && meta.outcome.includes('timed out after')let why = finalizeWhy(rc.res, timedOut)if (why == null) {console.log('salvage: REFUSED — the worker of ' + dir + ' did not run out of steps or time (outcome: ' + meta.outcome + ') — nothing to salvage')return false}if (exists(dir + '/report.json')) {console.log('salvage: REFUSED — ' + dir + ' has a report.json already — use ./colony check ' + dir + (post ? ' --post' : ''))return false}let host = hostName()if (meta.host == null || meta.host.toLowerCase() != host.toLowerCase()) {console.log('salvage: REFUSED — session ' + meta.session + ' ran on ' + meta.host + ', this host is ' + host + ' (its Claude session lives there)')return false}if (meta.claudeSession == null || meta.folder == null || !exists(meta.folder)) {console.log('salvage: REFUSED — session.json names no Claude session / no existing folder')return false}let tref = parseRef(meta.ticket)let ls = leaseSettings(argv)if (ls.error != null) {console.log('salvage: REFUSED — ' + ls.error)return false}let tk = readToken()if (tk.error != null) {console.log('salvage: REFUSED — ' + tk.error + ' (salvage leases the ticket, so it always needs the token)')return false}let token = tk.tokenlet got = getJson(ticketPath(tref.project, tref.number))if (got.status != 200) {console.log('salvage: REFUSED — ticket ' + meta.ticket + ' → ' + got.status + ' at ' + baseUrl())return false}let st = got.json.ticket.state// mission 030: a question ticket is never work — no salvage on one eitherlet qwhy = questionWhy(got.json.ticket)if (qwhy != null) {console.log('salvage: REFUSED — ' + meta.ticket + ' is ' + qwhy + ' (state ' + st + '); never work — nothing started')return false}if (st != 'open') {console.log('salvage: REFUSED — ' + meta.ticket + ' is "' + st + '" at ' + baseUrl() + ', not open' + (st == 'in progress' ? ' (another session holds it)' : '') + '; nothing started')return false}let pt = option(argv, '--ports')if (pt != null && pt != '') { meta.ports = pt }meta.post = postmeta.salvaged = timestamp(now())meta.leaseTtl = ls.ttlmeta.heartbeatSeconds = ls.heartbeatlet reason = meta.outcome == null ? why : meta.outcomeif (reason.startsWith('no valid report: ')) { reason = reason.slice(17) }console.log('salvage: ' + meta.ticket + ' session ' + meta.session + ' — the worker ' + why + ' (' + reason + ', worker cost $' + meta.costUsd + ')' + (post ? ' · posts to ' + baseUrl() : ' · NOT posting (no --post)'))let w = { meta = meta dir = dir outFile = outFile errFile = dir + '/claude-stderr.log' reportPath = dir + '/report.json' post = post ref = meta.ticket session = meta.session host = host project = tref.project number = tref.number token = token ticket = null mode = meta.permissionMode cs = cs done = done hooks = noHooks() fs = fs salvage = true label = 'SALVAGE' controllerNote = SALVAGE_NOTE }let workerBefore = meta.costUsd// mission 026: the LEASE (like work): open → in progress with the colony-lease marker; the heartbeat renews itlet expiry = now() + ls.ttl * 1000let lease = postJson(ticketPath(tref.project, tref.number) + '/state', { state = 'in progress' text = 'A worker started on this ticket: it writes the report of an earlier session.' + NL + NL + leaseLine(meta.session, host, 'salvage', expiry) }, token)if (lease.status != 201) {console.log('salvage: FAILED — the lease (' + meta.ticket + ' → in progress) was refused: ' + lease.status + ' ' + lease.text + ' — nothing started')return false}console.log('salvage: ' + meta.ticket + ' leased until ' + timestamp(expiry) + ' (heartbeat every ' + ls.heartbeat + ' s)')let hb = startHeartbeat({ project = tref.project number = tref.number ref = meta.ticket session = meta.session host = host ttl = ls.ttl heartbeat = ls.heartbeat })w.done = (r) => {hb.stop()r.ref = meta.ticket// the finalize part's cost from the written session.json (the local meta is a stale copy by now — hybriel#38)let now2 = readJson(dir + '/session.json')r.finalizeCost = now2.error != null || now2.value.finalize == null ? null : now2.value.finalize.costUsdr.workerBefore = workerBeforedone(r)return null}startFinalize(w, meta, why, reason, null)return true}// `cycle [--post] [--dry-run] [work options…]`: next → work → controller → post, ONCE, for the oldest// eligible ticket whose dev folder is on this host; prints each step and the summed cost.static runCycle = (argv, pick) => {let host = hostName()let p = pick(host)if (p.error != null) {console.log('CYCLE — ' + p.error)return false}if (p.ticket == null) {console.log('CYCLE — nothing to do: no eligible open ticket with its dev folder on ' + host)return false}let t = p.ticketlet ref = t.project + t.refconsole.log('cycle: 1 next → ' + ref + ' "' + t.subject + '" (oldest eligible on ' + host + ')')let num = '' + t.numberlet wargv = ['work' t.project num]let i = 1while (i < argv.length) {wargv.push(argv[i])i = i + 1}console.log('cycle: 2 work → 3 controller → 4 ' + (argv.includes('--post') ? 'post by the verdict' : 'no post (no --post)'))let started = runWork(wargv, (r) => {let wc = r.workerCost == null ? 0 : r.workerCostlet cc = r.controllerCost == null ? 0 : r.controllerCostconsole.log('CYCLE COST: $' + round4(wc + cc) + ' (worker ' + (r.workerCost == null ? '$?' : '$' + r.workerCost) + ' + controller ' + (r.controllerCost == null ? '–' : '$' + r.controllerCost) + ')')console.log('CYCLE END — ' + ref + ': ' + r.outcome)return null})if (!started) {if (argv.includes('--dry-run')) { console.log('CYCLE DRY RUN — stopped before any Claude session; nothing written') }else { console.log('CYCLE — no worker started for ' + ref + ' (see above)') }}return started}
Branches
- mainmain branch