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

13.6 KB

  1. // lease.hl — leases that expire (mission 020, ticket antcolony#1/#2; concept docs/scheduler-agent.md §2
  2. // "a ticket handed to an agent gets a lease; the agent's heartbeat renews it").
  3. //
  4. // tickets is the only state, so a lease is TEXT in the ticket: one plain line for the creator + ONE short machine
  5. // line (mission 023: markers must not clutter what the creator reads):
  6. // the lease itself = the `→ in progress` state event of `work`:
  7. // A worker started on this ticket.
  8. //
  9. // colony-lease: <S> · host <H> · started · ms <expiry epoch ms>
  10. // a heartbeat (only when a session outlives half the ttl) = a comment `Still working.` + `colony-lease: <S> · host <H> · renewed · ms …`
  11. // a resumed session = a comment `Work continues after a pause.` + `colony-lease: <S> · host <H> · resumed (park k) · ms …`
  12. // a parked session = a comment `Paused: … — continues after …` +
  13. // colony-parked: <S> · host <H> · phase worker|controller · resume-ms <ms> · ms <expiry>
  14. // (markers written before mission 023 also carry `lease until <ISO>` / `claude <uuid>` / `resume after <ISO>`
  15. // parts — still parsed; unknown parts are ignored.)
  16. // The LAST marker of the lease's session (from the `→ in progress` event on) decides. Past its `ms` the lease is
  17. // dead: `expireLease` sets the ticket back to `open` with a short state text and the marker
  18. // `colony-lease-expired: <S> · host <H>`. An `in progress` without a colony-lease marker (the architect's missions,
  19. // v0 leases) is never expired — nobody can know how long it may take.
  20. //
  21. // Mission 031 (ticket antcolony#1 — live 2026-09-24 21:05Z: ident#19 was deployed and handed to the creator while the
  22. // daemon's iteration was running; the iteration still acted on what it had read before and expired the finished ticket →
  23. // back to open, a second worker ran on it):
  24. // * a lease ENDS (kind `ended`, never expired) as soon as, after its `→ in progress` event, the ticket left `in progress`
  25. // (any state change, by anyone) or its session's work was finished: a `colony-deployed:` line (any session — the
  26. // deploy of the built work; also the gap between `deployed`'s comment and its state change, 146 ms live) or a
  27. // `colony-report:` (not a `· ready to deploy` one — readyOf) / `colony-verdict:` / `colony-run:` / `colony-lease-expired:`
  28. // line of THE LEASE'S session;
  29. // * the daemon never acts on its survey alone: `expireLease` / `stillLeased` RE-READ the ticket right before the write
  30. // and act only if it is STILL `in progress` with the SAME lease (same session, same expiry, same parked state);
  31. // otherwise nothing is written and the reason is returned (`skipped`), which the caller prints.
  32. // Remaining gap: the re-read and the write are two requests (a few ms apart); tickets has no conditional state change.
  33. // COLONY_TEST_HOOK_URL (tests only): GET <url>?action=…&project=…&number=…&session=… right BEFORE that re-read, waiting
  34. // for the answer — the e2e changes the ticket there to reproduce the race deterministically.
  35. import { every, now, timestamp } from 'hl:time'
  36. import { env } from 'hl:proc'
  37. import { fetch } from 'hl:fetch'
  38. import { postJson, getJson, readToken, ticketPath, userAgent } from './tickets.hl'
  39. import { NL } from './util.hl'
  40. static LEASE = 'colony-lease: '
  41. static PARKED = 'colony-parked: '
  42. // lease settings: --lease-ttl / COLONY_LEASE_TTL (s, default 7200), --heartbeat / COLONY_HEARTBEAT (s, default ttl/2).
  43. // Mission 023 ("heartbeats: as few and as short as possible"): was 3600 / ttl/3 = 3 comments per session hour; now a
  44. // session shorter than 1 h writes none, the default worker (1 h) + controller (15 min) at most one.
  45. static optionOf = (argv, flag, name, fallback) => {
  46. let i = 0
  47. while (i < argv.length) {
  48. if (argv[i] == flag && i + 1 < argv.length) { return argv[i + 1] }
  49. i = i + 1
  50. }
  51. let v = env(name)
  52. if (v == null || v == '') { return fallback }
  53. return v
  54. }
  55. static wholeOrNull = (s) => {
  56. if (s == null || s == '') { return null }
  57. let i = 0
  58. while (i < s.length) {
  59. let c = s.charCodeAt(i)
  60. if (c < 48 || c > 57) { return null }
  61. i = i + 1
  62. }
  63. let n = toNumber(s)
  64. if (n < 1) { return null }
  65. return n
  66. }
  67. // → { ttl, heartbeat } (seconds) or { error }
  68. static leaseSettings = (argv) => {
  69. let t = optionOf(argv, '--lease-ttl', 'COLONY_LEASE_TTL', '7200')
  70. let ttl = wholeOrNull(t)
  71. if (ttl == null) { return { error = '--lease-ttl must be whole seconds ≥ 1, got ' + t } }
  72. let half = (ttl - ttl % 2) / 2
  73. if (half < 1) { half = 1 }
  74. let h = optionOf(argv, '--heartbeat', 'COLONY_HEARTBEAT', '' + half)
  75. let hb = wholeOrNull(h)
  76. if (hb == null) { return { error = '--heartbeat must be whole seconds ≥ 1, got ' + h } }
  77. if (hb >= ttl) { return { error = '--heartbeat (' + hb + ' s) must be shorter than --lease-ttl (' + ttl + ' s)' } }
  78. return { ttl = ttl heartbeat = hb }
  79. }
  80. static leaseLine = (session, host, what, expiryMs) => {
  81. return LEASE + session + ' · host ' + host + ' · ' + what + ' · ms ' + expiryMs
  82. }
  83. static parkLine = (session, host, phase, resumeMs, expiryMs) => {
  84. return PARKED + session + ' · host ' + host + ' · phase ' + phase + ' · resume-ms ' + resumeMs + ' · ms ' + expiryMs
  85. }
  86. // a time for the creator: `2026-09-24 18:19 UTC`
  87. static clock = (ms) => {
  88. let t = timestamp(ms)
  89. return t.slice(0, 10) + ' ' + t.slice(11, 16) + ' UTC'
  90. }
  91. // one marker line → { kind: lease | parked, session, host, phase, claude, resumeMs, expiryMs } or null
  92. static parseLine = (line) => {
  93. let kind = null
  94. let rest = null
  95. if (line.startsWith(LEASE)) {
  96. kind = 'lease'
  97. rest = line.slice(LEASE.length)
  98. } else if (line.startsWith(PARKED)) {
  99. kind = 'parked'
  100. rest = line.slice(PARKED.length)
  101. } else { return null }
  102. let parts = rest.split(' · ')
  103. let m = { kind = kind session = parts[0].trim() host = null phase = null claude = null resumeMs = null expiryMs = null }
  104. for (p of parts) {
  105. if (p.startsWith('host ')) { m.host = p.slice(5) }
  106. else if (p.startsWith('phase ')) { m.phase = p.slice(6) }
  107. else if (p.startsWith('claude ')) { m.claude = p.slice(7) }
  108. else if (p.startsWith('resume-ms ')) { m.resumeMs = toNumber(p.slice(10)) }
  109. else if (p.startsWith('ms ')) { m.expiryMs = toNumber(p.slice(3)) }
  110. }
  111. if (m.expiryMs == null) { return null }
  112. return m
  113. }
  114. // the markers in one event text (a comment or a state text), in order
  115. static markersIn = (text) => {
  116. let out = []
  117. if (text == null) { return out }
  118. for (l of text.split(NL)) {
  119. let m = parseLine(l.trim())
  120. if (m != null) { out.push(m) }
  121. }
  122. return out
  123. }
  124. // mission 027 (deploy gate): the session whose report on a project with a live site waits for the deploy — the last
  125. // `colony-report: S · … · ready to deploy` line, unless a later `colony-deployed: S`, a lease of ANOTHER session or a state
  126. // change away from `in progress` came after it → S or null
  127. static READY_END = ' · ready to deploy'
  128. static readyOf = (events) => {
  129. let s = null
  130. for (e of events) {
  131. if (e.text != null) {
  132. for (l of e.text.split(NL)) {
  133. let t = l.trim()
  134. if (t.startsWith('colony-report: ') && t.endsWith(READY_END)) { s = t.slice(15).split(' · ')[0].trim() }
  135. else if (s != null && t.startsWith('colony-deployed: ' + s + ' ')) { s = null }
  136. else if (s != null && t.startsWith(LEASE) && !t.startsWith(LEASE + s + ' ')) { s = null }
  137. }
  138. }
  139. if (s != null && e.kind == 'state' && e.to != 'in progress') { s = null }
  140. }
  141. return s
  142. }
  143. // mission 031: the lease of `session` taken at events[at] has ENDED (see the header) → a short reason, or null
  144. static END_OWN = ['colony-report: ' 'colony-verdict: ' 'colony-run: ' 'colony-lease-expired: ']
  145. static endOf = (events, at, session) => {
  146. let j = at + 1
  147. while (j < events.length) {
  148. let e = events[j]
  149. if (e.kind == 'state' && e.to != 'in progress') {
  150. return 'the ticket was moved to "' + e.to + '"' + (e.author != null ? ' by ' + e.author : '')
  151. }
  152. if (e.text != null) {
  153. for (l of e.text.split(NL)) {
  154. let t = l.trim()
  155. if (t.startsWith('colony-deployed: ')) { return 'deployed (' + t.split(' · ')[0] + ')' }
  156. // a `… · ready to deploy` report keeps the ticket in progress on purpose (readyOf: kind ready) — not an end here
  157. let own = !t.endsWith(READY_END)
  158. for (p of END_OWN) {
  159. if (own && (t.startsWith(p + session + ' ') || t == (p + session))) { return 'its session finished (' + p + session + ')' }
  160. }
  161. }
  162. }
  163. j = j + 1
  164. }
  165. return null
  166. }
  167. // the lease of a ticket from its events (oldest first) →
  168. // { kind: none | foreign | colony | ready (mission 027: built, waiting for the deploy — never expires) | ended (mission 031:
  169. // finished or moved on — never expires), session, host, expiryMs, parked, phase, claude, resumeMs, text }
  170. static leaseOf = (events) => {
  171. let rs = readyOf(events)
  172. if (rs != null) { return { kind = 'ready' session = rs text = 'built, waiting for the deploy' } }
  173. let at = -1
  174. let i = 0
  175. while (i < events.length) {
  176. let e = events[i]
  177. if (e.kind == 'state' && e.to == 'in progress') { at = i }
  178. i = i + 1
  179. }
  180. if (at < 0) { return { kind = 'none' } }
  181. let first = markersIn(events[at].text)
  182. if (first.length == 0 || first[0].kind != 'lease') {
  183. let t = events[at].text == null ? '' : events[at].text
  184. return { kind = 'foreign' text = t.split(NL)[0] }
  185. }
  186. let session = first[0].session
  187. let ended = endOf(events, at, session)
  188. if (ended != null) { return { kind = 'ended' session = session host = first[0].host text = ended } }
  189. let last = first[0]
  190. let j = at + 1
  191. while (j < events.length) {
  192. for (m of markersIn(events[j].text)) { if (m.session == session) { last = m } }
  193. j = j + 1
  194. }
  195. return { kind = 'colony' session = session host = last.host expiryMs = last.expiryMs parked = last.kind == 'parked' phase = last.phase claude = last.claude resumeMs = last.resumeMs }
  196. }
  197. // mission 031: tests only — COLONY_TEST_HOOK_URL gets a GET right before the re-read (the answer is awaited, ignored)
  198. static testHook = (action, project, number, session) => {
  199. let u = env('COLONY_TEST_HOOK_URL')
  200. if (u == null || u == '') { return null }
  201. fetch(u + '?action=' + action + '&project=' + project + '&number=' + number + '&session=' + session, { headers = { 'user-agent' = userAgent() } timeoutMs = 20000 })
  202. return null
  203. }
  204. // mission 031: RE-READ the ticket right before acting on a lease the survey saw (`seen`, from leaseOf) →
  205. // { ok: true, lease } when it is STILL `in progress` with the same lease (same session, expiry and parked state),
  206. // else { ok: false, why } — the caller writes nothing and prints why.
  207. static stillLeased = (action, project, number, seen) => {
  208. testHook(action, project, number, seen.session)
  209. let d = getJson(ticketPath(project, number))
  210. if (d.status != 200) { return { ok = false why = 'could not re-read it (' + d.status + ')' } }
  211. let st = d.json.ticket.state
  212. if (st != 'in progress') { return { ok = false why = 'it is "' + st + '" now' } }
  213. let l = leaseOf(d.json.events)
  214. if (l.kind == 'ready') { return { ok = false why = 'it is built now, waiting for the deploy (session ' + l.session + ')' } }
  215. if (l.kind == 'ended') { return { ok = false why = 'the lease of ' + l.session + ' has ended: ' + l.text } }
  216. if (l.kind != 'colony') { return { ok = false why = 'it has no colony lease any more' } }
  217. if (l.session != seen.session) { return { ok = false why = 'another session holds it now (' + l.session + ')' } }
  218. 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)' } }
  219. return { ok = true lease = l }
  220. }
  221. // the lease is dead → the ticket back to `open`, the reason in the state text → { ok, text } or { ok: false, skipped: true,
  222. // text: why } (mission 031: only if the ticket is STILL in progress with the same dead lease — re-read first)
  223. static expireLease = (project, number, seen, token) => {
  224. let chk = stillLeased('expire', project, number, seen)
  225. if (!chk.ok) { return { ok = false skipped = true text = chk.why } }
  226. let lease = chk.lease
  227. if (now() < lease.expiryMs) { return { ok = false skipped = true text = 'the lease is alive until ' + timestamp(lease.expiryMs).slice(0, 19) + 'Z' } }
  228. 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.'
  229. let text = what + NL + NL + 'colony-lease-expired: ' + lease.session + ' · host ' + lease.host
  230. let s = postJson(ticketPath(project, number) + '/state', { state = 'open' text = text }, token)
  231. if (s.status != 201) { return { ok = false text = s.status + ' ' + s.text } }
  232. return { ok = true text = 'open' }
  233. }
  234. // a comment on the ticket with a lease marker → { ok, text }
  235. static postLease = (project, number, text, token) => {
  236. let c = postJson(ticketPath(project, number) + '/comments', { text = text }, token)
  237. if (c.status != 201) { return { ok = false text = c.status + ' ' + c.text } }
  238. return { ok = true text = 'ok' }
  239. }
  240. // the heartbeat of a running session: every `hb` s a renewal comment (expiry = now + ttl). → the timer (stop() it)
  241. // l = { project, number, ref, session, host, ttl, heartbeat }
  242. static startHeartbeat = (l) => {
  243. let t = every(l.heartbeat)
  244. on t.tick(x) {
  245. let tk = readToken()
  246. if (tk.error != null) {
  247. console.log('lease: HEARTBEAT FAILED for ' + l.ref + ' (session ' + l.session + '): ' + tk.error)
  248. return null
  249. }
  250. let exp = now() + l.ttl * 1000
  251. let r = postLease(l.project, l.number, 'Still working.' + NL + NL + leaseLine(l.session, l.host, 'renewed', exp), tk.token)
  252. if (r.ok) { console.log('lease: renewed ' + l.ref + ' (session ' + l.session + ') until ' + timestamp(exp)) }
  253. else { console.log('lease: HEARTBEAT FAILED for ' + l.ref + ' (session ' + l.session + '): ' + r.text) }
  254. return null
  255. }
  256. return t
  257. }

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