antcolony
All repositories: gitoria
11.9 KB
// next.hl — `next`: which ticket gets a worker next, and why the others do not.//// Rules (docs/scheduler-agent.md §2 + "Decided 2026-09-24"):// eligible = state `open` AND the project has metadata (registry) AND that metadata names a// concept AND does not say `"workers": false` (mission 016: such a project is never// eligible, listed with that reason, no creator question); v0 priority = the OLDEST eligible (stored createdMs, ties: lower number).// `in progress` = leased: a worker (today: the architect's mission) has it — v0 has no agent,// so the lease IS the state; `on hold` = held. Both are listed, never picked.// `awaiting creator` / `confirmed` / `rejected` are only counted (the inbox / done).// Mission 022 (relations.hl): an open ticket blocked by any ticket that is not `confirmed`, or with// children (a parent; the scheduler's own creator-question children do not count) is listed,// not eligible; a `rejected` ticket whose last rejection is newer than the last colony session// is a candidate again (same rules, marked "REJECTED … → rework"), other rejected ones are counted.// Mission 030: a QUESTION ticket (relations.hl isCreatorQuestion: source `colony…:question…` or subject `Question…`) is// never work, in no state: open / rejected / on hold → listed "a question for the creator — the librarian// takes its answer (<state>) — never work", BEFORE the rework-on-rejection logic (the pre-030 live run// reworked rejected questions); awaiting creator / confirmed are counted as before.// Mission 027: an `in progress` ticket whose report waits for the deploy (lease.hl readyOf) is listed as "built —// waiting for the deploy" (`./colony ready` lists only those).// With --post, every project that has open tickets but no metadata / no concept gets ONE// question in the creator's inbox (idempotent: source `colony:no-metadata:<p>` / `colony:no-concept:<p>`).import { loadRegistry } from './registry.hl'import { allTickets, readToken, fileQuestion, pageUrl, getJson, ticketPath } from './tickets.hl'import { relationsOf, blockReason, rejectionOf, questionWhy, startsInLastHour } from './relations.hl'import { sortByNumber, NL } from './util.hl'import { readyOf } from './lease.hl'import { env } from 'hl:proc'import { now } from 'hl:time'import { prioOf, prioWord } from './keywords.hl'static refOf = (t) => { return t.project + t.ref }// the rules over every ticket → { reg, eligible (oldest first), notEligible, noMeta, noConcept, counts } or { error }static survey = () => {let reg = loadRegistry()for (e of reg.errors) { console.log('registry: ' + e) }let all = allTickets()if (all.error != null) { return { error = all.error } }// mission 022: every row by id — a child's `source` tells a creator question from real worklet byId = {}for (t of all.tickets) { byId[t.id] = t }let lookup = (id) => { return byId[id] }let eligible = []let notEligible = []let noMeta = {}let noConcept = {}let counts = { awaiting = 0 confirmed = 0 rejected = 0 }for (t of all.tickets) {let m = reg.projects[t.project]// mission 030: a question ticket is never work, in no state — checked BEFORE the rework-on-rejection logiclet qwhy = questionWhy(t)let qListed = qwhy != null && (t.state == 'open' || t.state == 'rejected' || t.state == 'on hold')let candidate = t.state == 'open' && qwhy == nulllet pre = ''if (t.state == 'rejected' && qwhy == null) {// mission 022 "Rejections are work": eligible again when the last rejection is newer than the last colony sessionlet d = getJson(ticketPath(t.project, t.number))if (d.status != 200) {notEligible.push({ t = t why = 'rejected, but its history is unreadable (' + d.status + ')' })} else {let rj = rejectionOf(d.json.events)if (rj.eligible) {candidate = truet.rejectedNote = 'REJECTED by the creator ' + rj.rejection.when + ' (newer than the last colony session) → rework'pre = 'rejected by the creator ' + rj.rejection.when + ', but '} else {counts.rejected = counts.rejected + 1}}}if (qListed) {notEligible.push({ t = t why = qwhy + ' (' + t.state + ') — never work' })} else if (candidate) {if (m == null) {notEligible.push({ t = t why = pre + 'project ' + t.project + ' has no metadata (' + reg.dir + '/' + t.project + '.json) → question for the creator' })if (noMeta[t.project] == null) { noMeta[t.project] = [] }noMeta[t.project].push(t)} else if (!m.takesWorkers) {notEligible.push({ t = t why = pre + 'project ' + t.project + ' takes no workers ("workers": false in ' + reg.dir + '/' + t.project + '.json)' })} else if (!m.hasConcept) {notEligible.push({ t = t why = pre + 'project ' + t.project + ' has no concept → question for the creator' })if (noConcept[t.project] == null) { noConcept[t.project] = [] }noConcept[t.project].push(t)} else {// mission 022: blocked by a ticket that is not confirmed / a parent → not worklet why = blockReason(relationsOf(t, lookup), pre != '')if (why != null) { notEligible.push({ t = t why = pre + why }) } else { eligible.push(t) }}} else if (t.state == 'in progress') {// mission 027: built on a project with a live site, waiting for the deploy (lease.hl readyOf)let d = getJson(ticketPath(t.project, t.number))let rs = d.status == 200 ? readyOf(d.json.events) : nullif (rs != null) {t.readySession = rsnotEligible.push({ t = t why = 'built — waiting for the deploy (session ' + rs + '; ./colony ready)' })} else {// mission 030: a question leased by a pre-030 session — listed; `run` never resumes it (daemon.hl)notEligible.push({ t = t why = 'leased: in progress (a worker has it)' + (qwhy == null ? '' : ' — ' + qwhy + ': never resumed') })}} else if (t.state == 'on hold') {notEligible.push({ t = t why = 'on hold' })} else if (t.state == 'awaiting creator' || t.state == 'answered') {// 'answered' = tickets sets it after the creator commented on an awaiting-creator ticket: still the creator's, not the colony'scounts.awaiting = counts.awaiting + 1} else if (t.state == 'confirmed') {counts.confirmed = counts.confirmed + 1}}// antcolony#17: the creator's `/prio` (last valid one in the comments; normal = 0) — read for every eligible ticket// antcolony#30: safety net — more than COLONY_MAX_STARTS_PER_HOUR (default 4) sessions started on one ticket within the// last hour → not eligible (whatever the reason it keeps coming back); it is eligible again once the hour has passedlet cap = toNumber(env('COLONY_MAX_STARTS_PER_HOUR') == null || env('COLONY_MAX_STARTS_PER_HOUR') == '' ? '4' : env('COLONY_MAX_STARTS_PER_HOUR'))let kept = []for (t of eligible) {let d = getJson(ticketPath(t.project, t.number))t.prio = d.status == 200 ? prioOf(d.json.events, t.project) : 0let n = d.status == 200 ? startsInLastHour(d.json.events, now()) : 0if (cap > 0 && n >= cap) {notEligible.push({ t = t why = 'restart limit: ' + n + ' sessions started within the last hour (max ' + cap + ', COLONY_MAX_STARTS_PER_HOUR)' })} else { kept.push(t) }}eligible = kept// highest priority first, then oldest; the same ms → the lower number// (three stable passes: a combined key would pass 2^53)sortByNumber(eligible, (t) => { return t.number })sortByNumber(eligible, (t) => { return t.createdMs })sortByNumber(eligible, (t) => { return 0 - t.prio })sortByNumber(notEligible, (e) => { return e.t.number })sortByNumber(notEligible, (e) => { return e.t.createdMs })return { reg = reg eligible = eligible notEligible = notEligible noMeta = noMeta noConcept = noConcept counts = counts }}// `cycle` (mission 019): the oldest eligible ticket whose dev folder is on `host` → { ticket } (null: none) or { error }static pickFor = (host) => {let sv = survey()if (sv.error != null) { return { error = 'next: ' + sv.error } }for (t of sv.eligible) {let m = sv.reg.projects[t.project]if (m.dev != null && m.dev.host != null && m.dev.host.toLowerCase() == host.toLowerCase()) { return { ticket = t } }console.log('cycle: next — skipping ' + refOf(t) + ' (eligible, but its dev folder is on ' + (m.dev == null ? '?' : m.dev.host) + ', this host is ' + host + ')')}return { ticket = null }}static runNext = (argv) => {let post = argv.includes('--post')let sv = survey()if (sv.error != null) {console.log('next: ' + sv.error)return false}let eligible = sv.eligiblelet notEligible = sv.notEligiblelet noMeta = sv.noMetalet noConcept = sv.noConceptlet counts = sv.countsif (eligible.length == 0) {console.log('NEXT: nothing — no eligible open ticket')} else {let n = eligible[0]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 : ''))}if (eligible.length > 1) {console.log('')console.log('Eligible, waiting (highest priority, then oldest first):')let i = 1while (i < eligible.length) {let t = eligible[i]console.log(' ' + refOf(t) + ' "' + t.subject + '" — opened ' + t.created + (t.rejectedNote != null ? ' — ' + t.rejectedNote : ''))i = i + 1}}if (notEligible.length > 0) {console.log('')console.log('Not eligible:')for (e of notEligible) { console.log(' ' + refOf(e.t) + ' "' + e.t.subject + '" — ' + e.why) }}console.log('')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)' : ''))let qs = []// mission 023: creator-readable — subject "Question: …", ≤ 5 short lines, what to do firstfor (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.' }) }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.' }) }if (qs.length == 0) { return true }console.log('')if (!post) {console.log('Questions for the creator (' + qs.length + '; `next --post` files them in the inbox):')for (q of qs) { console.log(' ' + q.subject) }return true}let tk = readToken()if (tk.error != null) {console.log('next --post: ' + tk.error)return false}console.log('Questions for the creator:')for (q of qs) {let links = []let k = 0while (k < q.tickets.length && k < 3) {let t = q.tickets[k]links.push('[' + t.project + ' ' + t.ref + '](' + pageUrl(t) + ')')k = k + 1}let waiting = links.join(', ') + (q.tickets.length > 3 ? ' and ' + (q.tickets.length - 3) + ' more' : '')let summary = q.first + NL + NL + '**What to do:** ' + q.todo + NL + NL + '- Waiting: ' + waitinglet r = fileQuestion(tk.token, q.source, q.subject, summary, null)if (r.error != null) { console.log(' FAILED ' + q.subject + ': ' + r.error) }else if (r.created) { console.log(' FILED ' + r.ticket.project + r.ticket.ref + ' ' + q.subject + ' ' + pageUrl(r.ticket)) }else { console.log(' HAVE ' + r.ticket.project + r.ticket.ref + ' ' + q.subject + ' (already asked, nothing written)') }}return true}
Branches
- mainmain branch
Latest commits
- 3a4d0324antcolony#37: a too-long report gets up to 3 fix tries, finished work is never thrown away for lengthmre
- a6af7883tracker: worker box sees calendar.worldapi.org (login to copy)mre
- c613d26btemplates: bridges to external components (login.js for ident's selector) are allowed (creator 2026-09-27)mre
- 9062978ctracker: worker box sees /media/STORAGE/projects/old-tracker read-only (tracker#2 source data)mre
- 7f9660eeState of 2026-09-27, before the move to gitoriamre