gitoriaLog in with ident

antcolony

All repositories: gitoria

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

42.4 KB

  1. // daemon.hl — `run`: the scheduler as a long-running loop (mission 020, tickets antcolony#1/#2, build order 4
  2. // part 2; concept docs/scheduler-agent.md §2 leases, §3 quota / parking / ports). Still ONE host, no WebSocket.
  3. //
  4. // Every --interval s (and once at the start) ONE iteration:
  5. // 1. survey (next.hl) + the lease of every `in progress` ticket (lease.hl): an expired colony lease → the
  6. // ticket back to `open` with a comment; a parked session of THIS host whose resume time is over → a resume
  7. // candidate; a live lease → skipped (never a second session on a leased ticket, also after a restart);
  8. // 2. candidates = due resumes first, then the eligible open tickets whose dev folder is on this host (oldest
  9. // first) and not in use by a running session of this run;
  10. // 3. free slots = --parallel − running; nothing to start or no slot → no quota probe;
  11. // 4. quota: `claude -p /usage` (quota.hl, costs nothing) — at or above --reserve % of the 5 h window → no start;
  12. // above --week-limit % (default 84, creator 2026-09-24) of the WEEKLY usage, or weekly unknown → no start;
  13. // 100 turns either check off (--reserve 100: never blocks; --week-limit 100: never blocks, also when unknown);
  14. // 5. start: a port range per session from --port-pool (--ports-per-session each, disjoint while running) →
  15. // `work` (lease, worker, controller, post by the verdict) or `runResume`;
  16. // 6. ONE line: `ITER <n> <time> · quota … · running … · started / resumed / expired … · skipped: …`.
  17. // Mission 031: step 1 and the resume in 5 act on what the survey READ, seconds (or a librarian pass) earlier — so both
  18. // re-read the ticket right before they write (lease.hl expireLease / stillLeased) and write nothing unless it is STILL
  19. // `in progress` with the same lease; else `NOT expired …` / `NOT resumed …: re-read right before — <why> → nothing written`.
  20. // A lease that has ENDED (the ticket left `in progress`, a `colony-deployed:` line, the session's own report …) is
  21. // listed `… the lease of S has ended (…) → never expired`.
  22. // A finished session prints `SESSION END <ref> <session> · <outcome> · cost …`.
  23. // STOP: the `colony` wrapper turns SIGTERM/SIGINT into the file $COLONY_STOP_FILE ('finish' / 'park'); the run
  24. // checks it twice a second. First signal: start nothing more, let the running sessions finish, then exit.
  25. // Second signal: kill the running sessions' processes and PARK them (resumed by the next `run`). Never silent.
  26. // `--once`: one iteration, wait for what it started, exit. Refuses the LIVE tickets server without `--live`.
  27. // Mission 029 (antcolony#21): every iteration STARTS with the LIBRARIAN (librarian.hl), awaited: it reads the creator's new
  28. // ticket texts and files decisions (no model call when nothing is new; quiet then); only afterwards the iteration picks and
  29. // starts — a pass that failed or left a text unfiled starts nothing. `--librarian off` / COLONY_LIBRARIAN=off switches it
  30. // off; `--librarian-model`, `--librarian-max` (texts per pass, 0 = all).
  31. // Mission 034 (antcolony#25): with the librarian on, the QUOTA is read before it (`claude -p /usage`, $0): at or above the
  32. // start threshold (a reached limit = 100 % always) → no librarian call, no start. A model call that still hits the usage
  33. // limit (the librarian's, or a worker / controller parked on it) → `LIMIT:` pause until the reset (15 min when unknown):
  34. // no librarian, no probe, no start; one line when it begins, one when it ends (README "Usage limit").
  35. import { env } from 'hl:proc'
  36. import { exists, readFile, mkDir } from 'hl:fs'
  37. import { every, now, timestamp } from 'hl:time'
  38. import { survey } from './next.hl'
  39. import { baseUrl, readToken, getJson, ticketPath } from './tickets.hl'
  40. import { runWork, runResume, hostName, runsDir } from './work.hl'
  41. import { setting, isWholeNumber, round4 } from './claude.hl'
  42. import { leaseOf, expireLease, stillLeased, leaseSettings } from './lease.hl'
  43. import { probeUsage } from './quota.hl'
  44. import { portRange, newSession } from './brief.hl'
  45. import { librarianPass, librarianSettings } from './librarian.hl'
  46. import { keywordPass } from './keywords.hl'
  47. import { digestTick } from './status.hl'
  48. import { questionWhy } from './relations.hl'
  49. import { buildBrief, PORTS_MARKER } from './brief.hl'
  50. import { readAgentToken } from './tickets.hl'
  51. import { jsonErrorAt } from './jsoncheck.hl'
  52. import { parseCapabilities, missingCapabilities } from './util.hl'
  53. import AgentServer from './AgentServer.hl'
  54. // options `run` hands to every `work` / resume unchanged
  55. static PASS = ['--model' '--max-turns' '--permission-mode' '--timeout' '--max-budget-usd' '--claude' '--controller-claude' '--controller-model' '--controller-max-turns' '--controller-timeout' '--max-fails' '--lease-ttl' '--heartbeat' '--finalize-max-turns' '--finalize-timeout']
  56. static LIVE_HOST = 'tickets.worldapi.org'
  57. // mission 034: a usage limit without a known reset time pauses the librarian + starts this long, then tries once
  58. static LIMIT_RETRY_MS = 900000
  59. // the host part of a URL, lower case
  60. static hostOf = (url) => {
  61. let u = url.toLowerCase()
  62. let i = u.indexOf('://')
  63. if (i >= 0) { u = u.slice(i + 3) }
  64. let j = u.indexOf('/')
  65. if (j >= 0) { u = u.slice(0, j) }
  66. let k = u.indexOf(':')
  67. if (k >= 0) { u = u.slice(0, k) }
  68. return u
  69. }
  70. static passOptions = (argv) => {
  71. let out = []
  72. let i = 0
  73. while (i < argv.length) {
  74. if (PASS.includes(argv[i]) && i + 1 < argv.length) {
  75. out.push(argv[i])
  76. out.push(argv[i + 1])
  77. i = i + 2
  78. } else { i = i + 1 }
  79. }
  80. return out
  81. }
  82. static money = (n) => {
  83. if (n == null) { return '–' }
  84. return '$' + round4(n)
  85. }
  86. static ago = (ms) => { return timestamp(ms).slice(0, 19) + 'Z' }
  87. // `leases [--expire]`: every `in progress` ticket with its lease (a look at what `run` would do)
  88. static runLeases = (argv) => {
  89. let expire = argv.includes('--expire')
  90. let tk = null
  91. if (expire) {
  92. if (hostOf(baseUrl()) == LIVE_HOST && !argv.includes('--live')) {
  93. console.log('leases: REFUSED — ' + baseUrl() + ' is the LIVE tickets server; --expire writes, pass --live to do it there')
  94. return false
  95. }
  96. tk = readToken()
  97. if (tk.error != null) {
  98. console.log('leases: REFUSED — ' + tk.error + ' (--expire writes)')
  99. return false
  100. }
  101. }
  102. let sv = survey()
  103. if (sv.error != null) {
  104. console.log('leases: ' + sv.error)
  105. return false
  106. }
  107. let n = 0
  108. let t0 = now()
  109. for (e of sv.notEligible) {
  110. let t = e.t
  111. if (t.state == 'in progress') {
  112. n = n + 1
  113. let ref = t.project + t.ref
  114. let d = getJson(ticketPath(t.project, t.number))
  115. if (d.status != 200) { console.log('LEASE ' + ref + ' — cannot read: ' + d.status) } else {
  116. let l = leaseOf(d.json.events)
  117. if (l.kind == 'ready') { console.log('LEASE ' + ref + ' — built, waiting for the deploy (session ' + l.session + '): never expires — ./colony deployed ' + t.project + ' ' + t.number + ' after the deploy') }
  118. else if (l.kind == 'ended') { console.log('LEASE ' + ref + ' — the lease of session ' + l.session + ' has ended (' + l.text + '): never expires') }
  119. else if (l.kind != 'colony') { console.log('LEASE ' + ref + ' — no colony lease ("' + l.text + '"): never expires') }
  120. else {
  121. let st = l.parked ? 'parked (' + l.phase + ') until ' + ago(l.resumeMs) + ', ' : ''
  122. if (t0 < l.expiryMs) { console.log('LEASE ' + ref + ' — session ' + l.session + ' on ' + l.host + ': ' + st + 'alive until ' + ago(l.expiryMs)) }
  123. else if (!expire) { console.log('LEASE ' + ref + ' — session ' + l.session + ' on ' + l.host + ': ' + st + 'EXPIRED at ' + ago(l.expiryMs) + ' (--expire gives it back)') }
  124. else {
  125. let r = expireLease(t.project, t.number, l, tk.token)
  126. let res = ' → back to open'
  127. if (!r.ok && r.skipped == true) { res = ' → NOT expired, re-read right before: ' + r.text + ' (nothing written)' }
  128. else if (!r.ok) { res = ' → giving it back FAILED: ' + r.text }
  129. console.log('LEASE ' + ref + ' — session ' + l.session + ' on ' + l.host + ': ' + st + 'EXPIRED at ' + ago(l.expiryMs) + res)
  130. }
  131. }
  132. }
  133. }
  134. }
  135. console.log('LEASES: ' + n + ' ticket(s) in progress')
  136. return true
  137. }
  138. static runRun = (argv) => {
  139. let home = env('COLONY_HOME')
  140. if (home == null || home == '') {
  141. console.log('run: REFUSED — COLONY_HOME is not set (run through ./colony, which sets it)')
  142. return false
  143. }
  144. let base = baseUrl()
  145. let live = hostOf(base) == LIVE_HOST
  146. if (live && !argv.includes('--live')) {
  147. console.log('run: REFUSED — ' + base + ' is the LIVE tickets server (COLONY_TICKETS_URL unset or pointing there); pass --live to run against it')
  148. return false
  149. }
  150. let capText = setting(argv, '--capabilities', 'COLONY_CAPABILITIES', '')
  151. let hostCaps = parseCapabilities(capText)
  152. let intervalText = setting(argv, '--interval', 'COLONY_RUN_INTERVAL', '60')
  153. let parallelText = setting(argv, '--parallel', 'COLONY_PARALLEL', '1')
  154. let reserveText = setting(argv, '--reserve', 'COLONY_QUOTA_RESERVE', '70')
  155. let weekText = setting(argv, '--week-limit', 'COLONY_WEEK_LIMIT', '84')
  156. let poolText = setting(argv, '--port-pool', 'COLONY_PORT_POOL', '8700-8799')
  157. let perText = setting(argv, '--ports-per-session', 'COLONY_PORTS_PER_SESSION', '10')
  158. let claudeBin = setting(argv, '--claude', 'COLONY_CLAUDE', 'claude')
  159. let quotaBin = setting(argv, '--quota-claude', 'COLONY_QUOTA_CLAUDE', claudeBin)
  160. if (!isWholeNumber(intervalText) || intervalText == '0') {
  161. console.log('run: REFUSED — --interval must be whole seconds ≥ 1, got ' + intervalText)
  162. return false
  163. }
  164. if (!isWholeNumber(parallelText) || parallelText == '0') {
  165. console.log('run: REFUSED — --parallel must be a whole number ≥ 1, got ' + parallelText)
  166. return false
  167. }
  168. if (!isWholeNumber(reserveText) || toNumber(reserveText) > 100) {
  169. console.log('run: REFUSED — --reserve must be a percentage 0–100, got ' + reserveText)
  170. return false
  171. }
  172. if (!isWholeNumber(weekText) || toNumber(weekText) > 100) {
  173. console.log('run: REFUSED — --week-limit must be a percentage 0–100, got ' + weekText)
  174. return false
  175. }
  176. // mission 035 (antcolony#14): `--serve PORT` = the scheduler proper — it starts no worker itself; agents connect to it
  177. let serveText = setting(argv, '--serve', 'COLONY_SERVE', '')
  178. let serveHost = setting(argv, '--serve-host', 'COLONY_SERVE_HOST', '127.0.0.1')
  179. let ttlText = setting(argv, '--agent-ttl', 'COLONY_AGENT_TTL', '30')
  180. let settleText = setting(argv, '--account-settle', 'COLONY_ACCOUNT_SETTLE', '60')
  181. let serving = serveText != ''
  182. if (serving && (!isWholeNumber(serveText) || serveText == '0' || toNumber(serveText) > 65535)) {
  183. console.log('run: REFUSED — --serve must be a port 1–65535, got ' + serveText)
  184. return false
  185. }
  186. if (serving && (!isWholeNumber(ttlText) || ttlText == '0')) {
  187. console.log('run: REFUSED — --agent-ttl must be whole seconds ≥ 1, got ' + ttlText)
  188. return false
  189. }
  190. if (serving && (!isWholeNumber(settleText))) {
  191. console.log('run: REFUSED — --account-settle must be whole seconds ≥ 0, got ' + settleText)
  192. return false
  193. }
  194. if (serving && argv.includes('--once')) {
  195. console.log('run: REFUSED — --serve and --once do not go together (an agent needs the scheduler to stay)')
  196. return false
  197. }
  198. let agentToken = { token = '' }
  199. if (serving) {
  200. agentToken = readAgentToken()
  201. if (agentToken.error != null) {
  202. console.log('run: REFUSED — ' + agentToken.error + ' (agents must authenticate)')
  203. return false
  204. }
  205. }
  206. let pool = portRange(poolText)
  207. if (pool == null) {
  208. console.log('run: REFUSED — --port-pool must be A-B (1 ≤ A ≤ B ≤ 65535), got ' + poolText)
  209. return false
  210. }
  211. if (!isWholeNumber(perText) || perText == '0') {
  212. console.log('run: REFUSED — --ports-per-session must be a whole number ≥ 1, got ' + perText)
  213. return false
  214. }
  215. let interval = toNumber(intervalText)
  216. let parallel = toNumber(parallelText)
  217. let reserve = toNumber(reserveText)
  218. let weekLimit = toNumber(weekText)
  219. let per = toNumber(perText)
  220. let size = pool.to - pool.from + 1
  221. let slots = (size - size % per) / per
  222. if (!serving && slots < parallel) {
  223. console.log('run: REFUSED — the port pool ' + poolText + ' has ' + slots + ' range(s) of ' + per + ' ports, --parallel ' + parallel + ' needs ' + parallel)
  224. return false
  225. }
  226. let libOn = setting(argv, '--librarian', 'COLONY_LIBRARIAN', 'on')
  227. if (libOn != 'on' && libOn != 'off') {
  228. console.log('run: REFUSED — --librarian must be on or off, got ' + libOn)
  229. return false
  230. }
  231. let libSet = librarianSettings(argv, true)
  232. if (libSet.error != null) {
  233. console.log('run: REFUSED — ' + libSet.error)
  234. return false
  235. }
  236. let ls = leaseSettings(argv)
  237. if (ls.error != null) {
  238. console.log('run: REFUSED — ' + ls.error)
  239. return false
  240. }
  241. let tk = readToken()
  242. if (tk.error != null) {
  243. console.log('run: REFUSED — ' + tk.error + ' (run leases and posts)')
  244. return false
  245. }
  246. let host = hostName()
  247. let runs = runsDir(home)
  248. mkDir(runs, 448)
  249. let stopFile = env('COLONY_STOP_FILE')
  250. let once = argv.includes('--once')
  251. let pass = passOptions(argv)
  252. let running = []
  253. let live = {}
  254. let libBusy = false
  255. let busy = false
  256. let stopping = null
  257. let finished = false
  258. let iter = 0
  259. let loop = null
  260. let watch = null
  261. console.log('RUN: started on ' + host + (serving ? ' · SCHEDULER for agents on ' + serveHost + ':' + serveText + ' (starts no worker itself; agents offline after ' + ttlText + ' s without a sync)' : '') + ' · tickets ' + base + (live ? ' (LIVE)' : '') + ' · ' + (once ? 'once' : 'every ' + interval + ' s') + ' · parallel ' + parallel + ' · quota reserve ' + reserve + '% of the 5 h window, no start above ' + weekLimit + '% of the week (probe: ' + quotaBin + ' -p /usage) · ports ' + pool.from + '-' + pool.to + ', ' + per + ' per session · lease ttl ' + ls.ttl + ' s, heartbeat ' + ls.heartbeat + ' s · runs ' + runs + '/ · librarian ' + (libOn == 'on' ? 'on, first in every iteration (' + libSet.model + (libSet.max > 0 ? ', at most ' + libSet.max + ' text(s) per pass' : '') + ')' : 'off') + ' · stop file ' + (stopFile == null ? '— (no wrapper: SIGTERM kills without parking)' : stopFile))
  262. // mission 034 (antcolony#25): the Claude usage limit. `limitUntil` (epoch ms) = "limited until": set when a model call
  263. // of this run hit the limit — the librarian's (its pass reports `limited`), or a worker / controller that was parked on
  264. // it. Until then: no librarian pass, no quota probe, no start (resumes too); leases are still looked after; the
  265. // iterations are silent. ONE line when the pause begins, ONE when it ends. Unknown reset time → 15 min, then try once
  266. // (the quota is read first — below).
  267. let limitUntil = null
  268. let limitReset = (resetMs) => { return resetMs == null ? now() + LIMIT_RETRY_MS : resetMs }
  269. let pauseForLimit = (resetMs, why) => {
  270. let until = limitReset(resetMs)
  271. if (limitUntil == null) {
  272. limitUntil = until
  273. console.log('LIMIT: usage limit reached — librarian and starts paused until ' + ago(until) + (resetMs == null ? ' (no reset time known → ' + (LIMIT_RETRY_MS / 60000) + ' min, then try once)' : '') + ' · ' + why)
  274. } else if (until > limitUntil) {
  275. limitUntil = until
  276. console.log('LIMIT: the pause is extended until ' + ago(until) + ' · ' + why)
  277. }
  278. return null
  279. }
  280. // the quota reading at or above the start threshold → why (text), else null. A REACHED limit (100 %, 5 h or week)
  281. // always counts, also with --reserve 100 / --week-limit 100. An unknown week or a failed probe → null (the librarian
  282. // runs; starts are still held by iterate as before).
  283. let quotaBlock = (q) => {
  284. if (q == null || q.error != null || q.percent == null) { return null }
  285. if (q.percent >= 100) { return 'the 5 h limit is reached (100%)' }
  286. if (q.weekly != null && q.weekly >= 100) { return 'the weekly limit is reached (100%)' }
  287. if (reserve < 100 && q.percent >= reserve) { return '5 h ' + q.percent + '% ≥ reserve ' + reserve + '%' }
  288. if (weekLimit < 100 && q.weekly != null && q.weekly > weekLimit) { return 'week ' + q.weekly + '% > week limit ' + weekLimit + '%' }
  289. return null
  290. }
  291. let quotaText = (q) => { return 'quota ' + q.percent + '% of 5 h' + (q.resetsMs == null ? '' : ' (resets ' + ago(q.resetsMs) + ')') + (q.weekly == null ? '' : ', week ' + q.weekly + '%') + (q.cost != null && q.cost > 0 ? ', PROBE COST $' + q.cost : '') }
  292. // mission 035 (antcolony#14): the agents that are connected (in memory only — an agent's next sync rebuilds all of it).
  293. // agent = { host, seen, parallel, accepting, why, online }; `running` holds the sessions of the agents (entry.remote =
  294. // the agent's host): what an agent reports as running, plus a command queued / just sent that the agent has not shown yet.
  295. let agents = []
  296. let seenEvents = []
  297. let kwSeen = {}
  298. let digestState = { day = '' }
  299. let agentTtl = toNumber(ttlText) * 1000
  300. // antcolony#16: the quota belongs to the Claude ACCOUNT. Agents on one account (`--account`) report the same reading; the
  301. // scheduler pools it: freshest reading of the account, a limit pause of any of them, and one new start per account at a
  302. // time until a reading taken AFTER that start (--account-settle s later) shows what it costs. accountStart = { account: ms }.
  303. let settleMs = toNumber(settleText) * 1000
  304. let accountStart = {}
  305. let accountHosts = (acct) => {
  306. let hs = []
  307. for (a of agents) { if (agentOnline(a) && a.account == acct) { hs.push(a.host) } }
  308. return hs
  309. }
  310. let accountReading = (acct) => {
  311. let best = null
  312. for (a of agents) {
  313. if (agentOnline(a) && a.account == acct && a.quota != null && a.quota.at != null && (best == null || a.quota.at > best.at)) { best = a.quota }
  314. }
  315. return best
  316. }
  317. let accountBlock = (acct) => {
  318. let hs = accountHosts(acct)
  319. for (a of agents) {
  320. if (agentOnline(a) && a.account == acct && a.limitUntil != null && a.limitUntil > now()) { return 'usage limit reached until ' + ago(a.limitUntil) + ' (hit on ' + a.host + ')' }
  321. }
  322. let q = accountReading(acct)
  323. if (q == null) { return 'the quota is not read yet' }
  324. if (now() - q.at > 300000) { return 'the newest quota reading is older than 5 min' }
  325. if (q.percent >= 100) { return '5 h limit reached (100%)' }
  326. if (q.weekly != null && q.weekly >= 100) { return 'weekly limit reached (100%)' }
  327. if (reserve < 100 && q.percent >= reserve) { return 'quota 5 h ' + q.percent + '% ≥ reserve ' + reserve + '%' }
  328. if (weekLimit < 100 && (q.weekly == null || q.weekly > weekLimit)) { return q.weekly == null ? 'week UNKNOWN' : 'week ' + q.weekly + '% > week limit ' + weekLimit + '%' }
  329. let st = accountStart[acct]
  330. if (st != null && q.at < st + settleMs) { return 'a start on it (' + ago(st) + ') is not in the quota reading yet' + (hs.length > 1 ? ' — shared by ' + hs.join(', ') : '') }
  331. return null
  332. }
  333. let agentOf = (h) => {
  334. if (h == null) { return null }
  335. for (a of agents) { if (a.host.toLowerCase() == h.toLowerCase()) { return a } }
  336. return null
  337. }
  338. let agentOnline = (a) => { return a != null && now() - a.seen <= agentTtl }
  339. let hostRunning = (h) => {
  340. let n = 0
  341. for (r of running) { if (r.remote != null && r.remote.toLowerCase() == h.toLowerCase()) { n = n + 1 } }
  342. return n
  343. }
  344. // the sum of what the connected agents can run at once (the "/N" of the ITER line)
  345. let capacity = () => {
  346. if (!serving) { return parallel }
  347. let n = 0
  348. for (a of agents) { if (agentOnline(a)) { n = n + a.parallel } }
  349. return n
  350. }
  351. let dropHost = (h) => {
  352. let out = []
  353. for (x of running) { if (x.remote == null || x.remote.toLowerCase() != h.toLowerCase()) { out.push(x) } }
  354. running = out
  355. return null
  356. }
  357. let answer = (status, body) => { return { status = status body = body } }
  358. let handleAgent = (method, path, body, auth) => {
  359. if (path != '/agent/sync' || method != 'POST') { return answer(404, { error = 'POST /agent/sync only' }) }
  360. if (auth == null || auth != 'Bearer ' + agentToken.token) { return answer(401, { error = 'the agent token is wrong or missing' }) }
  361. if (body == null || body == '' || jsonErrorAt(body) >= 0) { return answer(400, { error = 'the body is not valid JSON' }) }
  362. let j = JSON.parse(body)
  363. if (j.host == null || hlTypeName(j.host) != 'String' || j.host == '' || j.running == null || j.events == null || j.parallel == null) { return answer(400, { error = 'host, parallel, running and events are required' }) }
  364. let a = agentOf(j.host)
  365. if (a == null) {
  366. agents.push({ host = j.host account = j.host quota = null limitUntil = null caps = [] seen = now() parallel = j.parallel accepting = j.accepting why = j.why announced = false wasAccepting = false })
  367. a = agentOf(j.host)
  368. }
  369. let wasOnline = agentOnline(a) && a.announced == true
  370. a.seen = now()
  371. a.caps = j.capabilities == null ? [] : parseCapabilities(j.capabilities.join(','))
  372. a.parallel = j.parallel
  373. a.account = j.account == null || j.account == '' ? j.host : j.account
  374. a.quota = j.quota
  375. a.limitUntil = j.limitUntil
  376. a.accepting = j.accepting == true
  377. a.why = j.why
  378. if (!wasOnline) {
  379. a.announced = true
  380. console.log('AGENT: ' + j.host + ' connected · offers ' + (a.caps.length == 0 ? '—' : a.caps.join(', ')) + ' · parallel ' + j.parallel + ' · Claude account ' + a.account + ' · ' + (a.accepting ? 'accepting work' : 'not accepting work (' + j.why + ')') + ' · running ' + j.running.length + ' · ' + j.events.length + ' buffered event(s)')
  381. } else if (a.accepting != a.wasAccepting) {
  382. console.log('AGENT: ' + j.host + (a.accepting ? ' accepts work again' : ' does not accept work: ' + j.why))
  383. }
  384. a.wasAccepting = a.accepting
  385. let ack = []
  386. for (e of j.events) {
  387. ack.push(e.id)
  388. if (seenEvents.includes(e.id)) { }
  389. else {
  390. seenEvents.push(e.id)
  391. if (seenEvents.length > 300) { seenEvents = seenEvents.slice(100) }
  392. if (e.kind == 'ended') { console.log('SESSION END ' + e.ref + ' ' + e.session + ' · ' + e.outcome + ' · cost ' + money(e.workerCost + e.controllerCost) + ' (worker ' + money(e.workerCost) + ' + controller ' + money(e.controllerCost) + ') · on ' + j.host + ' · ports ' + e.ports) }
  393. else if (e.kind == 'refused') { console.log('AGENT: ' + j.host + ' refused ' + e.ref + ' (' + e.session + ') — ' + e.why) }
  394. }
  395. }
  396. // rebuild this host's entries: what it runs + a command it has not shown yet (sent one round ago and still missing = lost / refused)
  397. let shown = []
  398. let commands = []
  399. let mine = []
  400. for (x of running) { if (x.remote != null && x.remote.toLowerCase() == j.host.toLowerCase()) { mine.push(x) } }
  401. dropHost(j.host)
  402. for (r of j.running) {
  403. shown.push(r.session)
  404. running.push({ ref = r.ref session = r.session folder = r.folder slot = -1 ports = r.ports phase = r.phase remote = j.host proc = null sentAt = null cmd = null })
  405. }
  406. for (x of mine) {
  407. if (shown.includes(x.session)) { }
  408. else if (x.sentAt != null || x.cmd == null) { }
  409. else {
  410. x.sentAt = now()
  411. commands.push(x.cmd)
  412. running.push(x)
  413. }
  414. }
  415. return answer(200, { ack = ack commands = commands })
  416. }
  417. let server = null
  418. if (serving) { server = new AgentServer(port = toNumber(serveText), host = serveHost, handler = handleAgent) }
  419. let slotFree = (i) => {
  420. for (r of running) { if (r.slot == i) { return false } }
  421. return true
  422. }
  423. let freeSlot = () => {
  424. let i = 0
  425. while (i < slots) {
  426. if (slotFree(i)) { return i }
  427. i = i + 1
  428. }
  429. return -1
  430. }
  431. let rangeOf = (i) => {
  432. let a = pool.from + i * per
  433. return a + '-' + (a + per - 1)
  434. }
  435. let runningSession = (s) => {
  436. for (r of running) { if (r.session == s) { return true } }
  437. return false
  438. }
  439. let folderBusy = (f) => {
  440. for (r of running) { if (r.folder == f) { return true } }
  441. return false
  442. }
  443. let checkEnd = () => {
  444. if (finished || busy || libBusy || (running.length > 0 && !serving)) { return null }
  445. if (stopping == null && !(once && iter >= 1)) { return null }
  446. finished = true
  447. if (loop != null) { loop.stop() }
  448. if (watch != null) { watch.stop() }
  449. console.log('RUN STOPPED — ' + (stopping != null ? 'stop requested (' + stopping + ')' : '--once') + ', ' + iter + ' iteration(s), ' + (serving ? running.length + ' session(s) keep running on their agents (leases in tickets)' : 'no session running'))
  450. return null
  451. }
  452. let sessionEnded = (entry, r) => {
  453. let out = []
  454. for (x of running) { if (x.session != entry.session) { out.push(x) } }
  455. running = out
  456. let total = (r.workerCost == null ? 0 : r.workerCost) + (r.controllerCost == null ? 0 : r.controllerCost)
  457. console.log('SESSION END ' + entry.ref + ' ' + entry.session + ' · ' + r.outcome + ' · cost ' + money(total) + ' (worker ' + money(r.workerCost) + ' + controller ' + money(r.controllerCost) + ') · ports ' + entry.ports + ' freed · running ' + running.length + '/' + capacity())
  458. // mission 034: parked on the usage limit → no librarian call and no start into it until the reset
  459. if (r.limited == true) { pauseForLimit(r.resetMs, entry.ref + ' ' + entry.session + ' was parked on it (' + r.limitText + ')') }
  460. checkEnd()
  461. return null
  462. }
  463. let startOne = (c) => {
  464. let slot = freeSlot()
  465. if (slot < 0) { return 'no port range free for ' + c.ref }
  466. let ports = rangeOf(slot)
  467. let entry = { ref = c.ref session = c.session folder = c.folder slot = slot ports = ports proc = null phase = 'worker' parkReason = null }
  468. // antcolony#28: process, phase and park reason live in `live[session]`, NOT on `entry` — `running` is rebuilt by
  469. // copying its entries (a Hybriel push copies), so a park reason set on a `running` entry never reached the hook
  470. live[c.session] = { proc = null phase = 'worker' parkReason = null }
  471. let hooks = {
  472. onProc = (p, phase) => {
  473. live[c.session].proc = p
  474. live[c.session].phase = phase
  475. return null
  476. }
  477. parkReason = () => { return live[c.session].parkReason }
  478. }
  479. // mission 031: a resume acts on what the survey saw — re-read first; still the same parked lease, or nothing is written
  480. if (c.kind == 'resume') {
  481. let chk = stillLeased('resume', c.project, c.number, c.lease)
  482. if (!chk.ok) {
  483. console.log('lease: NOT resumed ' + c.ref + ' (session ' + c.session + '): re-read right before — ' + chk.why + ' → nothing written')
  484. return 'NOT resumed ' + c.ref + ' (' + c.session + '): re-read right before — ' + chk.why + ' → nothing written'
  485. }
  486. }
  487. running.push(entry)
  488. let ok = false
  489. if (c.kind == 'resume') {
  490. ok = runResume(runs + '/' + c.session, pass, (r) => {
  491. sessionEnded(entry, r)
  492. return null
  493. }, hooks, ports)
  494. } else {
  495. let wargv = ['work' c.project '' + c.number '--post' '--session' c.session '--ports' ports]
  496. for (o of pass) { wargv.push(o) }
  497. ok = runWork(wargv, (r) => {
  498. sessionEnded(entry, r)
  499. return null
  500. }, hooks)
  501. }
  502. if (!ok) {
  503. let out = []
  504. for (x of running) { if (x.session != entry.session) { out.push(x) } }
  505. running = out
  506. return c.ref + ' not started (see above)'
  507. }
  508. return (c.kind == 'resume' ? 'resumed ' : 'started ') + c.ref + ' (' + c.session + ', ports ' + ports + ')'
  509. }
  510. let iterate = null
  511. let runLibrarian = null
  512. let tick = () => {
  513. if (busy || finished || stopping != null) { return null }
  514. if (once && iter >= 1) { return null }
  515. busy = true
  516. iter = iter + 1
  517. let head = 'ITER ' + iter + ' ' + ago(now())
  518. // antcolony#17: the creator's /confirm /reject /prio /hold in comments — code only, no quota, before anything is picked
  519. let kw = keywordPass(true, kwSeen)
  520. if (kw == null || kw.error != null) { console.log(head + ' · keywords: ' + (kw == null ? 'failed' : kw.error)) }
  521. // antcolony#19: the daily summary of what waits for the creator — code only, once a day after COLONY_DIGEST_HOUR
  522. let dg = digestTick(digestState, now())
  523. if (dg != null) { console.log(head + ' · ' + dg) }
  524. // mission 034: a known limit → no model call (no librarian), no probe, no start until it resets
  525. if (limitUntil != null) {
  526. if (now() < limitUntil) {
  527. iterate(head, 'usage limit', null, true, null)
  528. return null
  529. }
  530. console.log('LIMIT: resumed — the pause until ' + ago(limitUntil) + ' is over; the quota is read first, then the librarian and starts')
  531. limitUntil = null
  532. }
  533. if (libOn != 'on') {
  534. iterate(head, null, null, false, null)
  535. return null
  536. }
  537. // mission 034 (creator: "it cant recognize when it gets launches when we already hit the limit"): the quota FIRST
  538. // (`claude -p /usage`, local, $0) — at or above the start threshold → no librarian call and no start this iteration
  539. probeUsage(quotaBin, runs, (q) => {
  540. let qb = quotaBlock(q)
  541. if (qb != null) {
  542. iterate(head, null, q, false, qb)
  543. return null
  544. }
  545. runLibrarian(head, q)
  546. return null
  547. })
  548. return null
  549. }
  550. // mission 029 — ORDER: the librarian pass comes FIRST and is awaited; only then the iteration looks at tickets and
  551. // starts anything, so every brief built in this iteration has every creator decision written before it began.
  552. // A pass that failed or left a creator text unfiled → nothing starts in this iteration (`hold`).
  553. runLibrarian = (head, q) => {
  554. libBusy = true
  555. librarianPass({ argv = argv post = true auto = true stop = () => { return stopping != null } }, (s) => {
  556. libBusy = false
  557. // mission 034: the librarian's call hit the usage limit → pause (librarian + starts) until the reset
  558. if (s != null && s.limited == true) {
  559. pauseForLimit(s.resetMs, 'the librarian\'s model call hit it (' + s.limitText + '); ' + s.left + ' creator text(s) not filed yet')
  560. iterate(head, 'usage limit', null, true, null)
  561. return null
  562. }
  563. let hold = null
  564. if (s == null || s.error != null) { hold = 'librarian failed (' + (s == null ? '?' : s.error) + ') → no start this iteration' }
  565. else if (s.left != null && s.left > 0) { hold = 'librarian: ' + s.left + ' creator text(s) not filed yet → no start this iteration' }
  566. // the reading is re-taken before a start when the pass made model calls (it may be minutes old then)
  567. iterate(head, hold, s != null && s.calls != null && s.calls > 0 ? null : q, false, null)
  568. return null
  569. })
  570. return null
  571. }
  572. // the rest of one iteration (after the librarian): leases, candidates, quota, starts.
  573. // q = the quota reading of this iteration (null → probed here when something could start); paused = a known usage
  574. // limit (mission 034): leases only, no start, no line unless a lease was expired; libSkipped = why the librarian did
  575. // not run (the quota at or above the start threshold)
  576. iterate = (head, hold, q0, paused, libSkipped) => {
  577. if (stopping != null) {
  578. console.log(head + ' · stop requested — nothing started · running ' + running.length + '/' + capacity())
  579. busy = false
  580. checkEnd()
  581. return null
  582. }
  583. let notes = []
  584. let did = []
  585. // mission 035: an agent that has not synced for --agent-ttl s is offline — its entries go; its sessions keep their
  586. // leases in tickets (the heartbeat is theirs) and are handled like any lease: renewed, or expired
  587. for (a of agents) {
  588. if (a.announced == true && !agentOnline(a)) {
  589. a.announced = false
  590. let had = hostRunning(a.host)
  591. dropHost(a.host)
  592. console.log('AGENT: ' + a.host + ' is OFFLINE (no sync for ' + ttlText + ' s) — ' + had + ' session(s) were on it; their leases in tickets decide (renewed by the workers, else expired)')
  593. }
  594. }
  595. let sv = survey()
  596. if (sv.error != null) {
  597. console.log(head + ' · ERROR ' + sv.error + ' · running ' + running.length + '/' + capacity())
  598. busy = false
  599. checkEnd()
  600. return null
  601. }
  602. let t0 = now()
  603. let resumes = []
  604. for (e of sv.notEligible) {
  605. let t = e.t
  606. if (t.state == 'in progress') {
  607. let ref = t.project + t.ref
  608. let d = getJson(ticketPath(t.project, t.number))
  609. if (d.status != 200) { notes.push(ref + ' unreadable (' + d.status + ')') } else {
  610. let l = leaseOf(d.json.events)
  611. if (l.kind == 'ready') { notes.push(ref + ' built, waiting for the deploy') }
  612. else if (l.kind == 'ended') { notes.push(ref + ' in progress, but the lease of ' + l.session + ' has ended (' + l.text + ') → never expired') }
  613. else if (l.kind != 'colony') { notes.push(ref + ' in progress without a colony lease') }
  614. else if (runningSession(l.session)) { }
  615. else if (t0 >= l.expiryMs) {
  616. // mission 031: expireLease re-reads the ticket first and writes nothing unless it is STILL this dead lease
  617. let r = expireLease(t.project, t.number, l, tk.token)
  618. if (r.ok) { did.push('expired ' + ref + ' (session ' + l.session + ' on ' + l.host + ', lease ended ' + ago(l.expiryMs) + ') → open') }
  619. else if (r.skipped == true) {
  620. did.push('NOT expired ' + ref + ' (session ' + l.session + ', lease ended ' + ago(l.expiryMs) + '): re-read right before — ' + r.text + ' → nothing written')
  621. console.log('lease: NOT expired ' + ref + ' (session ' + l.session + '): re-read right before — ' + r.text + ' → nothing written')
  622. }
  623. else { did.push('EXPIRING ' + ref + ' FAILED: ' + r.text) }
  624. } else if (l.parked) {
  625. // mission 030: a question ticket is never work — a session parked on one (pre-030) is not resumed
  626. if (questionWhy(t) != null) { notes.push(ref + ' parked, but ' + questionWhy(t) + ' → not resumed') }
  627. else if (!serving && l.host.toLowerCase() != host.toLowerCase()) { notes.push(ref + ' parked on ' + l.host) }
  628. else if (t0 < l.resumeMs) { notes.push(ref + ' parked until ' + ago(l.resumeMs)) }
  629. else if (serving && !agentOnline(agentOf(l.host))) { notes.push(ref + ' parked on ' + l.host + ' (no agent online there)') }
  630. else if (!serving && !exists(runs + '/' + l.session + '/session.json')) { notes.push(ref + ' parked, but ' + runs + '/' + l.session + ' is missing') }
  631. else {
  632. let sj = serving ? null : runs + '/' + l.session + '/session.json'
  633. resumes.push({ kind = 'resume' ref = ref project = t.project number = t.number lease = l session = l.session folder = null sj = sj host = l.host })
  634. }
  635. } else { notes.push(ref + ' leased by ' + l.session + ' on ' + l.host + ' until ' + ago(l.expiryMs)) }
  636. }
  637. }
  638. }
  639. let cands = []
  640. for (r of resumes) {
  641. if (serving) { cands.push(r) }
  642. else {
  643. let m = JSON.parse(readFile(r.sj))
  644. r.folder = m.folder
  645. if (folderBusy(r.folder)) { notes.push(r.ref + ' (resume) dev folder busy') } else { cands.push(r) }
  646. }
  647. }
  648. let other = 0
  649. for (t of sv.eligible) {
  650. let m = sv.reg.projects[t.project]
  651. let ref = t.project + t.ref
  652. if (m.dev == null || m.dev.host == null) { other = other + 1 }
  653. else if (serving && !agentOnline(agentOf(m.dev.host))) { notes.push(ref + ' waits — no agent online on ' + m.dev.host) }
  654. else if (!serving && m.dev.host.toLowerCase() != host.toLowerCase()) { other = other + 1 }
  655. else if (missingCapabilities(m.needList, serving ? agentOf(m.dev.host).caps : hostCaps).length > 0) { notes.push(ref + ' waits — ' + m.dev.host + ' does not offer: ' + missingCapabilities(m.needList, serving ? agentOf(m.dev.host).caps : hostCaps).join(', ')) }
  656. else if (m.dev.folder == null || folderBusy(m.dev.folder)) { notes.push(ref + ' dev folder busy') }
  657. else {
  658. let dup = null
  659. for (c of cands) { if (dup == null && c.folder == m.dev.folder) { dup = c.ref } }
  660. if (dup != null) { notes.push(ref + ' waits (same dev folder as ' + dup + ')') }
  661. else { cands.push({ kind = 'new' ref = ref project = t.project number = t.number folder = m.dev.folder session = null host = m.dev.host }) }
  662. }
  663. }
  664. if (other > 0) { notes.push(other + ' eligible on other hosts') }
  665. let free = serving ? capacity() - running.length : parallel - running.length
  666. let libNote = libSkipped == null ? [] : ['librarian not run: quota at or above the start threshold (' + libSkipped + ')']
  667. let line = (quota, list) => {
  668. let parts = [head, quota, 'running ' + running.length + '/' + capacity()]
  669. for (x of did) { parts.push(x) }
  670. for (x of list) { parts.push(x) }
  671. for (x of libNote) { parts.push(x) }
  672. if (notes.length > 0) { parts.push('skipped: ' + notes.join(', ')) }
  673. console.log(parts.join(' · '))
  674. return null
  675. }
  676. // mission 034: paused on a known usage limit — nothing starts; the iteration is silent unless a lease was acted on
  677. if (paused == true) {
  678. if (did.length > 0) { line('usage limit — librarian and starts paused until ' + ago(limitUntil), []) }
  679. busy = false
  680. checkEnd()
  681. return null
  682. }
  683. if (hold != null && cands.length > 0) {
  684. line(hold, [])
  685. busy = false
  686. checkEnd()
  687. return null
  688. }
  689. // mission 035 (antcolony#14): the scheduler starts nothing itself — a candidate becomes a command for the agent of its
  690. // dev host (one with a free slot that says it is accepting work; the quota is the agent's), sent with the agent's next sync
  691. if (serving) {
  692. let queued = []
  693. for (c of cands) {
  694. let a = agentOf(c.host)
  695. if (!agentOnline(a)) { notes.push(c.ref + ' waits — no agent online on ' + c.host) }
  696. else if (!a.accepting) { notes.push(c.ref + ' waits — agent ' + a.host + ' is not accepting work (' + a.why + ')') }
  697. else if (hostRunning(a.host) >= a.parallel) { notes.push(c.ref + ' waits — agent ' + a.host + ' has all ' + a.parallel + ' slot(s) busy') }
  698. else if (accountBlock(a.account) != null) { notes.push(c.ref + ' waits — Claude account ' + a.account + ': ' + accountBlock(a.account)) }
  699. else if (c.kind == 'resume') {
  700. // mission 031: a resume acts on what the survey saw — re-read first
  701. let chk = stillLeased('resume', c.project, c.number, c.lease)
  702. if (!chk.ok) {
  703. console.log('lease: NOT resumed ' + c.ref + ' (session ' + c.session + '): re-read right before — ' + chk.why + ' → nothing written')
  704. notes.push('NOT resumed ' + c.ref + ' (' + c.session + '): re-read right before — ' + chk.why + ' → nothing written')
  705. } else {
  706. running.push({ ref = c.ref session = c.session folder = null slot = -1 ports = '' phase = 'worker' remote = a.host proc = null sentAt = null cmd = { kind = 'resume' project = c.project number = c.number session = c.session folder = null brief = null } })
  707. accountStart[a.account] = now()
  708. queued.push('resume ' + c.ref + ' (' + c.session + ') → ' + a.host)
  709. }
  710. } else {
  711. let session = newSession()
  712. let b = buildBrief(c.project, '' + c.number, ['--session' session '--ports' PORTS_MARKER])
  713. if (b.error != null) { notes.push(c.ref + ' not started: ' + b.error) }
  714. else {
  715. running.push({ ref = c.ref session = session folder = c.folder slot = -1 ports = '' phase = 'worker' remote = a.host proc = null sentAt = null cmd = { kind = 'start' project = c.project number = c.number session = session folder = c.folder brief = b.text } })
  716. accountStart[a.account] = now()
  717. queued.push('start ' + c.ref + ' (' + session + ') → ' + a.host)
  718. }
  719. }
  720. }
  721. let names = []
  722. for (a of agents) { if (agentOnline(a)) { names.push(a.host + ' ' + hostRunning(a.host) + '/' + a.parallel + (a.accepting ? '' : ' (not accepting: ' + a.why + ')')) } }
  723. let accts = []
  724. for (a of agents) {
  725. if (agentOnline(a) && !accts.includes(a.account)) {
  726. accts.push(a.account)
  727. let q = accountReading(a.account)
  728. names.push('account ' + a.account + (q == null ? ' quota unknown' : ' ' + q.percent + '%' + (q.weekly == null ? '' : ' week ' + q.weekly + '%')) + ' [' + accountHosts(a.account).join('+') + ']')
  729. }
  730. }
  731. line('agents: ' + (names.length == 0 ? 'none online' : names.join(', ')), queued)
  732. busy = false
  733. checkEnd()
  734. return null
  735. }
  736. if (cands.length == 0 || free <= 0) {
  737. let why = cands.length == 0 ? 'nothing to start' : 'all ' + parallel + ' slot(s) busy, waiting: ' + cands.length
  738. if (q0 == null) { line('quota not read (' + why + ')', []) }
  739. else if (q0.error != null) { line('quota UNKNOWN (' + q0.error + ') · ' + why, []) }
  740. else { line(quotaText(q0) + (libSkipped == null ? '' : ' → librarian and starts paused') + ' · ' + why, []) }
  741. busy = false
  742. checkEnd()
  743. return null
  744. }
  745. let withQuota = (fn) => {
  746. if (q0 != null) { return fn(q0) }
  747. return probeUsage(quotaBin, runs, fn)
  748. }
  749. withQuota((q) => {
  750. if (stopping != null) {
  751. line('stop requested — nothing started', [])
  752. busy = false
  753. checkEnd()
  754. return null
  755. }
  756. if (q.error != null) {
  757. line('quota UNKNOWN (' + q.error + ') → no start', [])
  758. busy = false
  759. checkEnd()
  760. return null
  761. }
  762. let qt = quotaText(q)
  763. // --reserve 100 = the 5 h window never blocks (creator 2026-09-24: first live runs up to 100 %)
  764. if (reserve < 100 && q.percent >= reserve) {
  765. let names = []
  766. for (c of cands) { names.push(c.ref) }
  767. line(qt + ' ≥ reserve ' + reserve + '% → no start (waiting: ' + names.join(', ') + ')', [])
  768. busy = false
  769. checkEnd()
  770. return null
  771. }
  772. // mission 023 (creator): no new start (resumes too) while the WEEKLY usage is above the week limit (84 %);
  773. // a probe without a weekly number starts nothing either — better wait than overspend the week;
  774. // --week-limit 100 = the week never blocks (also when unknown)
  775. if (weekLimit < 100 && (q.weekly == null || q.weekly > weekLimit)) {
  776. let wn = []
  777. for (c of cands) { wn.push(c.ref) }
  778. line(qt + (q.weekly == null ? ', week UNKNOWN' : ' > week limit ' + weekLimit + '%') + ' → no start (waiting: ' + wn.join(', ') + ')', [])
  779. busy = false
  780. checkEnd()
  781. return null
  782. }
  783. // mission 034: a REACHED limit (100 %) holds every start, also with --reserve 100 / --week-limit 100
  784. let reached = quotaBlock(q)
  785. if (reached != null) {
  786. let rn = []
  787. for (c of cands) { rn.push(c.ref) }
  788. line(qt + ' → ' + reached + ' → no start (waiting: ' + rn.join(', ') + ')', [])
  789. busy = false
  790. checkEnd()
  791. return null
  792. }
  793. let started = []
  794. let i = 0
  795. while (i < cands.length && running.length < parallel) {
  796. let c = cands[i]
  797. if (c.session == null) { c.session = newSession() }
  798. started.push(startOne(c))
  799. i = i + 1
  800. }
  801. while (i < cands.length) {
  802. notes.push(cands[i].ref + ' waits for a slot')
  803. i = i + 1
  804. }
  805. line(qt + (reserve >= 100 ? ' · 5 h reserve off (100%)' : ' < reserve ' + reserve + '%') + (weekLimit >= 100 ? ' · week limit off (100%)' : ''), started)
  806. busy = false
  807. checkEnd()
  808. return null
  809. })
  810. return null
  811. }
  812. let checkStop = () => {
  813. if (finished || stopFile == null || stopFile == '' || !exists(stopFile)) { return null }
  814. let mode = readFile(stopFile).trim()
  815. if (stopping == null) {
  816. stopping = 'finish'
  817. console.log('RUN: stop requested — starting nothing more; ' + running.length + ' running session(s) finish (a second SIGTERM/SIGINT parks them instead)')
  818. }
  819. if (mode == 'park' && stopping != 'park') {
  820. stopping = 'park'
  821. console.log('RUN: second stop signal — parking ' + running.length + ' running session(s)')
  822. for (r of running) {
  823. if (live[r.session] != null) {
  824. live[r.session].parkReason = 'the scheduler run was stopped (second SIGTERM/SIGINT)'
  825. if (live[r.session].proc != null) {
  826. console.log('RUN: killing ' + live[r.session].phase + ' pid ' + live[r.session].proc.pid + ' of ' + r.ref + ' ' + r.session + ' → parked')
  827. live[r.session].proc.kill()
  828. }
  829. }
  830. }
  831. }
  832. checkEnd()
  833. return null
  834. }
  835. watch = every(0.5)
  836. on watch.tick(x) {
  837. checkStop()
  838. return null
  839. }
  840. loop = every(interval)
  841. on loop.tick(x) {
  842. tick()
  843. return null
  844. }
  845. tick()
  846. return true
  847. }

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