| 1 | // Sprite Task hold — keep a Codewhale Computer (a Fly Sprite) awake while a |
| 2 | // turn runs, and only then. |
| 3 | // |
| 4 | // Sprites pause when idle; a Task registered on the in-Sprite API socket |
| 5 | // (/.sprite/api.sock, virtual host "sprite") holds one awake until it expires. |
| 6 | // The contract (ARCHITECTURE §2.1, S0 Q7): |
| 7 | // - acquire at turn start with a 5-minute expiry, refresh every 60 s, |
| 8 | // release (DELETE) at turn end; |
| 9 | // - expiries are capped at 5 minutes: a Task survives a checkpoint restore |
| 10 | // and keeps the Sprite billing until it expires, so a crashed or halted |
| 11 | // holder must never leave more than a short tail; |
| 12 | // - the holder dies with its parent: the CLI (mcp/turn-hold.mjs) releases on |
| 13 | // stdin EOF, so an Engine crash cannot leave a refreshed hold behind. |
| 14 | import http from "node:http"; |
| 15 | |
| 16 | export const DEFAULT_SOCKET = "/.sprite/api.sock"; |
| 17 | export const MAX_EXPIRE_SEC = 300; |
| 18 | const NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/; |
| 19 | |
| 20 | /** "5m" | "90s" | 300 → seconds; refuses anything above the 5-minute cap. */ |
| 21 | export function expireSeconds(expire) { |
| 22 | let sec; |
| 23 | if (typeof expire === "number") sec = expire; |
| 24 | else { |
| 25 | const m = /^(\d+)(s|m)$/.exec(String(expire ?? "").trim()); |
| 26 | if (!m) throw Object.assign(new Error(`expire must look like "5m" or "90s" (got ${JSON.stringify(expire)})`), { code: "bad_args" }); |
| 27 | sec = Number(m[1]) * (m[2] === "m" ? 60 : 1); |
| 28 | } |
| 29 | if (!Number.isInteger(sec) || sec < 30 || sec > MAX_EXPIRE_SEC) { |
| 30 | throw Object.assign(new Error(`task expiry must be 30..${MAX_EXPIRE_SEC} s — a Task outlives restores, so long holds are refused`), { code: "bad_args" }); |
| 31 | } |
| 32 | return sec; |
| 33 | } |
| 34 | |
| 35 | /** One JSON request to the Sprite API socket. Resolves {status, body}. */ |
| 36 | export function spriteApi(method, path, body, { socket = DEFAULT_SOCKET, timeoutMs = 5_000 } = {}) { |
| 37 | return new Promise((resolve, reject) => { |
| 38 | const payload = body == null ? null : JSON.stringify(body); |
| 39 | const req = http.request({ |
| 40 | socketPath: socket, method, path, host: "sprite", |
| 41 | headers: { Host: "sprite", ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}) }, |
| 42 | timeout: timeoutMs, |
| 43 | }, (res) => { |
| 44 | let text = ""; |
| 45 | res.setEncoding("utf8"); |
| 46 | res.on("data", (chunk) => { text += chunk; }); |
| 47 | res.on("end", () => { |
| 48 | let parsed = null; |
| 49 | try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; } |
| 50 | resolve({ status: res.statusCode, body: parsed }); |
| 51 | }); |
| 52 | }); |
| 53 | req.on("timeout", () => req.destroy(Object.assign(new Error(`Sprite API ${method} ${path} timed out`), { code: "timeout" }))); |
| 54 | req.on("error", reject); |
| 55 | if (payload) req.write(payload); |
| 56 | req.end(); |
| 57 | }); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * A refreshed Task hold. acquire() registers it and starts the refresh timer; |
| 62 | * release() stops the timer and deletes the Task. onEvent receives receipts |
| 63 | * ({event:"acquired"|"refreshed"|"refresh_failed"|"released"|"release_failed", ...}). |
| 64 | */ |
| 65 | export function createTaskHold({ |
| 66 | name, expire = "5m", refreshMs = 60_000, socket = DEFAULT_SOCKET, |
| 67 | api = (method, path, body) => spriteApi(method, path, body, { socket }), |
| 68 | onEvent = () => {}, now = () => new Date().toISOString(), |
| 69 | } = {}) { |
| 70 | if (typeof name !== "string" || !NAME_RE.test(name)) throw Object.assign(new Error("task name must be lowercase letters, digits and dashes (≤ 63)"), { code: "bad_args" }); |
| 71 | const sec = expireSeconds(expire); |
| 72 | if (!(refreshMs > 0) || refreshMs >= sec * 1000) throw Object.assign(new Error("refresh interval must be shorter than the expiry"), { code: "bad_args" }); |
| 73 | const expireText = `${sec}s`; |
| 74 | let timer = null; |
| 75 | let held = false; |
| 76 | const path = `/v1/tasks/${encodeURIComponent(name)}`; |
| 77 | |
| 78 | async function register(method, url) { |
| 79 | const r = await api(method, url, { name, expire: expireText }); |
| 80 | if (r.status < 200 || r.status >= 300) throw Object.assign(new Error(`Sprite API ${method} ${url} returned ${r.status}`), { code: "task_api_error", status: r.status }); |
| 81 | return r.body; |
| 82 | } |
| 83 | |
| 84 | async function refresh() { |
| 85 | try { |
| 86 | // PUT refreshes per the docs; a server without it gets a re-POST, which |
| 87 | // S0 observed to re-register the same name with a fresh expiry. |
| 88 | let body; |
| 89 | try { body = await register("PUT", path); } catch (error) { |
| 90 | if (error.status !== 404 && error.status !== 405) throw error; |
| 91 | body = await register("POST", "/v1/tasks"); |
| 92 | } |
| 93 | onEvent({ event: "refreshed", name, expires_at: body?.expires_at ?? null, ts: now() }); |
| 94 | } catch (error) { |
| 95 | onEvent({ event: "refresh_failed", name, error: error.message, ts: now() }); |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | return { |
| 100 | get held() { return held; }, |
| 101 | async acquire() { |
| 102 | if (held) return; |
| 103 | const body = await register("POST", "/v1/tasks"); |
| 104 | held = true; |
| 105 | onEvent({ event: "acquired", name, expire: expireText, expires_at: body?.expires_at ?? null, ts: now() }); |
| 106 | timer = setInterval(refresh, refreshMs); |
| 107 | }, |
| 108 | async release() { |
| 109 | if (timer) { clearInterval(timer); timer = null; } |
| 110 | if (!held) return; |
| 111 | held = false; |
| 112 | try { |
| 113 | const r = await api("DELETE", path); |
| 114 | if (r.status >= 300 && r.status !== 404) throw new Error(`Sprite API DELETE ${path} returned ${r.status}`); |
| 115 | onEvent({ event: "released", name, ts: now() }); |
| 116 | } catch (error) { |
| 117 | onEvent({ event: "release_failed", name, error: error.message, note: `the Task lapses on its own within ${sec} s`, ts: now() }); |
| 118 | } |
| 119 | }, |
| 120 | }; |
| 121 | } |
| 122 |