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/next.hl

11.9 KB

  1. // next.hl — `next`: which ticket gets a worker next, and why the others do not.
  2. //
  3. // Rules (docs/scheduler-agent.md §2 + "Decided 2026-09-24"):
  4. // eligible = state `open` AND the project has metadata (registry) AND that metadata names a
  5. // concept AND does not say `"workers": false` (mission 016: such a project is never
  6. // eligible, listed with that reason, no creator question); v0 priority = the OLDEST eligible (stored createdMs, ties: lower number).
  7. // `in progress` = leased: a worker (today: the architect's mission) has it — v0 has no agent,
  8. // so the lease IS the state; `on hold` = held. Both are listed, never picked.
  9. // `awaiting creator` / `confirmed` / `rejected` are only counted (the inbox / done).
  10. // Mission 022 (relations.hl): an open ticket blocked by any ticket that is not `confirmed`, or with
  11. // children (a parent; the scheduler's own creator-question children do not count) is listed,
  12. // not eligible; a `rejected` ticket whose last rejection is newer than the last colony session
  13. // is a candidate again (same rules, marked "REJECTED … → rework"), other rejected ones are counted.
  14. // Mission 030: a QUESTION ticket (relations.hl isCreatorQuestion: source `colony…:question…` or subject `Question…`) is
  15. // never work, in no state: open / rejected / on hold → listed "a question for the creator — the librarian
  16. // takes its answer (<state>) — never work", BEFORE the rework-on-rejection logic (the pre-030 live run
  17. // reworked rejected questions); awaiting creator / confirmed are counted as before.
  18. // Mission 027: an `in progress` ticket whose report waits for the deploy (lease.hl readyOf) is listed as "built —
  19. // waiting for the deploy" (`./colony ready` lists only those).
  20. // With --post, every project that has open tickets but no metadata / no concept gets ONE
  21. // question in the creator's inbox (idempotent: source `colony:no-metadata:<p>` / `colony:no-concept:<p>`).
  22. import { loadRegistry } from './registry.hl'
  23. import { allTickets, readToken, fileQuestion, pageUrl, getJson, ticketPath } from './tickets.hl'
  24. import { relationsOf, blockReason, rejectionOf, questionWhy, startsInLastHour } from './relations.hl'
  25. import { sortByNumber, NL } from './util.hl'
  26. import { readyOf } from './lease.hl'
  27. import { env } from 'hl:proc'
  28. import { now } from 'hl:time'
  29. import { prioOf, prioWord } from './keywords.hl'
  30. static refOf = (t) => { return t.project + t.ref }
  31. // the rules over every ticket → { reg, eligible (oldest first), notEligible, noMeta, noConcept, counts } or { error }
  32. static survey = () => {
  33. let reg = loadRegistry()
  34. for (e of reg.errors) { console.log('registry: ' + e) }
  35. let all = allTickets()
  36. if (all.error != null) { return { error = all.error } }
  37. // mission 022: every row by id — a child's `source` tells a creator question from real work
  38. let byId = {}
  39. for (t of all.tickets) { byId[t.id] = t }
  40. let lookup = (id) => { return byId[id] }
  41. let eligible = []
  42. let notEligible = []
  43. let noMeta = {}
  44. let noConcept = {}
  45. let counts = { awaiting = 0 confirmed = 0 rejected = 0 }
  46. for (t of all.tickets) {
  47. let m = reg.projects[t.project]
  48. // mission 030: a question ticket is never work, in no state — checked BEFORE the rework-on-rejection logic
  49. let qwhy = questionWhy(t)
  50. let qListed = qwhy != null && (t.state == 'open' || t.state == 'rejected' || t.state == 'on hold')
  51. let candidate = t.state == 'open' && qwhy == null
  52. let pre = ''
  53. if (t.state == 'rejected' && qwhy == null) {
  54. // mission 022 "Rejections are work": eligible again when the last rejection is newer than the last colony session
  55. let d = getJson(ticketPath(t.project, t.number))
  56. if (d.status != 200) {
  57. notEligible.push({ t = t why = 'rejected, but its history is unreadable (' + d.status + ')' })
  58. } else {
  59. let rj = rejectionOf(d.json.events)
  60. if (rj.eligible) {
  61. candidate = true
  62. t.rejectedNote = 'REJECTED by the creator ' + rj.rejection.when + ' (newer than the last colony session) → rework'
  63. pre = 'rejected by the creator ' + rj.rejection.when + ', but '
  64. } else {
  65. counts.rejected = counts.rejected + 1
  66. }
  67. }
  68. }
  69. if (qListed) {
  70. notEligible.push({ t = t why = qwhy + ' (' + t.state + ') — never work' })
  71. } else if (candidate) {
  72. if (m == null) {
  73. notEligible.push({ t = t why = pre + 'project ' + t.project + ' has no metadata (' + reg.dir + '/' + t.project + '.json) → question for the creator' })
  74. if (noMeta[t.project] == null) { noMeta[t.project] = [] }
  75. noMeta[t.project].push(t)
  76. } else if (!m.takesWorkers) {
  77. notEligible.push({ t = t why = pre + 'project ' + t.project + ' takes no workers ("workers": false in ' + reg.dir + '/' + t.project + '.json)' })
  78. } else if (!m.hasConcept) {
  79. notEligible.push({ t = t why = pre + 'project ' + t.project + ' has no concept → question for the creator' })
  80. if (noConcept[t.project] == null) { noConcept[t.project] = [] }
  81. noConcept[t.project].push(t)
  82. } else {
  83. // mission 022: blocked by a ticket that is not confirmed / a parent → not work
  84. let why = blockReason(relationsOf(t, lookup), pre != '')
  85. if (why != null) { notEligible.push({ t = t why = pre + why }) } else { eligible.push(t) }
  86. }
  87. } else if (t.state == 'in progress') {
  88. // mission 027: built on a project with a live site, waiting for the deploy (lease.hl readyOf)
  89. let d = getJson(ticketPath(t.project, t.number))
  90. let rs = d.status == 200 ? readyOf(d.json.events) : null
  91. if (rs != null) {
  92. t.readySession = rs
  93. notEligible.push({ t = t why = 'built — waiting for the deploy (session ' + rs + '; ./colony ready)' })
  94. } else {
  95. // mission 030: a question leased by a pre-030 session — listed; `run` never resumes it (daemon.hl)
  96. notEligible.push({ t = t why = 'leased: in progress (a worker has it)' + (qwhy == null ? '' : ' — ' + qwhy + ': never resumed') })
  97. }
  98. } else if (t.state == 'on hold') {
  99. notEligible.push({ t = t why = 'on hold' })
  100. } else if (t.state == 'awaiting creator' || t.state == 'answered') {
  101. // 'answered' = tickets sets it after the creator commented on an awaiting-creator ticket: still the creator's, not the colony's
  102. counts.awaiting = counts.awaiting + 1
  103. } else if (t.state == 'confirmed') {
  104. counts.confirmed = counts.confirmed + 1
  105. }
  106. }
  107. // antcolony#17: the creator's `/prio` (last valid one in the comments; normal = 0) — read for every eligible ticket
  108. // antcolony#30: safety net — more than COLONY_MAX_STARTS_PER_HOUR (default 4) sessions started on one ticket within the
  109. // last hour → not eligible (whatever the reason it keeps coming back); it is eligible again once the hour has passed
  110. let cap = toNumber(env('COLONY_MAX_STARTS_PER_HOUR') == null || env('COLONY_MAX_STARTS_PER_HOUR') == '' ? '4' : env('COLONY_MAX_STARTS_PER_HOUR'))
  111. let kept = []
  112. for (t of eligible) {
  113. let d = getJson(ticketPath(t.project, t.number))
  114. t.prio = d.status == 200 ? prioOf(d.json.events, t.project) : 0
  115. let n = d.status == 200 ? startsInLastHour(d.json.events, now()) : 0
  116. if (cap > 0 && n >= cap) {
  117. notEligible.push({ t = t why = 'restart limit: ' + n + ' sessions started within the last hour (max ' + cap + ', COLONY_MAX_STARTS_PER_HOUR)' })
  118. } else { kept.push(t) }
  119. }
  120. eligible = kept
  121. // highest priority first, then oldest; the same ms → the lower number
  122. // (three stable passes: a combined key would pass 2^53)
  123. sortByNumber(eligible, (t) => { return t.number })
  124. sortByNumber(eligible, (t) => { return t.createdMs })
  125. sortByNumber(eligible, (t) => { return 0 - t.prio })
  126. sortByNumber(notEligible, (e) => { return e.t.number })
  127. sortByNumber(notEligible, (e) => { return e.t.createdMs })
  128. return { reg = reg eligible = eligible notEligible = notEligible noMeta = noMeta noConcept = noConcept counts = counts }
  129. }
  130. // `cycle` (mission 019): the oldest eligible ticket whose dev folder is on `host` → { ticket } (null: none) or { error }
  131. static pickFor = (host) => {
  132. let sv = survey()
  133. if (sv.error != null) { return { error = 'next: ' + sv.error } }
  134. for (t of sv.eligible) {
  135. let m = sv.reg.projects[t.project]
  136. if (m.dev != null && m.dev.host != null && m.dev.host.toLowerCase() == host.toLowerCase()) { return { ticket = t } }
  137. console.log('cycle: next — skipping ' + refOf(t) + ' (eligible, but its dev folder is on ' + (m.dev == null ? '?' : m.dev.host) + ', this host is ' + host + ')')
  138. }
  139. return { ticket = null }
  140. }
  141. static runNext = (argv) => {
  142. let post = argv.includes('--post')
  143. let sv = survey()
  144. if (sv.error != null) {
  145. console.log('next: ' + sv.error)
  146. return false
  147. }
  148. let eligible = sv.eligible
  149. let notEligible = sv.notEligible
  150. let noMeta = sv.noMeta
  151. let noConcept = sv.noConcept
  152. let counts = sv.counts
  153. if (eligible.length == 0) {
  154. console.log('NEXT: nothing — no eligible open ticket')
  155. } else {
  156. let n = eligible[0]
  157. console.log('NEXT: ' + refOf(n) + ' "' + n.subject + '" — ' + (n.prio != 0 || eligible[eligible.length - 1].prio != 0 ? 'the highest priority (' + prioWord(n.prio) + '), then the oldest, eligible open ticket' : 'the oldest eligible open ticket') + ' (opened ' + n.created + ', ' + eligible.length + ' eligible) ' + pageUrl(n) + (n.rejectedNote != null ? ' — ' + n.rejectedNote : ''))
  158. }
  159. if (eligible.length > 1) {
  160. console.log('')
  161. console.log('Eligible, waiting (highest priority, then oldest first):')
  162. let i = 1
  163. while (i < eligible.length) {
  164. let t = eligible[i]
  165. console.log(' ' + refOf(t) + ' "' + t.subject + '" — opened ' + t.created + (t.rejectedNote != null ? ' — ' + t.rejectedNote : ''))
  166. i = i + 1
  167. }
  168. }
  169. if (notEligible.length > 0) {
  170. console.log('')
  171. console.log('Not eligible:')
  172. for (e of notEligible) { console.log(' ' + refOf(e.t) + ' "' + e.t.subject + '" — ' + e.why) }
  173. }
  174. console.log('')
  175. console.log('Not considered: ' + counts.awaiting + ' awaiting creator (inbox), ' + counts.confirmed + ' confirmed, ' + counts.rejected + ' rejected' + (counts.rejected > 0 ? ' (no rejection newer than the last colony session)' : ''))
  176. let qs = []
  177. // mission 023: creator-readable — subject "Question: …", ≤ 5 short lines, what to do first
  178. for (p of noMeta.keys()) { qs.push({ source = 'colony:no-metadata:' + p subject = 'Question: project ' + p + ' is not set up for the colony yet — where does its code live?' tickets = noMeta[p] first = 'No worker starts on project ' + p + ': the colony does not know where its code lives.' todo = 'tell the architect where the code lives and what it depends on — the architect sets it up.' }) }
  179. for (p of noConcept.keys()) { qs.push({ source = 'colony:no-concept:' + p subject = 'Question: project ' + p + ' has no written concept yet — can we write one?' tickets = noConcept[p] first = 'No worker starts on project ' + p + ' until it has a written concept.' todo = 'write the concept with the architect, then answer here.' }) }
  180. if (qs.length == 0) { return true }
  181. console.log('')
  182. if (!post) {
  183. console.log('Questions for the creator (' + qs.length + '; `next --post` files them in the inbox):')
  184. for (q of qs) { console.log(' ' + q.subject) }
  185. return true
  186. }
  187. let tk = readToken()
  188. if (tk.error != null) {
  189. console.log('next --post: ' + tk.error)
  190. return false
  191. }
  192. console.log('Questions for the creator:')
  193. for (q of qs) {
  194. let links = []
  195. let k = 0
  196. while (k < q.tickets.length && k < 3) {
  197. let t = q.tickets[k]
  198. links.push('[' + t.project + ' ' + t.ref + '](' + pageUrl(t) + ')')
  199. k = k + 1
  200. }
  201. let waiting = links.join(', ') + (q.tickets.length > 3 ? ' and ' + (q.tickets.length - 3) + ' more' : '')
  202. let summary = q.first + NL + NL + '**What to do:** ' + q.todo + NL + NL + '- Waiting: ' + waiting
  203. let r = fileQuestion(tk.token, q.source, q.subject, summary, null)
  204. if (r.error != null) { console.log(' FAILED ' + q.subject + ': ' + r.error) }
  205. else if (r.created) { console.log(' FILED ' + r.ticket.project + r.ticket.ref + ' ' + q.subject + ' ' + pageUrl(r.ticket)) }
  206. else { console.log(' HAVE ' + r.ticket.project + r.ticket.ref + ' ' + q.subject + ' (already asked, nothing written)') }
  207. }
  208. return true
  209. }

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