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/lib/work.hl

55.3 KB

  1. // work.hl — `work <project> <n>`: agent v0 (ticket antcolony#2, mission 016, concept
  2. // docs/scheduler-agent.md §3 + build order 3) + the controller step (mission 019, build order 4).
  3. // Run BY HAND (or by `cycle`) on the host that holds the project's dev folder; ONE worker, no daemon.
  4. //
  5. // 1. refuse unless: the project takes workers, has a concept, the ticket is `open` (or `rejected` with a
  6. // rejection newer than the last colony session — mission 022) and neither blocked nor a parent, the dev folder is
  7. // on THIS host and exists, a token is there (COLONY_TOKEN_FILE) — nothing is written before that;
  8. // `--dry-run` stops here (prints what would run; no write, no Claude session);
  9. // 2. the brief (`brief`, same text) → runs/<session>/brief.md;
  10. // 3. the LEASE: ticket → `in progress` with the text "A worker started on this ticket." + the colony-lease line;
  11. // 4. Claude Code headless in the dev folder: `claude -p` (brief on stdin) `--output-format json`
  12. // `--json-schema` templates/report.schema.json, model / max turns / permission mode configurable
  13. // (never bypassPermissions), `--permission-prompts none` (whatever would ask is denied),
  14. // `--strict-mcp-config` (no MCP servers), a fixed Claude `--session-id` (recorded before the start);
  15. // 5. on exit: raw result → runs/<session>/claude-result.json, the structured output →
  16. // runs/<session>/report.json, `report` validation (+ ticket/session must be this session's);
  17. // 6. no valid report (no structured output, max turns, timeout, crash, refused by validation) →
  18. // ticket back to `open` with the reason (the lease is given back);
  19. // 7. a valid report → the CONTROLLER (controller.hl) → with --post: posted by the verdict (pass →
  20. // report comment + awaiting creator; fail → findings + open; Nth fail → creator question + on hold).
  21. // No verdict (controller crashed / timed out / refused) → nothing posted, ticket stays leased,
  22. // `./colony check runs/<session> --post` retries.
  23. // Mission 020: the lease carries an expiry (lease.hl) and a heartbeat renews it while the session runs;
  24. // a session that ends on the Claude usage limit — or is stopped by `run`'s second SIGTERM — is PARKED
  25. // (ticket stays leased, `colony-parked:` marker) and `runResume` continues it later with `claude --resume`
  26. // (worker) or a new controller session; `hooks` = { onProc(process, phase), parkReason() } let `run` see
  27. // and stop the running process; the port range goes to the session as env COLONY_PORTS / _FROM / _TO.
  28. import { env } from 'hl:proc'
  29. import { readFile, writeFile, mkDir, exists, remove } from 'hl:fs'
  30. import { now, timestamp } from 'hl:time'
  31. import { buildBrief, portsOf, PORT_FROM_MARKER, PORT_TO_MARKER } from './brief.hl'
  32. import { reportFile, parseRef } from './report.hl'
  33. import { readToken, postJson, getJson, ticketPath, pageUrl, baseUrl } from './tickets.hl'
  34. import { sandboxFor, deployMarked } from './sandbox.hl'
  35. import { creatorNames } from './librarian.hl'
  36. import { runClaude, readClaude, usageLine, usageMeta, setting, isWholeNumber, uuid, round4, MODES, rateLimitOf, lastRateInfo, portEnv } from './claude.hl'
  37. import { leaseSettings, leaseLine, parkLine, startHeartbeat, postLease, clock } from './lease.hl'
  38. import { runController, postByVerdict, loadVerdict, maxFailsOf } from './controller.hl'
  39. import { NL, fromCwd, positionals, readJson, option } from './util.hl'
  40. import { loadRegistry } from './registry.hl'
  41. import { blockReason, questionWhy } from './relations.hl'
  42. static hostName = () => {
  43. let h = env('COLONY_HOST')
  44. if (h == null || h == '') { h = exists('/etc/hostname') ? readFile('/etc/hostname').trim() : '' }
  45. return h
  46. }
  47. static runsDir = (home) => {
  48. let runsText = env('COLONY_RUNS_DIR')
  49. return runsText == null || runsText == '' ? home + '/runs' : fromCwd(runsText)
  50. }
  51. // no daemon around: nothing watches the process, nothing asks to park
  52. static noHooks = () => {
  53. return { onProc = (p, phase) => { return null } parkReason = () => { return null } }
  54. }
  55. // costs of a session over all its parts (a parked + resumed session has several)
  56. static workerCostOf = (meta) => {
  57. let a = meta.costEarlier == null ? 0 : meta.costEarlier
  58. if (meta.costUsd == null) { return meta.costEarlier }
  59. return a + meta.costUsd
  60. }
  61. static controllerCostOf = (meta) => {
  62. let a = meta.controllerCostEarlier
  63. let c = meta.controller == null ? null : meta.controller.costUsd
  64. if (c == null) { return a }
  65. if (a == null) { return c }
  66. return a + c
  67. }
  68. // antcolony#18: the box a session of this run lives in (sandbox.hl). No box possible → a command that fails at once
  69. // (never a session with host access because the box could not be built).
  70. static boxOf = (meta, mayDeploy, more) => {
  71. let r = sandboxFor({ project = meta.project folder = meta.folder host = meta.host deploy = mayDeploy extraRo = more })
  72. if (r.error != null) {
  73. console.log('work: SANDBOX — ' + r.error + ' — the session cannot start')
  74. return ['/usr/bin/false' '--']
  75. }
  76. return r.args
  77. }
  78. // `box <project> [--deploy] [--folder F]` (antcolony#18): the argv prefix of the sandbox a session of that project runs in,
  79. // one argument per line (a test / the architect runs `<prefix> cmd …` to see what a worker sees). --folder = a dev folder
  80. // other than the registry's.
  81. static runBox = (argv) => {
  82. let pos = positionals(argv)
  83. if (pos.length != 2) {
  84. console.log('usage: box <project> [--deploy] [--folder F]')
  85. return false
  86. }
  87. let reg = loadRegistry()
  88. let m = reg.projects[pos[1]]
  89. let folder = option(argv, '--folder')
  90. if (folder == null || folder == '') { folder = m == null || m.dev == null ? null : m.dev.folder }
  91. if (folder == null) {
  92. console.log('box: REFUSED — project ' + pos[1] + ' has no dev folder')
  93. return false
  94. }
  95. let r = sandboxFor({ project = pos[1] folder = folder host = hostName() deploy = argv.includes('--deploy') extraRo = null })
  96. if (r.error != null) {
  97. console.log('box: REFUSED — ' + r.error)
  98. return false
  99. }
  100. for (x of r.args) { console.log(x) }
  101. return true
  102. }
  103. // PARK a session (mission 020): the ticket stays leased (`in progress`), a comment with the reason and the
  104. // `colony-parked:` marker (resume time, lease expiry = resume + lease ttl); `run` on this host resumes it
  105. // after `resumeMs` once the quota is below the reserve. phase = worker (→ claude --resume) | controller
  106. // (→ a new controller session on the kept report). Calls done({ outcome: 'parked', … }).
  107. static parkRun = (meta, phase, reason, resumeMs, resetKnown, done) => {
  108. let prev = meta.parked == null ? 0 : meta.parked.count
  109. if (phase == 'worker') {
  110. meta.costEarlier = workerCostOf(meta)
  111. meta.costUsd = null
  112. } else if (meta.controller != null && meta.controller.costUsd != null) {
  113. meta.controllerCostEarlier = controllerCostOf(meta)
  114. meta.controller.costUsd = null
  115. }
  116. let count = prev + 1
  117. meta.parked = { phase = phase reason = reason resumeMs = resumeMs resumeAfter = timestamp(resumeMs) count = count at = timestamp(now()) }
  118. meta.outcome = 'parked (' + phase + ')'
  119. writeFile(meta.runDir + '/session.json', JSON.stringify(meta), 384)
  120. let ttl = meta.leaseTtl == null ? 3600 : meta.leaseTtl
  121. let exp = resumeMs + ttl * 1000
  122. // mission 023: one plain line for the creator + the machine line (the Claude session id is in session.json)
  123. let why = reason.startsWith('Claude usage limit') ? 'the Claude usage limit is reached' : 'the scheduler was stopped'
  124. let text = 'Paused: ' + why + ' — work continues after ' + clock(resumeMs) + '.' + NL + NL + parkLine(meta.session, meta.host, phase, resumeMs, exp)
  125. let ref = parseRef(meta.ticket)
  126. let tk = readToken()
  127. let posted = 'no token: ' + tk.error
  128. if (tk.error == null) {
  129. let r = postLease(ref.project, ref.number, text, tk.token)
  130. posted = r.ok ? 'marker posted' : 'POSTING THE MARKER FAILED: ' + r.text
  131. }
  132. console.log('PARKED — ' + meta.ticket + ' session ' + meta.session + ' (' + phase + ') until ' + timestamp(resumeMs) + ': ' + reason + ' · ' + posted + ' · files ' + meta.runDir + '/')
  133. // mission 034: `run` pauses the librarian + starts until the reset when the park was the usage limit
  134. let lim = reason.startsWith('Claude usage limit')
  135. done({ outcome = 'parked' session = meta.session workerCost = workerCostOf(meta) controllerCost = controllerCostOf(meta) limited = lim resetMs = lim && resetKnown ? resumeMs : null limitText = lim ? reason : null })
  136. return null
  137. }
  138. // the controller's settings (options / env / defaults; the worker's permission mode and claude binary)
  139. static controllerSettings = (argv) => {
  140. let claudeBin = setting(argv, '--claude', 'COLONY_CLAUDE', 'claude')
  141. return {
  142. bin = setting(argv, '--controller-claude', 'COLONY_CONTROLLER_CLAUDE', claudeBin)
  143. model = setting(argv, '--controller-model', 'COLONY_CONTROLLER_MODEL', 'sonnet')
  144. maxTurns = setting(argv, '--controller-max-turns', 'COLONY_CONTROLLER_MAX_TURNS', '20')
  145. timeout = setting(argv, '--controller-timeout', 'COLONY_CONTROLLER_TIMEOUT', '900')
  146. mode = setting(argv, '--permission-mode', 'COLONY_PERMISSION_MODE', 'auto')
  147. budget = setting(argv, '--max-budget-usd', 'COLONY_MAX_BUDGET_USD', '')
  148. maxFails = maxFailsOf(setting(argv, '--max-fails', 'COLONY_MAX_FAILS', '2'))
  149. }
  150. }
  151. // refusals shared by work and check → text or null
  152. static controllerRefusal = (cs) => {
  153. if (!MODES.includes(cs.mode)) { return 'permission mode "' + cs.mode + '" is not allowed (allowed: ' + MODES.join(', ') + '; never bypassPermissions)' }
  154. if (!isWholeNumber(cs.maxTurns) || cs.maxTurns == '0') { return '--controller-max-turns must be a whole number ≥ 1, got ' + cs.maxTurns }
  155. if (!isWholeNumber(cs.timeout) || cs.timeout == '0') { return '--controller-timeout must be whole seconds ≥ 1, got ' + cs.timeout }
  156. return null
  157. }
  158. // 7. the controller's answer → post by it (or not) → the last line; calls done(result)
  159. // x = { ref, dir, post, meta, report, cs, label ('WORK' / 'CHECK') }
  160. static afterVerdict = (x, cv, done) => {
  161. let meta = x.meta
  162. let pr = x.hooks == null ? null : x.hooks.parkReason()
  163. if (pr != null) {
  164. parkRun(meta, 'controller', pr, now(), false, done)
  165. return null
  166. }
  167. if (cv.limited == true) {
  168. parkRun(meta, 'controller', 'Claude usage limit hit by the controller (' + cv.limitText + ')', cv.resetMs == null ? now() + 3600000 : cv.resetMs, cv.resetMs != null, done)
  169. return null
  170. }
  171. let res = { outcome = null session = meta.session workerCost = workerCostOf(meta) controllerCost = controllerCostOf(meta) }
  172. if (cv.verdict == null) {
  173. meta.outcome = 'valid report, no verdict: ' + cv.reason
  174. writeFile(x.dir + '/session.json', JSON.stringify(meta), 384)
  175. 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' : ''))
  176. res.outcome = 'no verdict'
  177. done(res)
  178. return null
  179. }
  180. if (!x.post) {
  181. meta.outcome = 'valid report, verdict ' + cv.effective + ', not posted'
  182. writeFile(x.dir + '/session.json', JSON.stringify(meta), 384)
  183. 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')
  184. res.outcome = 'verdict ' + cv.effective + ', not posted'
  185. done(res)
  186. return null
  187. }
  188. let p = postByVerdict(x.report, cv, meta, x.cs.maxFails)
  189. meta.outcome = p.outcome
  190. writeFile(x.dir + '/session.json', JSON.stringify(meta), 384)
  191. res.outcome = p.outcome
  192. if (!p.ok) {
  193. console.log(x.label + ' OK, POST FAILED — controller: ' + cv.effective + '; re-run ./colony check ' + x.dir + ' --post (idempotent). ' + x.ref + ' stays "in progress".')
  194. } else if (p.outcome == 'posted') {
  195. console.log(x.label + ' DONE — ' + x.ref + ' report posted (controller: pass) · files ' + x.dir + '/')
  196. } else if (p.outcome == 'ready to deploy') {
  197. // mission 027: the deploy gate — the creator tests on the live site, so not "awaiting creator" before the deploy
  198. let rr = parseRef(x.ref)
  199. 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 + '/')
  200. } else if (p.outcome == 'half done') {
  201. 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 + '/')
  202. } else if (p.outcome == 'sent back') {
  203. console.log(x.label + ' FAILED — controller: fail (' + p.fails + ' of ' + x.cs.maxFails + ') → ' + x.ref + ' back to open with the findings · files ' + x.dir + '/')
  204. } else if (p.outcome == 'question') {
  205. 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 + '/')
  206. } else {
  207. console.log(x.label + ' — controller: ' + cv.effective + ', ' + p.outcome + ' · files ' + x.dir + '/')
  208. }
  209. done(res)
  210. return null
  211. }
  212. // ---- mission 025: the FINALIZE step ------------------------------------------------------------------------------
  213. // A worker that ran out of steps (`error_max_turns`) or time (killed by --timeout) did work but wrote no report (first
  214. // live cycle, ident.worldapi.org#2: all gates green in its dev copy, then max turns before the copy-back + report →
  215. // $4.67 for nothing). Before the lease goes back, the SAME Claude session is resumed ONCE (`claude -p --resume <id>`,
  216. // same model / permission mode / --json-schema, a small --max-turns) with a short instruction: stop, start nothing new,
  217. // change nothing, write the report about what is done / verified / open.
  218. // Mission 026: NO `--disallowedTools` any more — it changes the tool list, i.e. the cached prompt prefix, so the first
  219. // real finalize re-sent the whole 207 k context without cache ($1.05). The "change nothing" instruction + auto mode stay.
  220. // A valid report → controller → post as usual; none → back to open as before (once). The cost is summed.
  221. // --finalize-max-turns (default 8; 0 = no finalize step) / --finalize-timeout (default 600 s, never more than the
  222. // worker's own --timeout) → { maxTurns, timeout, error }
  223. static finalizeSettings = (argv, workerTimeout) => {
  224. let maxTurns = setting(argv, '--finalize-max-turns', 'COLONY_FINALIZE_MAX_TURNS', '8')
  225. let wt = isWholeNumber(workerTimeout) ? toNumber(workerTimeout) : 600
  226. let deflt = wt < 600 ? '' + wt : '600'
  227. let timeout = setting(argv, '--finalize-timeout', 'COLONY_FINALIZE_TIMEOUT', deflt)
  228. let error = null
  229. if (!isWholeNumber(maxTurns)) { error = '--finalize-max-turns must be a whole number (0 = no finalize step), got ' + maxTurns }
  230. else if (!isWholeNumber(timeout) || timeout == '0') { error = '--finalize-timeout must be whole seconds ≥ 1, got ' + timeout }
  231. return { maxTurns = maxTurns timeout = timeout error = error }
  232. }
  233. // why the worker stopped, when a finalize step can help → text or null
  234. static finalizeWhy = (res, timedOut) => {
  235. if (timedOut) { return 'ran out of time' }
  236. if (res != null && res.subtype == 'error_max_turns') { return 'ran out of steps' }
  237. return null
  238. }
  239. // mission 027 ("validate → reject incomplete reports back to the worker (one retry)", concept §2): a report the validation
  240. // refused (e.g. a result over 120 characters, a test step on a work copy, 4 questions) gets the SAME Claude session resumed
  241. // once — like the finalize step, with the problems — to answer the corrected report; the work is not lost for a long line
  242. static REFUSED_WHY = 'had its report refused'
  243. static fixPrompt = (meta, maxTurns, problems) => {
  244. let o = []
  245. o.push('# FIX YOUR REPORT — the scheduler refused it')
  246. o.push('')
  247. o.push('Your report was refused by the validation (' + problems.length + ' problem' + (problems.length == 1 ? '' : 's') + '):')
  248. for (p of problems) { o.push('- ' + p) }
  249. o.push('')
  250. 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.')
  251. 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.')
  252. o.push('')
  253. o.push('Answer with the JSON report exactly as the brief (the first message of this session) says, with')
  254. o.push(' "ticket": "' + meta.ticket + '",')
  255. o.push(' "session": "' + meta.session + '",')
  256. o.push('')
  257. o.push('colony-finalize: ' + meta.session)
  258. return o.join(NL)
  259. }
  260. // the finalize instruction (stdin of the resumed session) — kept as runs/<S>/finalize.md
  261. static finalizePrompt = (meta, why, maxTurns) => {
  262. let o = []
  263. o.push('# STOP WORKING — write your report now')
  264. o.push('')
  265. 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.')
  266. o.push('')
  267. 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.')
  268. 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.')
  269. 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.')
  270. 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.')
  271. o.push('- Anything you started that is still running goes into `running`.')
  272. o.push('')
  273. o.push('Answer with the JSON report exactly as the brief (the first message of this session) says, with')
  274. o.push(' "ticket": "' + meta.ticket + '",')
  275. o.push(' "session": "' + meta.session + '",')
  276. o.push('')
  277. o.push('colony-finalize: ' + meta.session)
  278. return o.join(NL)
  279. }
  280. // start the finalize step for a worker that ended without a report; `finish` runs again on its exit (w.finalized)
  281. // (`meta` = the caller's session object: Hybriel copies objects stored in fields, so w.meta may be stale — hybriel#38)
  282. static startFinalize = (w, meta, why, reason, problems) => {
  283. let dir = w.dir
  284. meta.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' }
  285. if (meta.claudeCostSeen == null) { meta.claudeCostSeen = meta.costUsd }
  286. meta.costEarlier = workerCostOf(meta)
  287. meta.costUsd = null
  288. meta.outcome = 'finalizing (the worker ' + why + ')'
  289. // the worker's own output stays beside the finalize output (the controller sees both parts)
  290. if (exists(w.outFile)) {
  291. writeFile(dir + '/claude-result.before-finalize.json', readFile(w.outFile), 384)
  292. remove(w.outFile)
  293. }
  294. if (exists(w.errFile)) {
  295. writeFile(dir + '/claude-stderr.before-finalize.log', readFile(w.errFile), 384)
  296. remove(w.errFile)
  297. }
  298. if (exists(w.reportPath)) { remove(w.reportPath) }
  299. writeFile(dir + '/session.json', JSON.stringify(meta), 384)
  300. let promptFile = dir + '/finalize.md'
  301. writeFile(promptFile, why == REFUSED_WHY ? fixPrompt(meta, w.fs.maxTurns, problems) : finalizePrompt(meta, why, w.fs.maxTurns), 384)
  302. 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']
  303. if (meta.maxBudgetUsd != null && meta.maxBudgetUsd != '') {
  304. args.push('--max-budget-usd')
  305. args.push(meta.maxBudgetUsd)
  306. }
  307. w.finalized = true
  308. w.resumedPart = true
  309. w.firstReason = reason
  310. w.firstProblems = problems == null ? [] : problems
  311. w.firstTimedOut = why == 'ran out of time'
  312. w.timeoutText = w.fs.timeout
  313. w.meta = meta
  314. console.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)
  315. let boxed = boxOf(meta, meta.deploy == true, null)
  316. 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) => {
  317. finish(w, x)
  318. return null
  319. })
  320. w.hooks.onProc(p, 'worker')
  321. return p
  322. }
  323. // 5./6. — after the worker's exit (or, mission 025, after the finalize step's exit: w.finalized)
  324. static finish = (w, x) => {
  325. let meta = w.meta
  326. let fin = w.finalized == true
  327. meta.ended = timestamp(now())
  328. meta.exit = x.ev.exit
  329. for (l of x.lines) { console.log((fin ? ' finalize ' : ' worker ') + l) }
  330. // mission 026: what this part left listening on its ports was stopped (claude.hl cleanupPorts) — kept in session.json
  331. if (x.cleanup.length > 0) {
  332. let cl = []
  333. if (meta.cleanup != null) { for (c of meta.cleanup) { cl.push(c) } }
  334. cl.push({ part = fin ? 'finalize' : 'worker' at = timestamp(now()) lines = x.cleanup })
  335. meta.cleanup = cl
  336. }
  337. let rc = readClaude(w.outFile, x.ev.exit, x.timedOut, w.timeoutText == null ? meta.timeoutSeconds : w.timeoutText, fin ? 'finalize step' : 'worker')
  338. let reason = rc.reason
  339. if (rc.cr != null) {
  340. meta.permissionModeSeen = rc.cr.mode
  341. meta.permissionModes = rc.cr.modes
  342. if (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') }
  343. }
  344. let res = rc.res
  345. if (res != null) {
  346. let um = usageMeta(res)
  347. for (k of um.keys()) { meta[k] = um[k] }
  348. console.log(usageLine(fin ? 'FINALIZE USAGE' : 'USAGE', res))
  349. // mission 025 (seen on the first real finalize, Claude Code 2.1.280): a RESUMED session reports the WHOLE session's
  350. // cost in total_cost_usd / modelUsage (cumulative over all its parts), not this part's → this part = the total now
  351. // minus the total seen after the previous part (meta.claudeCostSeen); a smaller total → taken as this part's own
  352. let total = res.total_cost_usd
  353. if (w.resumedPart == true && total != null && meta.claudeCostSeen != null) {
  354. let inc = round4(total - meta.claudeCostSeen)
  355. if (inc < 0) { inc = total }
  356. meta.costUsd = inc
  357. meta.claudeCostTotal = total
  358. console.log((fin ? 'FINALIZE' : 'RESUME') + ' COST: $' + inc + ' for this part (Claude reports $' + total + ' for the whole session so far)')
  359. }
  360. if (total != null) { meta.claudeCostSeen = total }
  361. 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') }
  362. if (res.structured_output == null) {
  363. if (reason == null) { reason = 'no structured report (Claude: ' + res.subtype + (res.is_error ? ', error' : '') + ', ' + res.num_turns + ' turns, exit ' + x.ev.exit + ')' }
  364. } else {
  365. writeFile(w.reportPath, JSON.stringify(res.structured_output), 384)
  366. }
  367. }
  368. // mission 020: stopped by `run` (second SIGTERM) or evicted by the usage limit → PARK, not give back
  369. let ri = lastRateInfo(rc.value)
  370. if (ri != null) { meta.rateLimit = ri }
  371. let pr = w.hooks.parkReason()
  372. let rl = rateLimitOf(rc.value, res)
  373. if (reason != null && pr != null) {
  374. parkRun(meta, 'worker', pr, now(), false, w.done)
  375. return false
  376. }
  377. if (reason != null && rl.limited) {
  378. parkRun(meta, 'worker', 'Claude usage limit (' + rl.text + ')', rl.resetMs == null ? now() + 3600000 : rl.resetMs, rl.resetMs != null, w.done)
  379. return false
  380. }
  381. // mission 025: out of steps / time → resume the same session ONCE to write the report
  382. if (reason != null && !fin && w.fs != null && w.fs.maxTurns != '0' && meta.claudeSession != null) {
  383. let fw = finalizeWhy(res, x.timedOut)
  384. if (fw != null) {
  385. startFinalize(w, meta, fw, reason, null)
  386. return true
  387. }
  388. }
  389. if (fin) {
  390. meta.finalize.ended = timestamp(now())
  391. meta.finalize.costUsd = meta.costUsd
  392. meta.finalize.numTurns = res == null ? null : res.num_turns
  393. meta.finalize.subtype = res == null ? null : res.subtype
  394. meta.finalize.outcome = reason == null ? 'report written' : 'no report: ' + reason
  395. if (reason != null) { console.log('work: FINALIZE FAILED — ' + reason + ' (the worker ' + meta.finalize.why + ' before)') }
  396. }
  397. let problems = []
  398. if (reason == null) {
  399. let rep = reportFile(w.reportPath, false, { ticket = w.ref session = w.session })
  400. if (fin) { meta.finalize.outcome = rep.valid ? 'valid report' : 'report refused by the validation' }
  401. if (!rep.valid) {
  402. reason = 'the report was refused by the validation (' + rep.problems.length + ' problem' + (rep.problems.length == 1 ? '' : 's') + ')'
  403. problems = rep.problems
  404. // mission 027: ONE retry — the same session fixes its report (not after a finalize / fix step: one per session)
  405. if (!fin && pr == null && w.fs != null && w.fs.maxTurns != '0' && meta.claudeSession != null) {
  406. startFinalize(w, meta, REFUSED_WHY, reason, problems)
  407. return true
  408. }
  409. } else if (pr != null) {
  410. parkRun(meta, 'controller', pr + ' (the worker had finished with a valid report; the controller runs on resume)', now(), false, w.done)
  411. return false
  412. } else {
  413. // 7. the controller
  414. meta.outcome = 'valid report, controller running'
  415. writeFile(w.dir + '/session.json', JSON.stringify(meta), 384)
  416. let report = readJson(w.reportPath).value
  417. let cs = w.cs
  418. let boxed = boxOf(meta, false, [w.dir])
  419. 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) => {
  420. 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)
  421. return null
  422. })
  423. w.hooks.onProc(cp, 'controller')
  424. return true
  425. }
  426. }
  427. // no valid report → the lease goes back: ticket → open, with the reason
  428. // (mission 025: after a failed finalize step the WORKER's reason counts; the finalize step's is added)
  429. let finReason = null
  430. let timedOut = x.timedOut
  431. if (fin) {
  432. finReason = reason
  433. reason = w.firstReason
  434. timedOut = w.firstTimedOut
  435. if (problems.length == 0 && w.firstProblems != null) { problems = w.firstProblems }
  436. }
  437. meta.outcome = 'no valid report: ' + reason + (fin ? ' · finalize step: ' + finReason : '')
  438. meta.problems = problems
  439. writeFile(w.dir + '/session.json', JSON.stringify(meta), 384)
  440. // mission 023: plain words for the creator; the reason in full + the validation problems stay in session.json
  441. let why = 'it ended without a result'
  442. if (timedOut) { why = 'it ran out of time' }
  443. else if (reason.includes('error_max_turns')) { why = 'it ran out of steps' }
  444. else if (problems.length > 0) { why = 'its report was incomplete' }
  445. 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'
  446. let s = postJson(ticketPath(w.project, w.number) + '/state', { state = 'open' text = text }, w.token)
  447. // `salvage` (mission 025; since mission 026 it holds a lease like work → gives it back the same way)
  448. let head = w.salvage == true ? 'SALVAGE FAILED — the finalize step gave no valid report (' + finReason + '); the worker: ' + reason : 'WORK FAILED — ' + reason
  449. if (s.status != 201) {
  450. console.log(head + '; AND giving the lease back failed: ' + s.status + ' ' + s.text + ' — set ' + w.ref + ' to open by hand')
  451. } else {
  452. console.log(head + ' → ' + w.ref + ' back to open · files ' + w.dir + '/')
  453. }
  454. w.done({ outcome = 'no valid report' session = w.session workerCost = workerCostOf(meta) controllerCost = null })
  455. return false
  456. }
  457. static noop = (r) => { return null }
  458. // `work`; `done(result)` is called once the whole run is over (result = { outcome, workerCost,
  459. // controllerCost }) — not on a refusal / dry run. → true when a worker was started
  460. static runWork = (argv, whenDone, hooksIn) => {
  461. let done = whenDone == null ? noop : whenDone
  462. let hooks = hooksIn == null ? noHooks() : hooksIn
  463. let pos = positionals(argv)
  464. if (pos.length != 3) {
  465. 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]')
  466. return false
  467. }
  468. let project = pos[1]
  469. let number = pos[2]
  470. let post = argv.includes('--post')
  471. let dry = argv.includes('--dry-run')
  472. let model = setting(argv, '--model', 'COLONY_MODEL', 'sonnet')
  473. let maxTurns = setting(argv, '--max-turns', 'COLONY_MAX_TURNS', '40')
  474. let mode = setting(argv, '--permission-mode', 'COLONY_PERMISSION_MODE', 'auto')
  475. let timeoutText = setting(argv, '--timeout', 'COLONY_WORK_TIMEOUT', '3600')
  476. let budget = setting(argv, '--max-budget-usd', 'COLONY_MAX_BUDGET_USD', '')
  477. let claudeBin = setting(argv, '--claude', 'COLONY_CLAUDE', 'claude')
  478. let cs = controllerSettings(argv)
  479. let home = env('COLONY_HOME')
  480. if (home == null || home == '') {
  481. console.log('work: REFUSED — COLONY_HOME is not set (run through ./colony, which sets it)')
  482. return false
  483. }
  484. if (!MODES.includes(mode)) {
  485. console.log('work: REFUSED — permission mode "' + mode + '" is not allowed (allowed: ' + MODES.join(', ') + '; never bypassPermissions)')
  486. return false
  487. }
  488. if (!isWholeNumber(maxTurns) || maxTurns == '0') {
  489. console.log('work: REFUSED — --max-turns must be a whole number ≥ 1, got ' + maxTurns)
  490. return false
  491. }
  492. if (!isWholeNumber(timeoutText) || timeoutText == '0') {
  493. console.log('work: REFUSED — --timeout must be whole seconds ≥ 1, got ' + timeoutText)
  494. return false
  495. }
  496. let cr = controllerRefusal(cs)
  497. if (cr != null) {
  498. console.log('work: REFUSED — ' + cr)
  499. return false
  500. }
  501. let ls = leaseSettings(argv)
  502. if (ls.error != null) {
  503. console.log('work: REFUSED — ' + ls.error)
  504. return false
  505. }
  506. let fs = finalizeSettings(argv, timeoutText)
  507. if (fs.error != null) {
  508. console.log('work: REFUSED — ' + fs.error)
  509. return false
  510. }
  511. let tk = readToken()
  512. if (tk.error != null) {
  513. console.log('work: REFUSED — ' + tk.error + ' (work writes the lease, so it always needs the token)')
  514. return false
  515. }
  516. let b = buildBrief(project, number, argv)
  517. if (b.error != null) {
  518. console.log('work: ' + b.error)
  519. return false
  520. }
  521. // mission 035 (antcolony#14): the scheduler assembles the brief (it holds the creator's decisions); an agent's `work`
  522. // takes that text and fills in ITS port range. The ticket checks below still run here, on this host.
  523. let briefFrom = option(argv, '--brief-file')
  524. if (briefFrom != null && briefFrom != '') {
  525. if (!exists(briefFrom)) {
  526. console.log('work: REFUSED — --brief-file ' + briefFrom + ' does not exist')
  527. return false
  528. }
  529. let pr = portsOf(argv)
  530. b.text = readFile(briefFrom).replaceAll(PORT_FROM_MARKER, '' + pr.from).replaceAll(PORT_TO_MARKER, '' + pr.to)
  531. b.ports = pr
  532. }
  533. let m = b.meta
  534. if (!m.takesWorkers) {
  535. console.log('work: REFUSED — project ' + project + ' takes no workers ("workers": false in the registry)')
  536. return false
  537. }
  538. if (b.ticket.state == 'rejected') {
  539. // mission 022 "Rejections are work": only while the last rejection is newer than the last colony session
  540. if (!b.rejection.eligible) {
  541. console.log('work: REFUSED — ' + b.ref + ' is "rejected", and no rejection is newer than the last colony session on it')
  542. return false
  543. }
  544. console.log('work: ' + b.ref + ' was REJECTED by the creator (' + b.rejection.rejection.when + ') — reworking it; the brief starts with the rejection')
  545. } else if (b.ticket.state != 'open') {
  546. console.log('work: REFUSED — ' + b.ref + ' is "' + b.ticket.state + '", not open (in progress = leased by another session)')
  547. return false
  548. }
  549. let br = blockReason(b.rel, b.rejection != null && b.ticket.state == 'rejected')
  550. if (br != null) {
  551. console.log('work: REFUSED — ' + b.ref + ' is not eligible: ' + br)
  552. return false
  553. }
  554. let host = hostName()
  555. if (m.dev == null || m.dev.folder == null || m.dev.folder == '') {
  556. console.log('work: REFUSED — project ' + project + ' has no dev folder in the registry')
  557. return false
  558. }
  559. if (m.dev.host != null && m.dev.host.toLowerCase() != host.toLowerCase()) {
  560. console.log('work: REFUSED — the dev folder of ' + project + ' is on ' + m.dev.host + ', this host is ' + host + ' (COLONY_HOST overrides the name)')
  561. return false
  562. }
  563. if (!exists(m.dev.folder)) {
  564. console.log('work: REFUSED — dev folder ' + m.dev.folder + ' does not exist on ' + host)
  565. return false
  566. }
  567. let session = b.session
  568. let dir = runsDir(home) + '/' + session
  569. if (exists(dir)) {
  570. console.log('work: REFUSED — ' + dir + ' exists already (one session, one run)')
  571. return false
  572. }
  573. if (dry) {
  574. 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.')
  575. return false
  576. }
  577. let box = sandboxFor({ project = project folder = m.dev.folder host = host deploy = false extraRo = null })
  578. if (box.error != null) {
  579. console.log('work: REFUSED — ' + box.error)
  580. return false
  581. }
  582. mkDir(dir, 448)
  583. let briefFile = dir + '/brief.md'
  584. let outFile = dir + '/claude-result.json'
  585. let errFile = dir + '/claude-stderr.log'
  586. let reportPath = dir + '/report.json'
  587. writeFile(briefFile, b.text, 384)
  588. let claudeSession = uuid()
  589. let marked = deployMarked(project, b.ticket, b.events, creatorNames())
  590. 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 }
  591. writeFile(dir + '/session.json', JSON.stringify(meta), 384)
  592. // 3. the lease (mission 020: with an expiry; the heartbeat renews it while the session runs)
  593. let expiry = now() + ls.ttl * 1000
  594. let 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)
  595. if (lease.status != 201) {
  596. meta.outcome = 'lease failed: ' + lease.status
  597. writeFile(dir + '/session.json', JSON.stringify(meta), 384)
  598. console.log('work: FAILED — the lease (' + b.ref + ' → in progress) was refused: ' + lease.status + ' ' + lease.text + ' — no worker started')
  599. return false
  600. }
  601. console.log('work: ' + b.ref + ' leased — "session ' + session + ' started on ' + host + '" until ' + timestamp(expiry) + ' (heartbeat every ' + ls.heartbeat + ' s) ' + pageUrl(b.ticket))
  602. let hb = startHeartbeat({ project = project number = number ref = b.ref session = session host = host ttl = ls.ttl heartbeat = ls.heartbeat })
  603. let userDone = done
  604. done = (r) => {
  605. hb.stop()
  606. userDone(r)
  607. return null
  608. }
  609. // 4. the worker
  610. let 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]
  611. if (budget != '') {
  612. args.push('--max-budget-usd')
  613. args.push(budget)
  614. }
  615. 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 }
  616. let boxed = boxOf(meta, meta.deploy == true, null)
  617. 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) => {
  618. finish(w, x)
  619. return null
  620. })
  621. hooks.onProc(p, 'worker')
  622. 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 + '/')
  623. return true
  624. }
  625. // RESUME a parked session (mission 020; called by `run` once the park time is over and the quota is below the
  626. // reserve): lease comment `resumed` + heartbeat, then phase worker → `claude -p --resume <claude session>` with a
  627. // short prompt (the brief is in the session already; the port range may be new) → the normal finish (report →
  628. // controller → post); phase controller → a new controller session on the kept report.json → post.
  629. // `ports` = the range for this part ('A-B', null = keep). → true when a session was started.
  630. static runResume = (dir, argv, whenDone, hooksIn, ports) => {
  631. let done = whenDone == null ? noop : whenDone
  632. let hooks = hooksIn == null ? noHooks() : hooksIn
  633. let sj = readJson(dir + '/session.json')
  634. if (sj.error != null) {
  635. console.log('resume: REFUSED — ' + sj.error)
  636. return false
  637. }
  638. let meta = sj.value
  639. if (meta.parked == null) {
  640. console.log('resume: REFUSED — ' + dir + ' is not parked (outcome: ' + meta.outcome + ')')
  641. return false
  642. }
  643. let host = hostName()
  644. if (meta.host != host) {
  645. console.log('resume: REFUSED — session ' + meta.session + ' ran on ' + meta.host + ', this host is ' + host + ' (its Claude session lives there)')
  646. return false
  647. }
  648. let tk = readToken()
  649. if (tk.error != null) {
  650. console.log('resume: REFUSED — ' + tk.error)
  651. return false
  652. }
  653. let cs = controllerSettings(argv)
  654. let k = meta.parked.count
  655. let phase = meta.parked.phase
  656. let reason = meta.parked.reason
  657. if (ports != null) { meta.ports = ports }
  658. let ttl = meta.leaseTtl == null ? 3600 : meta.leaseTtl
  659. let hbs = meta.heartbeatSeconds == null ? 1200 : meta.heartbeatSeconds
  660. let tref = parseRef(meta.ticket)
  661. let expiry = now() + ttl * 1000
  662. let lr = postLease(tref.project, tref.number, 'Work continues after a pause.' + NL + NL + leaseLine(meta.session, host, 'resumed (park ' + k + ')', expiry), tk.token)
  663. if (!lr.ok) {
  664. console.log('resume: FAILED — the lease comment on ' + meta.ticket + ' was refused: ' + lr.text + ' — nothing started')
  665. return false
  666. }
  667. meta.resumed = k
  668. meta.outcome = 'resumed (' + phase + ')'
  669. writeFile(dir + '/session.json', JSON.stringify(meta), 384)
  670. let hb = startHeartbeat({ project = tref.project number = tref.number ref = meta.ticket session = meta.session host = host ttl = ttl heartbeat = hbs })
  671. let done2 = (r) => {
  672. hb.stop()
  673. done(r)
  674. return null
  675. }
  676. if (phase == 'controller') {
  677. let report = readJson(dir + '/report.json').value
  678. let boxed = boxOf(meta, false, [dir])
  679. 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) => {
  680. afterVerdict({ ref = meta.ticket dir = dir post = meta.post meta = meta report = report cs = cs label = 'WORK' hooks = hooks }, cv, done2)
  681. return null
  682. })
  683. hooks.onProc(cp, 'controller')
  684. console.log('work: RESUMED ' + meta.ticket + ' session ' + meta.session + ' — a new controller session (park ' + k + ') · ports ' + meta.ports)
  685. return true
  686. }
  687. // the worker: keep the output of the part before the park, continue the same Claude session
  688. let outFile = dir + '/claude-result.json'
  689. let errFile = dir + '/claude-stderr.log'
  690. if (exists(outFile)) {
  691. writeFile(dir + '/claude-result.parked-' + k + '.json', readFile(outFile), 384)
  692. remove(outFile)
  693. }
  694. if (exists(errFile)) {
  695. writeFile(dir + '/claude-stderr.parked-' + k + '.log', readFile(errFile), 384)
  696. remove(errFile)
  697. }
  698. if (exists(dir + '/report.json')) { remove(dir + '/report.json') }
  699. let promptFile = dir + '/resume-' + k + '.md'
  700. 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 + '",' + NL
  701. writeFile(promptFile, pt, 384)
  702. 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']
  703. if (meta.maxBudgetUsd != null && meta.maxBudgetUsd != '') {
  704. args.push('--max-budget-usd')
  705. args.push(meta.maxBudgetUsd)
  706. }
  707. let fs = finalizeSettings(argv, meta.timeoutSeconds)
  708. 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 }
  709. let boxed = boxOf(meta, meta.deploy == true, null)
  710. 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) => {
  711. finish(w, x)
  712. return null
  713. })
  714. hooks.onProc(p, 'worker')
  715. 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)
  716. return true
  717. }
  718. // `check <run dir> [--post]`: the controller on a finished `work` run (a valid report.json) — or its
  719. // verdict.json when there is one (`--again` runs the controller anew) — then, with --post, posting
  720. // by the verdict. For runs made without --post, or when the controller / the post failed.
  721. static runCheck = (argv, whenDone) => {
  722. let done = whenDone == null ? noop : whenDone
  723. let pos = positionals(argv)
  724. if (pos.length != 2) {
  725. console.log('usage: check <runs/session dir> [--post] [--again] [--controller-model M] [--controller-max-turns N] [--controller-timeout S] [--max-fails N]')
  726. return false
  727. }
  728. let dir = fromCwd(pos[1])
  729. while (dir.endsWith('/')) { dir = dir.slice(0, dir.length - 1) }
  730. let post = argv.includes('--post')
  731. let cs = controllerSettings(argv)
  732. let cr = controllerRefusal(cs)
  733. if (cr != null) {
  734. console.log('check: REFUSED — ' + cr)
  735. return false
  736. }
  737. let sj = readJson(dir + '/session.json')
  738. if (sj.error != null) {
  739. console.log('check: REFUSED — ' + sj.error + ' (a runs/<session> folder of `work`)')
  740. return false
  741. }
  742. let meta = sj.value
  743. if (meta.runDir == null) { meta.runDir = dir }
  744. if (meta.ports == null) { meta.ports = setting(argv, '--ports', 'COLONY_PORTS', '8700-8749') }
  745. let rep = reportFile(dir + '/report.json', false, { ticket = meta.ticket session = meta.session })
  746. if (!rep.valid) {
  747. console.log('check: REFUSED — no valid report in ' + dir)
  748. return false
  749. }
  750. if (post) {
  751. let tk = readToken()
  752. if (tk.error != null) {
  753. console.log('check: REFUSED — ' + tk.error + ' (--post writes to tickets)')
  754. return false
  755. }
  756. }
  757. let report = readJson(dir + '/report.json').value
  758. let x = { ref = meta.ticket dir = dir post = post meta = meta report = report cs = cs label = 'CHECK' }
  759. if (exists(dir + '/verdict.json') && !argv.includes('--again')) {
  760. console.log('check: using the verdict of ' + dir + '/verdict.json (--again runs the controller anew)')
  761. let cv = loadVerdict(dir, meta.ticket, meta.session)
  762. afterVerdict(x, cv, done)
  763. return true
  764. }
  765. let boxed = boxOf(meta, false, [dir])
  766. 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) => {
  767. afterVerdict(x, cv, done)
  768. return null
  769. })
  770. return true
  771. }
  772. // mission 025: the controller of a salvaged run re-runs nothing that writes (the run is over; its folder may be in use)
  773. 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.'
  774. // `salvage <runs/session dir> [--post] [--finalize-max-turns N] [--finalize-timeout S] [--ports A-B] [controller options]`
  775. // (mission 025): the finalize step for a FINISHED run whose worker ran out of steps / time without a report — resume
  776. // its Claude session once, then the controller, then (with --post) post by the verdict like `check`.
  777. // Mission 026: salvage takes a LEASE like `work` (ticket must be `open` → `in progress` + colony-lease marker + heartbeat),
  778. // so `run` cannot pick the ticket meanwhile; no valid report → the lease goes back (`open`) like work's give-back;
  779. // without --post the ticket stays leased (`./colony check <dir> --post` posts later) — exactly like `work`.
  780. static runSalvage = (argv, whenDone) => {
  781. let userDone = whenDone == null ? noop : whenDone
  782. let done = (r) => {
  783. 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)) + ')')
  784. console.log('SALVAGE END — ' + r.ref + ': ' + r.outcome)
  785. userDone(r)
  786. return null
  787. }
  788. let pos = positionals(argv)
  789. if (pos.length != 2) {
  790. 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]')
  791. return false
  792. }
  793. let dir = fromCwd(pos[1])
  794. while (dir.endsWith('/')) { dir = dir.slice(0, dir.length - 1) }
  795. let post = argv.includes('--post')
  796. let cs = controllerSettings(argv)
  797. let cr = controllerRefusal(cs)
  798. if (cr != null) {
  799. console.log('salvage: REFUSED — ' + cr)
  800. return false
  801. }
  802. let sj = readJson(dir + '/session.json')
  803. if (sj.error != null) {
  804. console.log('salvage: REFUSED — ' + sj.error + ' (a runs/<session> folder of `work`)')
  805. return false
  806. }
  807. let meta = sj.value
  808. meta.runDir = dir
  809. let fs = finalizeSettings(argv, meta.timeoutSeconds)
  810. if (fs.error != null) {
  811. console.log('salvage: REFUSED — ' + fs.error)
  812. return false
  813. }
  814. if (fs.maxTurns == '0') {
  815. console.log('salvage: REFUSED — --finalize-max-turns 0 means no finalize step')
  816. return false
  817. }
  818. if (meta.finalize != null && meta.finalize.why == REFUSED_WHY) {
  819. // mission 027: its report was refused and the fix step ran — the worker did not run out of steps or time
  820. console.log('salvage: REFUSED — the worker of ' + dir + ' did not run out of steps or time (outcome: ' + meta.outcome + ') — nothing to salvage')
  821. return false
  822. }
  823. if (meta.finalize != null) {
  824. 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' : ''))
  825. return false
  826. }
  827. if (meta.parked != null && (meta.outcome == null || meta.outcome.startsWith('parked'))) {
  828. console.log('salvage: REFUSED — ' + dir + ' is parked (' + meta.outcome + '); `run` resumes it')
  829. return false
  830. }
  831. let outFile = dir + '/claude-result.json'
  832. let rc = readClaude(outFile, meta.exit, false, meta.timeoutSeconds, 'worker')
  833. let timedOut = meta.outcome != null && meta.outcome.includes('timed out after')
  834. let why = finalizeWhy(rc.res, timedOut)
  835. if (why == null) {
  836. console.log('salvage: REFUSED — the worker of ' + dir + ' did not run out of steps or time (outcome: ' + meta.outcome + ') — nothing to salvage')
  837. return false
  838. }
  839. if (exists(dir + '/report.json')) {
  840. console.log('salvage: REFUSED — ' + dir + ' has a report.json already — use ./colony check ' + dir + (post ? ' --post' : ''))
  841. return false
  842. }
  843. let host = hostName()
  844. if (meta.host == null || meta.host.toLowerCase() != host.toLowerCase()) {
  845. console.log('salvage: REFUSED — session ' + meta.session + ' ran on ' + meta.host + ', this host is ' + host + ' (its Claude session lives there)')
  846. return false
  847. }
  848. if (meta.claudeSession == null || meta.folder == null || !exists(meta.folder)) {
  849. console.log('salvage: REFUSED — session.json names no Claude session / no existing folder')
  850. return false
  851. }
  852. let tref = parseRef(meta.ticket)
  853. let ls = leaseSettings(argv)
  854. if (ls.error != null) {
  855. console.log('salvage: REFUSED — ' + ls.error)
  856. return false
  857. }
  858. let tk = readToken()
  859. if (tk.error != null) {
  860. console.log('salvage: REFUSED — ' + tk.error + ' (salvage leases the ticket, so it always needs the token)')
  861. return false
  862. }
  863. let token = tk.token
  864. let got = getJson(ticketPath(tref.project, tref.number))
  865. if (got.status != 200) {
  866. console.log('salvage: REFUSED — ticket ' + meta.ticket + ' → ' + got.status + ' at ' + baseUrl())
  867. return false
  868. }
  869. let st = got.json.ticket.state
  870. // mission 030: a question ticket is never work — no salvage on one either
  871. let qwhy = questionWhy(got.json.ticket)
  872. if (qwhy != null) {
  873. console.log('salvage: REFUSED — ' + meta.ticket + ' is ' + qwhy + ' (state ' + st + '); never work — nothing started')
  874. return false
  875. }
  876. if (st != 'open') {
  877. console.log('salvage: REFUSED — ' + meta.ticket + ' is "' + st + '" at ' + baseUrl() + ', not open' + (st == 'in progress' ? ' (another session holds it)' : '') + '; nothing started')
  878. return false
  879. }
  880. let pt = option(argv, '--ports')
  881. if (pt != null && pt != '') { meta.ports = pt }
  882. meta.post = post
  883. meta.salvaged = timestamp(now())
  884. meta.leaseTtl = ls.ttl
  885. meta.heartbeatSeconds = ls.heartbeat
  886. let reason = meta.outcome == null ? why : meta.outcome
  887. if (reason.startsWith('no valid report: ')) { reason = reason.slice(17) }
  888. console.log('salvage: ' + meta.ticket + ' session ' + meta.session + ' — the worker ' + why + ' (' + reason + ', worker cost $' + meta.costUsd + ')' + (post ? ' · posts to ' + baseUrl() : ' · NOT posting (no --post)'))
  889. 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 }
  890. let workerBefore = meta.costUsd
  891. // mission 026: the LEASE (like work): open → in progress with the colony-lease marker; the heartbeat renews it
  892. let expiry = now() + ls.ttl * 1000
  893. let 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)
  894. if (lease.status != 201) {
  895. console.log('salvage: FAILED — the lease (' + meta.ticket + ' → in progress) was refused: ' + lease.status + ' ' + lease.text + ' — nothing started')
  896. return false
  897. }
  898. console.log('salvage: ' + meta.ticket + ' leased until ' + timestamp(expiry) + ' (heartbeat every ' + ls.heartbeat + ' s)')
  899. let hb = startHeartbeat({ project = tref.project number = tref.number ref = meta.ticket session = meta.session host = host ttl = ls.ttl heartbeat = ls.heartbeat })
  900. w.done = (r) => {
  901. hb.stop()
  902. r.ref = meta.ticket
  903. // the finalize part's cost from the written session.json (the local meta is a stale copy by now — hybriel#38)
  904. let now2 = readJson(dir + '/session.json')
  905. r.finalizeCost = now2.error != null || now2.value.finalize == null ? null : now2.value.finalize.costUsd
  906. r.workerBefore = workerBefore
  907. done(r)
  908. return null
  909. }
  910. startFinalize(w, meta, why, reason, null)
  911. return true
  912. }
  913. // `cycle [--post] [--dry-run] [work options…]`: next → work → controller → post, ONCE, for the oldest
  914. // eligible ticket whose dev folder is on this host; prints each step and the summed cost.
  915. static runCycle = (argv, pick) => {
  916. let host = hostName()
  917. let p = pick(host)
  918. if (p.error != null) {
  919. console.log('CYCLE — ' + p.error)
  920. return false
  921. }
  922. if (p.ticket == null) {
  923. console.log('CYCLE — nothing to do: no eligible open ticket with its dev folder on ' + host)
  924. return false
  925. }
  926. let t = p.ticket
  927. let ref = t.project + t.ref
  928. console.log('cycle: 1 next → ' + ref + ' "' + t.subject + '" (oldest eligible on ' + host + ')')
  929. let num = '' + t.number
  930. let wargv = ['work' t.project num]
  931. let i = 1
  932. while (i < argv.length) {
  933. wargv.push(argv[i])
  934. i = i + 1
  935. }
  936. console.log('cycle: 2 work → 3 controller → 4 ' + (argv.includes('--post') ? 'post by the verdict' : 'no post (no --post)'))
  937. let started = runWork(wargv, (r) => {
  938. let wc = r.workerCost == null ? 0 : r.workerCost
  939. let cc = r.controllerCost == null ? 0 : r.controllerCost
  940. console.log('CYCLE COST: $' + round4(wc + cc) + ' (worker ' + (r.workerCost == null ? '$?' : '$' + r.workerCost) + ' + controller ' + (r.controllerCost == null ? '–' : '$' + r.controllerCost) + ')')
  941. console.log('CYCLE END — ' + ref + ': ' + r.outcome)
  942. return null
  943. })
  944. if (!started) {
  945. if (argv.includes('--dry-run')) { console.log('CYCLE DRY RUN — stopped before any Claude session; nothing written') }
  946. else { console.log('CYCLE — no worker started for ' + ref + ' (see above)') }
  947. }
  948. return started
  949. }

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