gitoriaLog in with ident

antcolony

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Main branchmain3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmremain/lib/controller.hl

22.9 KB

  1. // controller.hl — the controller step (ticket antcolony#1, mission 019; concept docs/scheduler-agent.md
  2. // §1 "controller = one-shot LLM session that checks a report against what happened" and §2 "Report
  3. // handling": validate → controller session → post or send back).
  4. //
  5. // runController: a SECOND one-shot Claude Code session after `work` got a schema-valid report, BEFORE
  6. // anything is posted; same safety settings as the worker (permission mode, never bypass,
  7. // `--permission-prompts none`, `--strict-mcp-config`, no token) + Edit/Write/NotebookEdit disallowed;
  8. // in the same dev folder; stdin = templates/controller.md + the brief + the report + the worker's
  9. // session as a compact transcript (from claude-result.json). `--json-schema`
  10. // templates/verdict.schema.json → { ticket, session, verdict: pass|fail, findings[], summary }.
  11. // Files: runs/<session>/controller-prompt.md, controller-result.json, controller-stderr.log,
  12. // verdict.json; session.json gets `controller` (cost, usage, verdict).
  13. // The CODE decides: a `pass` with a `false` / `no evidence` finding counts as `fail` ("overruled").
  14. // postByVerdict: pass → `report --post`, the comment's machine line ends with `· controller pass` (mission 023:
  15. // summary + findings stay in verdict.json); fail → a SHORT comment (what happened, ≤ 3 findings, the report's
  16. // issues NOT filed), machine line `colony-verdict: <session> · fail · k of N`, ticket → `open`; the Nth fail on
  17. // the same ticket (N = --max-fails / COLONY_MAX_FAILS, default 2; counted from the fail markers in
  18. // the ticket's comments, one per session) → a creator question (inbox) instead, ticket → `on hold`.
  19. // Idempotent: a session's fail comment is written once.
  20. import { readFile, writeFile, exists } from 'hl:fs'
  21. import { env } from 'hl:proc'
  22. import { now, timestamp } from 'hl:time'
  23. import { loadRegistry } from './registry.hl'
  24. import { getJson, postJson, readToken, ticketPath, pageUrl, fileQuestion } from './tickets.hl'
  25. import { postReport, parseRef, oneLine, refLabel, deployGate } from './report.hl'
  26. import { runClaude, readClaude, usageLine, usageMeta, uuid, money, rateLimitOf, portEnv } from './claude.hl'
  27. import { NL, readJson, isList, isObject, isString, nonEmpty, fenceFor } from './util.hl'
  28. static VERDICT_KEYS = ['ticket' 'session' 'verdict' 'findings' 'summary' 'questions']
  29. static REQUIRED_KEYS = ['ticket' 'session' 'verdict' 'findings' 'summary']
  30. // antcolony#24: the check's ruling on each question of the report
  31. static QUESTION_KEYS = ['index' 'ruling' 'reason' 'answer']
  32. static RULINGS = ['ask' 'decided' 'trivial' 'internal' 'packed']
  33. static FINDING_KEYS = ['claim' 'about' 'status' 'check']
  34. static ABOUT = ['verified' 'done' 'other']
  35. static STATUS = ['ok' 'false' 'no evidence' 'not checked']
  36. static BAD = ['false' 'no evidence']
  37. static TRANSCRIPT_MAX = 80000
  38. static clip = (s, n) => {
  39. if (s.length <= n) { return s }
  40. return s.slice(0, n) + NL + '…[' + (s.length - n) + ' more characters]'
  41. }
  42. static fenced = (o, text) => {
  43. let f = fenceFor(text)
  44. o.push(f)
  45. o.push(text)
  46. o.push(f)
  47. }
  48. // the worker's session (claude-result.json with verbose output = the message list) as compact
  49. // Markdown: its text, every tool call and every result, each clipped
  50. static transcriptOf = (value) => {
  51. if (!isList(value)) { return '_(the worker output is not a message list — no transcript; only the final result was kept)_' }
  52. let o = []
  53. let n = 0
  54. for (m of value) {
  55. if (isObject(m) && (m.type == 'assistant' || m.type == 'user') && m.message != null && isList(m.message.content)) {
  56. for (c of m.message.content) {
  57. if (isObject(c)) {
  58. if (c.type == 'text' && nonEmpty(c.text)) {
  59. n = n + 1
  60. o.push('#### ' + n + ' · ' + m.type + ' text')
  61. fenced(o, clip(c.text, 1500))
  62. } else if (c.type == 'tool_use') {
  63. n = n + 1
  64. o.push('#### ' + n + ' · tool call `' + c.name + '`')
  65. fenced(o, clip(JSON.stringify(c.input), 2000))
  66. } else if (c.type == 'tool_result') {
  67. n = n + 1
  68. let t = isString(c.content) ? c.content : JSON.stringify(c.content)
  69. o.push('#### ' + n + ' · tool result' + (c.is_error == true ? ' (ERROR)' : ''))
  70. fenced(o, clip(t, 2000))
  71. }
  72. }
  73. }
  74. } else if (isObject(m) && m.type == 'result') {
  75. n = n + 1
  76. o.push('#### ' + n + ' · end: ' + m.subtype + ', ' + m.num_turns + ' turns' + (m.permission_denials != null && m.permission_denials.length > 0 ? ', ' + m.permission_denials.length + ' permission denial(s): ' + JSON.stringify(m.permission_denials) : ''))
  77. }
  78. }
  79. if (n == 0) { return '_(no messages in the worker output)_' }
  80. let t = o.join(NL)
  81. // mission 025: a long session keeps its START and its END (the end is where the final checks are — the first live
  82. // worker's session was 1.3 MB); before, only the first TRANSCRIPT_MAX characters were kept
  83. if (t.length > TRANSCRIPT_MAX) {
  84. let head = 30000
  85. let tail = TRANSCRIPT_MAX - head
  86. t = t.slice(0, head) + NL + NL + '…[' + (t.length - head - tail) + ' characters of the transcript left out here — the full session is in the run files (claude-result*.json)]' + NL + NL + t.slice(t.length - tail)
  87. }
  88. return t
  89. }
  90. static fill = (text, vars) => {
  91. let s = text
  92. for (k of vars.keys()) { s = s.replaceAll('{{' + k + '}}', vars[k]) }
  93. return s
  94. }
  95. // the controller's whole input (stdin) → text
  96. static promptOf = (c) => {
  97. let o = []
  98. o.push(fill(readFile('./templates/controller.md').trim(), { TICKET = c.ref SESSION = c.session FOLDER = c.folder RUNDIR = c.dir PORTS = c.ports }))
  99. o.push('')
  100. // mission 025: e.g. `salvage` — re-run nothing that writes
  101. if (c.note != null && c.note != '') {
  102. o.push('**' + c.note + '**')
  103. o.push('')
  104. }
  105. o.push('## The worker report (report.json)')
  106. o.push('')
  107. fenced(o, readFile(c.dir + '/report.json'))
  108. o.push('')
  109. o.push("## The worker's session (compact transcript of claude-result.json)")
  110. o.push('')
  111. // a session that was parked and resumed (mission 020): the parts before each park first
  112. let k = 1
  113. while (exists(c.dir + '/claude-result.parked-' + k + '.json')) {
  114. let pj = readJson(c.dir + '/claude-result.parked-' + k + '.json')
  115. o.push('### Part ' + k + ' — until the session was parked')
  116. o.push('')
  117. o.push(pj.error != null ? '_(' + pj.error + ')_' : transcriptOf(pj.value))
  118. o.push('')
  119. o.push('### Part ' + (k + 1) + ' — resumed')
  120. o.push('')
  121. k = k + 1
  122. }
  123. // mission 025: a worker that ran out of steps / time, then the finalize step that only wrote the report
  124. if (exists(c.dir + '/claude-result.before-finalize.json')) {
  125. let bj = readJson(c.dir + '/claude-result.before-finalize.json')
  126. o.push('### Part ' + k + ' — the worker, until it ran out of steps / time (claude-result.before-finalize.json)')
  127. o.push('')
  128. o.push(bj.error != null ? '_(' + bj.error + ')_' : transcriptOf(bj.value))
  129. o.push('')
  130. o.push('### Part ' + (k + 1) + ' — the finalize step: the same session resumed ONLY to write the report (it was told to change nothing)')
  131. o.push('')
  132. }
  133. let rj = exists(c.dir + '/claude-result.json') ? readJson(c.dir + '/claude-result.json') : { error = 'no claude-result.json' }
  134. o.push(rj.error != null ? '_(' + rj.error + ')_' : transcriptOf(rj.value))
  135. o.push('')
  136. o.push('## The brief the worker got (brief.md)')
  137. o.push('')
  138. fenced(o, readFile(c.dir + '/brief.md'))
  139. o.push('')
  140. o.push('(End of input. Check, then answer with the JSON verdict.)')
  141. return o.join(NL)
  142. }
  143. // the verdict (the code is the authority, not the schema) → problems
  144. static validateVerdict = (v, ref, session, nq) => {
  145. let p = []
  146. if (!isObject(v)) { return ['the verdict must be one JSON object'] }
  147. for (k of v.keys()) { if (!VERDICT_KEYS.includes(k)) { p.push("(top): unknown field '" + k + "'") } }
  148. for (k of REQUIRED_KEYS) { if (v[k] == null) { p.push("(top): field '" + k + "' is missing") } }
  149. // antcolony#24: one ruling per question of the report (an old verdict.json without `questions` is fine when the report has none)
  150. let qr = v.questions == null ? [] : v.questions
  151. if (!isList(qr)) { p.push('questions: must be a list') } else {
  152. let seen = []
  153. let qi = 0
  154. while (qi < qr.length) {
  155. let q = qr[qi]
  156. let at = 'questions[' + qi + ']'
  157. if (!isObject(q)) { p.push(at + ': must be an object') } else {
  158. for (k of q.keys()) { if (!QUESTION_KEYS.includes(k)) { p.push(at + ": unknown field '" + k + "'") } }
  159. if (hlTypeName(q.index) != 'Number' || q.index < 0 || (nq != null && q.index >= nq)) { p.push(at + ": 'index' must be the number of a question of the report (0-based)") } else { seen.push(q.index) }
  160. if (!RULINGS.includes(q.ruling)) { p.push(at + ": 'ruling' must be one of " + RULINGS.join(', ')) }
  161. if (!nonEmpty(q.reason)) { p.push(at + ": 'reason' must be a non-empty string") }
  162. if ((q.ruling == 'decided' || q.ruling == 'trivial' || q.ruling == 'internal') && !nonEmpty(q.answer)) { p.push(at + ": 'answer' (the decision, one line) is needed when the question is dropped") }
  163. }
  164. qi = qi + 1
  165. }
  166. if (nq != null) {
  167. let n = 0
  168. while (n < nq) { if (!seen.includes(n)) { p.push('questions: no ruling for question ' + n + ' of the report') } n = n + 1 }
  169. }
  170. }
  171. if (v.ticket != null && v.ticket != ref) { p.push('ticket: "' + v.ticket + '" but the report is for ' + ref) }
  172. if (v.session != null && v.session != session) { p.push('session: "' + v.session + '" but the report is session ' + session) }
  173. if (v.verdict != null && v.verdict != 'pass' && v.verdict != 'fail') { p.push('verdict: must be pass or fail, not ' + JSON.stringify(v.verdict)) }
  174. if (v.summary != null && !nonEmpty(v.summary)) { p.push('summary: must be a non-empty string') }
  175. if (v.findings != null) {
  176. if (!isList(v.findings)) { p.push('findings: must be a list') } else {
  177. let i = 0
  178. while (i < v.findings.length) {
  179. let f = v.findings[i]
  180. let at = 'findings[' + i + ']'
  181. if (!isObject(f)) { p.push(at + ': must be an object') } else {
  182. for (k of f.keys()) { if (!FINDING_KEYS.includes(k)) { p.push(at + ": unknown field '" + k + "'") } }
  183. for (k of ['claim' 'check']) { if (!nonEmpty(f[k])) { p.push(at + ": '" + k + "' must be a non-empty string") } }
  184. if (!ABOUT.includes(f.about)) { p.push(at + ": 'about' must be one of " + ABOUT.join(', ')) }
  185. if (!STATUS.includes(f.status)) { p.push(at + ": 'status' must be one of " + STATUS.join(', ')) }
  186. }
  187. i = i + 1
  188. }
  189. }
  190. }
  191. return p
  192. }
  193. // how many questions the worker's report has (null when unreadable)
  194. static questionCount = (dir) => {
  195. let rj = readJson(dir + '/report.json')
  196. if (rj.error != null || !isObject(rj.value) || !isList(rj.value.questions)) { return null }
  197. return rj.value.questions.length
  198. }
  199. static packedOf = (v) => {
  200. let o = []
  201. if (v.questions != null) { for (q of v.questions) { if (q.ruling == 'packed') { o.push(q) } } }
  202. return o
  203. }
  204. // antcolony#24: the report as it is posted — only the questions the check ruled `ask` become tickets; a dropped one
  205. // (already decided / trivial / internal) becomes a `decided` line with the check's answer
  206. static filterQuestions = (r, v) => {
  207. if (v == null || v.questions == null || v.questions.length == 0) { return r }
  208. let rulings = {}
  209. for (q of v.questions) { rulings['' + q.index] = q }
  210. let keep = []
  211. let decided = []
  212. if (r.decided != null) { for (d of r.decided) { decided.push(d) } }
  213. let i = 0
  214. while (i < r.questions.length) {
  215. let q = rulings['' + i]
  216. if (q == null || q.ruling == 'ask') { keep.push(r.questions[i]) }
  217. else if (q.ruling != 'packed') { decided.push(oneLine(q.answer, 120)) }
  218. i = i + 1
  219. }
  220. r.questions = keep
  221. r.decided = decided
  222. return r
  223. }
  224. // the effective verdict: a pass with a false / no-evidence finding is a fail → { verdict, overruled, bad }
  225. static decide = (v) => {
  226. let bad = []
  227. for (f of v.findings) { if (BAD.includes(f.status)) { bad.push(f) } }
  228. // antcolony#24: a packed question (more than one decision) sends the work back to be split
  229. if (v.verdict == 'pass' && packedOf(v).length > 0) {
  230. for (q of packedOf(v)) { bad.push({ claim = 'question ' + q.index + ' is packed: ' + q.reason about = 'other' status = 'false' check = q.reason }) }
  231. return { verdict = 'fail' overruled = true bad = bad }
  232. }
  233. if (v.verdict == 'pass' && bad.length > 0) { return { verdict = 'fail' overruled = true bad = bad } }
  234. return { verdict = v.verdict overruled = false bad = bad }
  235. }
  236. // runs the controller on a run folder. c = { dir, folder, ref, session, host, ports, bin, model,
  237. // maxTurns, mode, timeout, budget }; meta = the session.json object (gets `controller`, written back).
  238. // Calls done({ verdict (the JSON) | null, effective, overruled, reason, costUsd }) after the exit.
  239. static runController = (c, meta, done) => {
  240. let promptFile = c.dir + '/controller-prompt.md'
  241. let outFile = c.dir + '/controller-result.json'
  242. let errFile = c.dir + '/controller-stderr.log'
  243. let verdictFile = c.dir + '/verdict.json'
  244. writeFile(promptFile, promptOf(c), 384)
  245. let cs = uuid()
  246. let cm = { model = c.model maxTurns = c.maxTurns permissionMode = c.mode timeoutSeconds = c.timeout claude = c.bin claudeSession = cs started = timestamp(now()) outcome = 'running' }
  247. meta.controller = cm
  248. writeFile(c.dir + '/session.json', JSON.stringify(meta), 384)
  249. let args = ['-p' '--output-format' 'json' '--json-schema' readFile('./templates/verdict.schema.json') '--model' c.model '--max-turns' c.maxTurns '--permission-mode' c.mode '--permission-prompts' 'none' '--strict-mcp-config' '--disallowedTools' 'Edit,Write,NotebookEdit' '--session-id' cs]
  250. if (c.budget != null && c.budget != '') {
  251. args.push('--max-budget-usd')
  252. args.push(c.budget)
  253. }
  254. let p = runClaude({ label = 'controller' folder = c.folder inFile = promptFile outFile = outFile errFile = errFile bin = c.bin args = args timeout = c.timeout env = portEnv(c.ports) session = c.session ports = c.ports sandbox = c.sandbox }, (x) => {
  255. cm.ended = timestamp(now())
  256. cm.exit = x.ev.exit
  257. // mission 026: what the controller left listening on its ports was stopped (lib/cleanup.sh) — logged here
  258. if (x.cleanup.length > 0) { cm.cleanup = x.cleanup }
  259. for (l of x.lines) { console.log(' controller ' + l) }
  260. let rc = readClaude(outFile, x.ev.exit, x.timedOut, c.timeout, 'controller')
  261. let reason = rc.reason
  262. let rl = rateLimitOf(rc.value, rc.res)
  263. let v = null
  264. if (rc.cr != null) {
  265. cm.permissionModeSeen = rc.cr.mode
  266. if (rc.cr.mode != null && rc.cr.mode != c.mode) { console.log('controller: WARNING — asked for permission mode "' + c.mode + '", Claude reported "' + rc.cr.mode + '" (' + rc.cr.modes.join(', ') + ')') }
  267. }
  268. if (rc.res != null) {
  269. let um = usageMeta(rc.res)
  270. for (k of um.keys()) { cm[k] = um[k] }
  271. console.log(usageLine('CONTROLLER USAGE', rc.res))
  272. if (rc.res.structured_output == null) {
  273. if (reason == null) { reason = 'no structured verdict (Claude: ' + rc.res.subtype + (rc.res.is_error ? ', error' : '') + ', ' + rc.res.num_turns + ' turns, exit ' + x.ev.exit + ')' }
  274. } else {
  275. v = rc.res.structured_output
  276. writeFile(verdictFile, JSON.stringify(v), 384)
  277. }
  278. }
  279. let eff = null
  280. if (reason == null) {
  281. let problems = validateVerdict(v, c.ref, c.session, questionCount(c.dir))
  282. if (problems.length > 0) {
  283. reason = 'the verdict was refused (' + problems.join('; ') + ')'
  284. v = null
  285. } else {
  286. eff = decide(v)
  287. }
  288. }
  289. if (reason != null) {
  290. cm.outcome = 'no verdict: ' + reason
  291. meta.controller = cm
  292. writeFile(c.dir + '/session.json', JSON.stringify(meta), 384)
  293. console.log('controller: NO VERDICT — ' + reason)
  294. done({ verdict = null reason = reason costUsd = cm.costUsd limited = rl.limited resetMs = rl.resetMs limitText = rl.text })
  295. return null
  296. }
  297. cm.verdict = v.verdict
  298. cm.effective = eff.verdict
  299. cm.overruled = eff.overruled
  300. cm.outcome = 'verdict ' + eff.verdict
  301. meta.controller = cm
  302. writeFile(c.dir + '/session.json', JSON.stringify(meta), 384)
  303. printVerdict(v, eff)
  304. done({ verdict = v effective = eff.verdict overruled = eff.overruled reason = null costUsd = cm.costUsd limited = false })
  305. return null
  306. })
  307. console.log('controller: started — ' + c.bin + ' -p --model ' + c.model + ' --max-turns ' + c.maxTurns + ' --permission-mode ' + c.mode + ' (Edit/Write off) in ' + c.folder + ' · pid ' + p.pid + ' · claude session ' + cs)
  308. return p
  309. }
  310. static printVerdict = (v, eff) => {
  311. console.log('VERDICT: ' + eff.verdict.toUpperCase() + (eff.overruled ? ' (the controller said pass, but ' + eff.bad.length + ' finding(s) are false / no evidence → fail)' : '') + ' — ' + v.findings.length + ' finding(s)')
  312. for (f of v.findings) { console.log(' ' + f.status + ' — ' + f.about + ': ' + f.claim) }
  313. }
  314. // a verdict loaded from verdict.json (`check` on a run that has one) → the done() shape, or { reason }
  315. static loadVerdict = (dir, ref, session) => {
  316. let rj = readJson(dir + '/verdict.json')
  317. if (rj.error != null) { return { verdict = null reason = rj.error } }
  318. let problems = validateVerdict(rj.value, ref, session, questionCount(dir))
  319. if (problems.length > 0) { return { verdict = null reason = 'verdict.json refused: ' + problems.join('; ') } }
  320. let eff = decide(rj.value)
  321. printVerdict(rj.value, eff)
  322. return { verdict = rj.value effective = eff.verdict overruled = eff.overruled reason = null }
  323. }
  324. // ---- posting by the verdict ---------------------------------------------------------------------
  325. // pass → the report comment's machine line ends with this (mission 023: the controller's summary + findings stay in
  326. // runs/<session>/verdict.json — the creator reads the result, not the check)
  327. static passNote = () => { return 'controller pass' }
  328. // 1 → 'once', n → 'n times'
  329. static times = (n) => { return n == 1 ? 'once' : n + ' times' }
  330. // the fail markers in a ticket's comments → { sessions (distinct, in order), mine }
  331. static failSessions = (events, session) => {
  332. let out = []
  333. let mine = false
  334. for (e of events) {
  335. if (e.kind == 'comment' && e.text != null) {
  336. let i = e.text.indexOf('colony-verdict: ')
  337. if (i >= 0) {
  338. let rest = e.text.slice(i + 16)
  339. let j = rest.indexOf(' · fail')
  340. if (j > 0) {
  341. let s = rest.slice(0, j)
  342. if (s == session) { mine = true }
  343. if (!out.includes(s)) { out.push(s) }
  344. }
  345. }
  346. }
  347. }
  348. return { sessions = out mine = mine }
  349. }
  350. static maxFailsOf = (text) => {
  351. let n = toNumber(text)
  352. if (n == null || n < 1) { return 2 }
  353. return n
  354. }
  355. // → { ok, outcome: 'posted' | 'ready to deploy' (mission 027) | 'half done' | 'sent back' | 'question' | 'nothing new' | 'post failed' | 'post refused', question }
  356. // (mission 026: a passed report with `complete: false` → 'half done': short comment, ticket back to open)
  357. static postByVerdict = (r, cv, meta, maxFails) => {
  358. let reg = loadRegistry()
  359. if (cv.effective == 'pass') {
  360. let ok = postReport(filterQuestions(r, cv.verdict), reg, passNote())
  361. // mission 027: a project with a live site → 'ready to deploy' (built; `./colony deployed` after the deploy)
  362. return { ok = ok outcome = !ok ? 'post failed' : r.complete == false ? 'half done' : deployGate(r, reg) ? 'ready to deploy' : 'posted' }
  363. }
  364. return postFail(r, cv, meta, maxFails)
  365. }
  366. static postFail = (r, cv, meta, maxFails) => {
  367. let v = cv.verdict
  368. let tk = readToken()
  369. if (tk.error != null) {
  370. console.log('POST REFUSED — ' + tk.error)
  371. return { ok = false outcome = 'post refused' }
  372. }
  373. let token = tk.token
  374. let ref = parseRef(r.ticket)
  375. let got = getJson(ticketPath(ref.project, ref.number))
  376. if (got.status != 200) {
  377. console.log('POST REFUSED — ticket ' + r.ticket + ' → ' + got.status)
  378. return { ok = false outcome = 'post refused' }
  379. }
  380. let ticket = got.json.ticket
  381. let url = pageUrl(ticket)
  382. let fs = failSessions(got.json.events, r.session)
  383. if (fs.mine) {
  384. console.log(' HAVE the controller fail comment of session ' + r.session + ' on ' + r.ticket + ' — nothing written')
  385. console.log('POSTED — nothing new (fail already filed)')
  386. return { ok = true outcome = 'nothing new' }
  387. }
  388. let n = fs.sessions.length + 1
  389. let all = []
  390. for (s of fs.sessions) { all.push(s) }
  391. all.push(r.session)
  392. let question = null
  393. if (n >= maxFails) {
  394. // mission 023: a creator question like every other — subject "Question (<ref>): …", ≤ 5 short lines
  395. let qs = []
  396. qs.push('[' + refLabel(r.ticket) + '](' + url + ') failed its check ' + times(n) + ', so no worker starts on it any more (it is on hold).')
  397. qs.push('')
  398. qs.push('**What to do:** say in a comment there what you want, then set it back to open — or reject it.')
  399. qs.push('')
  400. qs.push('- Last check: ' + oneLine(v.summary, 150))
  401. let q = fileQuestion(token, 'colony-verdict:' + r.session + ':question', 'Question (' + r.ticket + '): the work failed its check ' + times(n) + ' — how should it go on?', qs.join(NL), r.ticket)
  402. if (q.error != null) {
  403. console.log('POST FAILED — creator question: ' + q.error + ' — re-run to continue (idempotent)')
  404. return { ok = false outcome = 'post failed' }
  405. }
  406. question = q.ticket
  407. console.log((q.created ? ' FILED ' : ' HAVE ') + 'creator question ' + question.project + question.ref + ' ' + pageUrl(question))
  408. }
  409. // mission 023: the fail comment is short — what happened, what comes next, ≤ 3 findings; the checked report,
  410. // all findings and the controller's summary stay in runs/<session>/ (verdict.json, report.json)
  411. let o = []
  412. if (question == null) { o.push((cv.overruled ? 'The result could not be confirmed' : 'The result did not hold up') + ' when it was checked — back to open for another try.') }
  413. else { o.push('The result failed its check ' + times(n) + ' — on hold until you answer [' + question.project + ' ' + question.ref + '](' + pageUrl(question) + ').') }
  414. let bad = []
  415. for (f of v.findings) { if (f.status == 'false' || f.status == 'no evidence') { bad.push(f) } }
  416. if (bad.length == 0) { bad = v.findings }
  417. if (bad.length > 0) {
  418. o.push('')
  419. let k = 0
  420. while (k < bad.length && k < 3) {
  421. o.push('- ' + (bad[k].status == 'no evidence' ? 'Not shown: ' : bad[k].status == 'false' ? 'Not true: ' : '') + oneLine(bad[k].claim, 90))
  422. k = k + 1
  423. }
  424. }
  425. o.push('')
  426. o.push('colony-verdict: ' + r.session + ' · fail · ' + n + ' of ' + maxFails + (cv.overruled ? ' · overruled' : ''))
  427. let c = postJson(ticketPath(ref.project, ref.number) + '/comments', { text = o.join(NL) }, token)
  428. if (c.status != 201) {
  429. console.log('POST FAILED — comment on ' + r.ticket + ': ' + c.status + ' ' + c.text)
  430. return { ok = false outcome = 'post failed' }
  431. }
  432. console.log(' POSTED the controller fail comment on ' + r.ticket + ' ' + url)
  433. let st = c.json.ticket.state
  434. let want = question == null ? 'open' : 'on hold'
  435. if (st == want || st == 'confirmed' || st == 'rejected') {
  436. console.log(' STATE left as "' + st + '"')
  437. } else {
  438. let text = question == null ? 'Back to open: the check failed (' + n + ' of ' + maxFails + ' tries).' : 'On hold: the check failed ' + times(n) + ' — waiting for your answer.'
  439. let s = postJson(ticketPath(ref.project, ref.number) + '/state', { state = want text = text }, token)
  440. if (s.status != 201) {
  441. console.log('POST FAILED — state of ' + r.ticket + ': ' + s.status + ' ' + s.text)
  442. return { ok = false outcome = 'post failed' }
  443. }
  444. console.log(' STATE ' + r.ticket + ': ' + st + ' → ' + want)
  445. }
  446. console.log('POSTED')
  447. return { ok = true outcome = question == null ? 'sent back' : 'question' question = question fails = n }
  448. }

Branches

Latest commits

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