antcolony
All repositories: gitoria
13.6 KB
// lease.hl — leases that expire (mission 020, ticket antcolony#1/#2; concept docs/scheduler-agent.md §2// "a ticket handed to an agent gets a lease; the agent's heartbeat renews it").//// tickets is the only state, so a lease is TEXT in the ticket: one plain line for the creator + ONE short machine// line (mission 023: markers must not clutter what the creator reads):// the lease itself = the `→ in progress` state event of `work`:// A worker started on this ticket.//// colony-lease: <S> · host <H> · started · ms <expiry epoch ms>// a heartbeat (only when a session outlives half the ttl) = a comment `Still working.` + `colony-lease: <S> · host <H> · renewed · ms …`// a resumed session = a comment `Work continues after a pause.` + `colony-lease: <S> · host <H> · resumed (park k) · ms …`// a parked session = a comment `Paused: … — continues after …` +// colony-parked: <S> · host <H> · phase worker|controller · resume-ms <ms> · ms <expiry>// (markers written before mission 023 also carry `lease until <ISO>` / `claude <uuid>` / `resume after <ISO>`// parts — still parsed; unknown parts are ignored.)// The LAST marker of the lease's session (from the `→ in progress` event on) decides. Past its `ms` the lease is// dead: `expireLease` sets the ticket back to `open` with a short state text and the marker// `colony-lease-expired: <S> · host <H>`. An `in progress` without a colony-lease marker (the architect's missions,// v0 leases) is never expired — nobody can know how long it may take.//// Mission 031 (ticket antcolony#1 — live 2026-09-24 21:05Z: ident#19 was deployed and handed to the creator while the// daemon's iteration was running; the iteration still acted on what it had read before and expired the finished ticket →// back to open, a second worker ran on it):// * a lease ENDS (kind `ended`, never expired) as soon as, after its `→ in progress` event, the ticket left `in progress`// (any state change, by anyone) or its session's work was finished: a `colony-deployed:` line (any session — the// deploy of the built work; also the gap between `deployed`'s comment and its state change, 146 ms live) or a// `colony-report:` (not a `· ready to deploy` one — readyOf) / `colony-verdict:` / `colony-run:` / `colony-lease-expired:`// line of THE LEASE'S session;// * the daemon never acts on its survey alone: `expireLease` / `stillLeased` RE-READ the ticket right before the write// and act only if it is STILL `in progress` with the SAME lease (same session, same expiry, same parked state);// otherwise nothing is written and the reason is returned (`skipped`), which the caller prints.// Remaining gap: the re-read and the write are two requests (a few ms apart); tickets has no conditional state change.// COLONY_TEST_HOOK_URL (tests only): GET <url>?action=…&project=…&number=…&session=… right BEFORE that re-read, waiting// for the answer — the e2e changes the ticket there to reproduce the race deterministically.import { every, now, timestamp } from 'hl:time'import { env } from 'hl:proc'import { fetch } from 'hl:fetch'import { postJson, getJson, readToken, ticketPath, userAgent } from './tickets.hl'import { NL } from './util.hl'static LEASE = 'colony-lease: 'static PARKED = 'colony-parked: '// lease settings: --lease-ttl / COLONY_LEASE_TTL (s, default 7200), --heartbeat / COLONY_HEARTBEAT (s, default ttl/2).// Mission 023 ("heartbeats: as few and as short as possible"): was 3600 / ttl/3 = 3 comments per session hour; now a// session shorter than 1 h writes none, the default worker (1 h) + controller (15 min) at most one.static optionOf = (argv, flag, name, fallback) => {let i = 0while (i < argv.length) {if (argv[i] == flag && i + 1 < argv.length) { return argv[i + 1] }i = i + 1}let v = env(name)if (v == null || v == '') { return fallback }return v}static wholeOrNull = (s) => {if (s == null || s == '') { return null }let i = 0while (i < s.length) {let c = s.charCodeAt(i)if (c < 48 || c > 57) { return null }i = i + 1}let n = toNumber(s)if (n < 1) { return null }return n}// → { ttl, heartbeat } (seconds) or { error }static leaseSettings = (argv) => {let t = optionOf(argv, '--lease-ttl', 'COLONY_LEASE_TTL', '7200')let ttl = wholeOrNull(t)if (ttl == null) { return { error = '--lease-ttl must be whole seconds ≥ 1, got ' + t } }let half = (ttl - ttl % 2) / 2if (half < 1) { half = 1 }let h = optionOf(argv, '--heartbeat', 'COLONY_HEARTBEAT', '' + half)let hb = wholeOrNull(h)if (hb == null) { return { error = '--heartbeat must be whole seconds ≥ 1, got ' + h } }if (hb >= ttl) { return { error = '--heartbeat (' + hb + ' s) must be shorter than --lease-ttl (' + ttl + ' s)' } }return { ttl = ttl heartbeat = hb }}static leaseLine = (session, host, what, expiryMs) => {return LEASE + session + ' · host ' + host + ' · ' + what + ' · ms ' + expiryMs}static parkLine = (session, host, phase, resumeMs, expiryMs) => {return PARKED + session + ' · host ' + host + ' · phase ' + phase + ' · resume-ms ' + resumeMs + ' · ms ' + expiryMs}// a time for the creator: `2026-09-24 18:19 UTC`static clock = (ms) => {let t = timestamp(ms)return t.slice(0, 10) + ' ' + t.slice(11, 16) + ' UTC'}// one marker line → { kind: lease | parked, session, host, phase, claude, resumeMs, expiryMs } or nullstatic parseLine = (line) => {let kind = nulllet rest = nullif (line.startsWith(LEASE)) {kind = 'lease'rest = line.slice(LEASE.length)} else if (line.startsWith(PARKED)) {kind = 'parked'rest = line.slice(PARKED.length)} else { return null }let parts = rest.split(' · ')let m = { kind = kind session = parts[0].trim() host = null phase = null claude = null resumeMs = null expiryMs = null }for (p of parts) {if (p.startsWith('host ')) { m.host = p.slice(5) }else if (p.startsWith('phase ')) { m.phase = p.slice(6) }else if (p.startsWith('claude ')) { m.claude = p.slice(7) }else if (p.startsWith('resume-ms ')) { m.resumeMs = toNumber(p.slice(10)) }else if (p.startsWith('ms ')) { m.expiryMs = toNumber(p.slice(3)) }}if (m.expiryMs == null) { return null }return m}// the markers in one event text (a comment or a state text), in orderstatic markersIn = (text) => {let out = []if (text == null) { return out }for (l of text.split(NL)) {let m = parseLine(l.trim())if (m != null) { out.push(m) }}return out}// mission 027 (deploy gate): the session whose report on a project with a live site waits for the deploy — the last// `colony-report: S · … · ready to deploy` line, unless a later `colony-deployed: S`, a lease of ANOTHER session or a state// change away from `in progress` came after it → S or nullstatic READY_END = ' · ready to deploy'static readyOf = (events) => {let s = nullfor (e of events) {if (e.text != null) {for (l of e.text.split(NL)) {let t = l.trim()if (t.startsWith('colony-report: ') && t.endsWith(READY_END)) { s = t.slice(15).split(' · ')[0].trim() }else if (s != null && t.startsWith('colony-deployed: ' + s + ' ')) { s = null }else if (s != null && t.startsWith(LEASE) && !t.startsWith(LEASE + s + ' ')) { s = null }}}if (s != null && e.kind == 'state' && e.to != 'in progress') { s = null }}return s}// mission 031: the lease of `session` taken at events[at] has ENDED (see the header) → a short reason, or nullstatic END_OWN = ['colony-report: ' 'colony-verdict: ' 'colony-run: ' 'colony-lease-expired: ']static endOf = (events, at, session) => {let j = at + 1while (j < events.length) {let e = events[j]if (e.kind == 'state' && e.to != 'in progress') {return 'the ticket was moved to "' + e.to + '"' + (e.author != null ? ' by ' + e.author : '')}if (e.text != null) {for (l of e.text.split(NL)) {let t = l.trim()if (t.startsWith('colony-deployed: ')) { return 'deployed (' + t.split(' · ')[0] + ')' }// a `… · ready to deploy` report keeps the ticket in progress on purpose (readyOf: kind ready) — not an end herelet own = !t.endsWith(READY_END)for (p of END_OWN) {if (own && (t.startsWith(p + session + ' ') || t == (p + session))) { return 'its session finished (' + p + session + ')' }}}}j = j + 1}return null}// the lease of a ticket from its events (oldest first) →// { kind: none | foreign | colony | ready (mission 027: built, waiting for the deploy — never expires) | ended (mission 031:// finished or moved on — never expires), session, host, expiryMs, parked, phase, claude, resumeMs, text }static leaseOf = (events) => {let rs = readyOf(events)if (rs != null) { return { kind = 'ready' session = rs text = 'built, waiting for the deploy' } }let at = -1let i = 0while (i < events.length) {let e = events[i]if (e.kind == 'state' && e.to == 'in progress') { at = i }i = i + 1}if (at < 0) { return { kind = 'none' } }let first = markersIn(events[at].text)if (first.length == 0 || first[0].kind != 'lease') {let t = events[at].text == null ? '' : events[at].textreturn { kind = 'foreign' text = t.split(NL)[0] }}let session = first[0].sessionlet ended = endOf(events, at, session)if (ended != null) { return { kind = 'ended' session = session host = first[0].host text = ended } }let last = first[0]let j = at + 1while (j < events.length) {for (m of markersIn(events[j].text)) { if (m.session == session) { last = m } }j = j + 1}return { kind = 'colony' session = session host = last.host expiryMs = last.expiryMs parked = last.kind == 'parked' phase = last.phase claude = last.claude resumeMs = last.resumeMs }}// mission 031: tests only — COLONY_TEST_HOOK_URL gets a GET right before the re-read (the answer is awaited, ignored)static testHook = (action, project, number, session) => {let u = env('COLONY_TEST_HOOK_URL')if (u == null || u == '') { return null }fetch(u + '?action=' + action + '&project=' + project + '&number=' + number + '&session=' + session, { headers = { 'user-agent' = userAgent() } timeoutMs = 20000 })return null}// mission 031: RE-READ the ticket right before acting on a lease the survey saw (`seen`, from leaseOf) →// { ok: true, lease } when it is STILL `in progress` with the same lease (same session, expiry and parked state),// else { ok: false, why } — the caller writes nothing and prints why.static stillLeased = (action, project, number, seen) => {testHook(action, project, number, seen.session)let d = getJson(ticketPath(project, number))if (d.status != 200) { return { ok = false why = 'could not re-read it (' + d.status + ')' } }let st = d.json.ticket.stateif (st != 'in progress') { return { ok = false why = 'it is "' + st + '" now' } }let l = leaseOf(d.json.events)if (l.kind == 'ready') { return { ok = false why = 'it is built now, waiting for the deploy (session ' + l.session + ')' } }if (l.kind == 'ended') { return { ok = false why = 'the lease of ' + l.session + ' has ended: ' + l.text } }if (l.kind != 'colony') { return { ok = false why = 'it has no colony lease any more' } }if (l.session != seen.session) { return { ok = false why = 'another session holds it now (' + l.session + ')' } }if (l.expiryMs != seen.expiryMs || l.parked != seen.parked) { return { ok = false why = 'the lease of ' + l.session + ' changed (now ' + (l.parked ? 'parked, ' : '') + 'until ' + timestamp(l.expiryMs).slice(0, 19) + 'Z)' } }return { ok = true lease = l }}// the lease is dead → the ticket back to `open`, the reason in the state text → { ok, text } or { ok: false, skipped: true,// text: why } (mission 031: only if the ticket is STILL in progress with the same dead lease — re-read first)static expireLease = (project, number, seen, token) => {let chk = stillLeased('expire', project, number, seen)if (!chk.ok) { return { ok = false skipped = true text = chk.why } }let lease = chk.leaseif (now() < lease.expiryMs) { return { ok = false skipped = true text = 'the lease is alive until ' + timestamp(lease.expiryMs).slice(0, 19) + 'Z' } }let what = lease.parked ? 'Back to open: the paused work was never continued — a new worker will pick it up.' : 'Back to open: the worker stopped answering (its computer crashed or went offline) — a new worker will pick it up.'let text = what + NL + NL + 'colony-lease-expired: ' + lease.session + ' · host ' + lease.hostlet s = postJson(ticketPath(project, number) + '/state', { state = 'open' text = text }, token)if (s.status != 201) { return { ok = false text = s.status + ' ' + s.text } }return { ok = true text = 'open' }}// a comment on the ticket with a lease marker → { ok, text }static postLease = (project, number, text, token) => {let c = postJson(ticketPath(project, number) + '/comments', { text = text }, token)if (c.status != 201) { return { ok = false text = c.status + ' ' + c.text } }return { ok = true text = 'ok' }}// the heartbeat of a running session: every `hb` s a renewal comment (expiry = now + ttl). → the timer (stop() it)// l = { project, number, ref, session, host, ttl, heartbeat }static startHeartbeat = (l) => {let t = every(l.heartbeat)on t.tick(x) {let tk = readToken()if (tk.error != null) {console.log('lease: HEARTBEAT FAILED for ' + l.ref + ' (session ' + l.session + '): ' + tk.error)return null}let exp = now() + l.ttl * 1000let r = postLease(l.project, l.number, 'Still working.' + NL + NL + leaseLine(l.session, l.host, 'renewed', exp), tk.token)if (r.ok) { console.log('lease: renewed ' + l.ref + ' (session ' + l.session + ') until ' + timestamp(exp)) }else { console.log('lease: HEARTBEAT FAILED for ' + l.ref + ' (session ' + l.session + '): ' + r.text) }return null}return t}
Branches
- mainmain branch