返回 CodeWhale
sprite-task.mjs
根目录 / crates / tui / plugins / computer-use / src / sprite-task.mjs
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
122 lines Plain Text