hub-runner 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bin/hub-runner.mjs +2 -1
- package/lib/launchd.mjs +17 -3
- package/main.mjs +69 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Runs a hub workspace's Mac bots on this Mac. It runs under launchd, so no app has to be open.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
npm i -g hub-runner
|
|
6
|
+
npm i -g hub-runner --min-release-age=0
|
|
7
7
|
hub-runner login
|
|
8
8
|
hub-runner start
|
|
9
9
|
```
|
|
@@ -22,11 +22,13 @@ hub-runner start
|
|
|
22
22
|
## Update
|
|
23
23
|
|
|
24
24
|
```
|
|
25
|
-
npm i -g hub-runner@latest && hub-runner restart
|
|
25
|
+
npm i -g hub-runner@latest --min-release-age=0 && hub-runner restart
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
`restart` lets the turns open now finish, so no bot is cut off.
|
|
29
29
|
|
|
30
|
+
`--min-release-age=0` lets the newest release install when your npm config holds packages back for a few days (`min-release-age`). It applies to this one install, and hub-runner has no dependencies, so nothing else skips the wait.
|
|
31
|
+
|
|
30
32
|
## Notes
|
|
31
33
|
|
|
32
34
|
- Node 24 or later, and `pnpm` on your PATH when you run `start`: the runner uses both to set the bots up.
|
package/bin/hub-runner.mjs
CHANGED
|
@@ -12,7 +12,7 @@ const USAGE = `hub-runner login | start [--take-over] | stop | restart | status
|
|
|
12
12
|
login sign in with Vercel and choose the workspace this Mac runs bots for
|
|
13
13
|
start start the runner under launchd (it starts again at every login); --take-over claims it from another Mac
|
|
14
14
|
stop stop the runner
|
|
15
|
-
restart let the open turns finish, then start the runner again (after \`npm i -g hub-runner@latest\`)
|
|
15
|
+
restart let the open turns finish, then start the runner again (after \`npm i -g hub-runner@latest --min-release-age=0\`)
|
|
16
16
|
status what the runner is doing; exits 1 when it isn't running
|
|
17
17
|
logs the runner's log; -f follows it
|
|
18
18
|
logout stop the runner and forget the sign-in`;
|
|
@@ -48,6 +48,7 @@ function logs(follow) {
|
|
|
48
48
|
|
|
49
49
|
const [command, ...args] = process.argv.slice(2);
|
|
50
50
|
try {
|
|
51
|
+
if (process.getuid() === 0) throw new Error('run hub-runner without sudo: the runner is a launchd job of your own user, and root has none');
|
|
51
52
|
switch (command) {
|
|
52
53
|
case 'login': await login(); break;
|
|
53
54
|
case 'start': start({ takeOver: args.includes('--take-over') }); console.log('started: see `hub-runner status`'); break;
|
package/lib/launchd.mjs
CHANGED
|
@@ -17,6 +17,20 @@ const launchctl = (args, { check = true } = {}) => {
|
|
|
17
17
|
|
|
18
18
|
export const loaded = () => launchctl(['print', `${domain()}/${LABEL}`], { check: false });
|
|
19
19
|
|
|
20
|
+
const EXIT_TIMEOUT_S = 60;
|
|
21
|
+
|
|
22
|
+
// `bootout` returns while the old runner may still be draining (up to ExitTimeOut), and a `bootstrap` before launchd
|
|
23
|
+
// drops the job fails with error 5. So wait for the job to leave the domain.
|
|
24
|
+
function bootout() {
|
|
25
|
+
launchctl(['bootout', `${domain()}/${LABEL}`]);
|
|
26
|
+
const until = Date.now() + (EXIT_TIMEOUT_S + 5) * 1000;
|
|
27
|
+
const tick = new Int32Array(new SharedArrayBuffer(4));
|
|
28
|
+
while (loaded()) {
|
|
29
|
+
if (Date.now() > until) throw new Error(`launchd still holds ${LABEL} ${EXIT_TIMEOUT_S + 5}s after bootout: see \`hub-runner logs\``);
|
|
30
|
+
Atomics.wait(tick, 0, 0, 250);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
export function plist({ takeOver }) {
|
|
21
35
|
const pnpm = execFileSync('/bin/sh', ['-c', 'command -v pnpm'], { encoding: 'utf8' }).trim();
|
|
22
36
|
const env = { HUB_PNPM: pnpm, PATH: process.env.PATH, HUB_RUNNER_DIR: runnerDir(), HUB_RUNNER_BUILD: version(), HUB_DEVICE_NAME: deviceName(), ...(takeOver ? { HUB_RUNNER_TAKE_OVER: '1' } : {}) };
|
|
@@ -40,7 +54,7 @@ ${Object.entries(env).map(([k, v]) => ` <key>${k}</key>\n <string>${xml(v)
|
|
|
40
54
|
<key>RunAtLoad</key>
|
|
41
55
|
<true/>
|
|
42
56
|
<key>ExitTimeOut</key>
|
|
43
|
-
<integer
|
|
57
|
+
<integer>${EXIT_TIMEOUT_S}</integer>
|
|
44
58
|
<key>StandardOutPath</key>
|
|
45
59
|
<string>${xml(logFile())}</string>
|
|
46
60
|
<key>StandardErrorPath</key>
|
|
@@ -55,12 +69,12 @@ export function start({ takeOver = false } = {}) {
|
|
|
55
69
|
mkdirSync(runnerDir(), { recursive: true });
|
|
56
70
|
mkdirSync(dirname(plistFile()), { recursive: true });
|
|
57
71
|
writeFileSync(plistFile(), plist({ takeOver }));
|
|
58
|
-
if (loaded())
|
|
72
|
+
if (loaded()) bootout();
|
|
59
73
|
launchctl(['bootstrap', domain(), plistFile()]);
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
export function stop() {
|
|
63
|
-
if (loaded())
|
|
77
|
+
if (loaded()) bootout();
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
/** Starts the loaded job again, or loads it from the plist the last `start` wrote. */
|
package/main.mjs
CHANGED
|
@@ -2463,6 +2463,7 @@ function cancelTurn(fetchFn, eve, sessionId, turnId) {
|
|
|
2463
2463
|
);
|
|
2464
2464
|
}
|
|
2465
2465
|
var ATTEMPT_MS = 15e3;
|
|
2466
|
+
var NOT_READY_WAITS_MS = [250, 500, 1e3, 2e3];
|
|
2466
2467
|
var COMPACT_MS = 3e4;
|
|
2467
2468
|
async function compactSession(fetchFn, eve, sessionId) {
|
|
2468
2469
|
try {
|
|
@@ -2487,32 +2488,58 @@ function sessionEnded(status2, body) {
|
|
|
2487
2488
|
return false;
|
|
2488
2489
|
}
|
|
2489
2490
|
}
|
|
2491
|
+
var CreateRefused = class extends Error {
|
|
2492
|
+
};
|
|
2493
|
+
async function createSession(fetchFn, eve, operationId) {
|
|
2494
|
+
const response = await fetchFn(`${eve.url}/v1/session`, { method: "POST", headers: eve.headers, signal: AbortSignal.timeout(ATTEMPT_MS), body: JSON.stringify({ operationId }) });
|
|
2495
|
+
const text = await response.text();
|
|
2496
|
+
const body = (() => {
|
|
2497
|
+
try {
|
|
2498
|
+
return JSON.parse(text);
|
|
2499
|
+
} catch {
|
|
2500
|
+
return {};
|
|
2501
|
+
}
|
|
2502
|
+
})(), { sessionId } = body;
|
|
2503
|
+
if (definitive(response.status)) throw new CreateRefused(`${response.status} ${body.error ?? body.code ?? text.slice(0, 200)}`);
|
|
2504
|
+
if (!response.ok || !sessionId) throw new Error(`create ${operationId}: HTTP ${response.status} ${text.slice(0, 200)}`);
|
|
2505
|
+
return sessionId;
|
|
2506
|
+
}
|
|
2490
2507
|
async function sendToBot(fetchFn, eve, d3, context, loadImage) {
|
|
2491
|
-
const first = !d3.sessionStarted;
|
|
2492
2508
|
try {
|
|
2509
|
+
const { eveSessionId } = d3;
|
|
2493
2510
|
const render = (notes) => [context, messageToYou(d3, notes)].filter(Boolean).join("\n\n");
|
|
2494
2511
|
const prompt = render("");
|
|
2495
|
-
const fields = { deliveryId: d3.id, turnPolicy: !d3.steer && (d3.authorKind === "agent" || queued(d3.text) !== null) ? "queue" : "steer"
|
|
2512
|
+
const fields = { deliveryId: d3.id, turnPolicy: !d3.steer && (d3.authorKind === "agent" || queued(d3.text) !== null) ? "queue" : "steer" };
|
|
2496
2513
|
const fit = d3.fit ? fitImages(Buffer.byteLength(JSON.stringify({ message: [{ type: "text", text: prompt }], ...fields })), d3.messageId, d3.fit.own, d3.fit.earlier) : void 0;
|
|
2497
2514
|
const message = await messageWithImages(render, fit ? fit.attachments : d3.attachments, loadImage, fit?.more);
|
|
2498
|
-
const
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2515
|
+
const deadline = Date.now() + ATTEMPT_MS, signal = AbortSignal.timeout(ATTEMPT_MS);
|
|
2516
|
+
const send = async () => {
|
|
2517
|
+
const response2 = await fetchFn(`${eve.url}/v1/session/${encodeURIComponent(eveSessionId)}`, {
|
|
2518
|
+
method: "POST",
|
|
2519
|
+
headers: eve.headers,
|
|
2520
|
+
signal,
|
|
2521
|
+
body: JSON.stringify({ message, ...fields })
|
|
2522
|
+
});
|
|
2523
|
+
const text2 = await response2.text();
|
|
2524
|
+
const body2 = (() => {
|
|
2525
|
+
try {
|
|
2526
|
+
return JSON.parse(text2);
|
|
2527
|
+
} catch {
|
|
2528
|
+
return {};
|
|
2529
|
+
}
|
|
2530
|
+
})();
|
|
2531
|
+
return { response: response2, text: text2, body: body2 };
|
|
2532
|
+
};
|
|
2533
|
+
let { response, text, body } = await send();
|
|
2534
|
+
for (const wait of NOT_READY_WAITS_MS) {
|
|
2535
|
+
if (response.status !== 409 || body.code !== "session_not_ready" || Date.now() + wait >= deadline) break;
|
|
2536
|
+
await new Promise((resolve6) => setTimeout(resolve6, wait));
|
|
2537
|
+
({ response, text, body } = await send());
|
|
2538
|
+
}
|
|
2512
2539
|
const answer = (kind, detail) => ({ answer: kind, status: response.status, ...detail ? { detail } : {} });
|
|
2513
|
-
if (response.ok) return body.sessionId ===
|
|
2540
|
+
if (response.ok) return body.sessionId === eveSessionId ? answer({ kind: "accepted" }) : answer({ kind: "unknown" }, `2xx named session ${body.sessionId ?? "none"}, not ${eveSessionId}`);
|
|
2514
2541
|
console.error("[hosted-hub] bot", response.status, d3.id, text.slice(0, 300));
|
|
2515
|
-
if (sessionEnded(response.status, text)
|
|
2542
|
+
if (sessionEnded(response.status, text)) return answer({ kind: "ended" });
|
|
2516
2543
|
if (response.status === 409 && body.code === "session_not_ready") return answer({ kind: "unknown", notTaken: true }, "session_not_ready");
|
|
2517
2544
|
if (definitive(response.status))
|
|
2518
2545
|
return answer({ kind: "refused", error: `${response.status} ${body.error ?? body.code ?? text.slice(0, 200)}` });
|
|
@@ -15315,10 +15342,10 @@ async function run3(config, bots = realBots) {
|
|
|
15315
15342
|
async function retireDead(name, work) {
|
|
15316
15343
|
const open = work.sessions.filter((s3) => s3.bot === name);
|
|
15317
15344
|
for (const s3 of open) {
|
|
15318
|
-
const res = await cancelTurn(net, eve(name, work.human), s3.
|
|
15345
|
+
const res = await cancelTurn(net, eve(name, work.human), s3.eveSessionId);
|
|
15319
15346
|
if (!res.ok) throw new Error(`cancel ${s3.sessionId}: HTTP ${res.status}`);
|
|
15320
15347
|
}
|
|
15321
|
-
for (const s3 of open) await upload(s3
|
|
15348
|
+
for (const s3 of open) await upload(s3, name, work.human, s3.foldedIndex + 1, { toTail: true });
|
|
15322
15349
|
const sessionIds = open.map((s3) => s3.sessionId);
|
|
15323
15350
|
if (sessionIds.length) {
|
|
15324
15351
|
const { retired } = await hubJson("/api/local-runner/lost", { method: "POST", body: JSON.stringify({ sessionIds }) });
|
|
@@ -15368,21 +15395,30 @@ async function run3(config, bots = realBots) {
|
|
|
15368
15395
|
return { bytes: new Uint8Array(await res.arrayBuffer()), mediaType: res.headers.get("content-type") ?? "" };
|
|
15369
15396
|
};
|
|
15370
15397
|
async function deliver([d3], work) {
|
|
15371
|
-
await worktrees.ensure(d3);
|
|
15372
15398
|
unsettled.add(d3.id);
|
|
15373
|
-
const
|
|
15399
|
+
const created = d3.eveSessionId ?? await createSession(net, eve(d3.bot, work.human), d3.sessionId).catch((error) => {
|
|
15400
|
+
if (error instanceof CreateRefused) return error;
|
|
15401
|
+
throw error;
|
|
15402
|
+
});
|
|
15403
|
+
if (created instanceof CreateRefused) return answered(d3, { kind: "refused", error: created.message });
|
|
15404
|
+
const eveSessionId = d3.eveSessionId ?? (await hubJson(`/api/sessions/${encodeURIComponent(d3.sessionId)}/eve`, { method: "POST", body: JSON.stringify({ eveSessionId: created }) })).eveSessionId;
|
|
15405
|
+
await worktrees.ensure({ ...d3, sessionId: eveSessionId });
|
|
15406
|
+
const { answer } = await sendToBot(net, eve(d3.bot, work.human), { ...d3, status: d3.status ?? null, eveSessionId }, d3.workspace, loadImage);
|
|
15374
15407
|
if (answer.kind === "ended") {
|
|
15375
|
-
await upload(d3.sessionId, d3.bot, work.human, 0, { toTail: true });
|
|
15408
|
+
await upload({ sessionId: d3.sessionId, eveSessionId }, d3.bot, work.human, 0, { toTail: true });
|
|
15376
15409
|
}
|
|
15377
|
-
|
|
15410
|
+
await answered(d3, answer);
|
|
15411
|
+
}
|
|
15412
|
+
async function answered(d3, answer) {
|
|
15413
|
+
const { stood } = await hubJson(`/api/deliveries/${encodeURIComponent(d3.id)}/answer`, { method: "POST", body: JSON.stringify({ sessionId: d3.sessionId, answer: { kind: answer.kind, ...answer.kind === "refused" ? { error: answer.error } : {} } }) });
|
|
15378
15414
|
log("delivery", d3.id, answer.kind, stood);
|
|
15379
15415
|
if (stood === "pending") throw new Error(`delivery ${d3.id}: eve's answer settled nothing`);
|
|
15380
15416
|
unsettled.delete(d3.id);
|
|
15381
15417
|
wake();
|
|
15382
15418
|
}
|
|
15383
|
-
async function upload(sessionId, bot, human, from, { toTail = false, signal } = {}) {
|
|
15419
|
+
async function upload({ sessionId, eveSessionId }, bot, human, from, { toTail = false, signal } = {}) {
|
|
15384
15420
|
const { url, headers } = eve(bot, human);
|
|
15385
|
-
const session = new Client({ host: new URL(url).origin, headers }).sessions.attach(
|
|
15421
|
+
const session = new Client({ host: new URL(url).origin, headers }).sessions.attach(eveSessionId);
|
|
15386
15422
|
let next = from, pending = [], posting = Promise.resolve(), failure, dropped = false;
|
|
15387
15423
|
const halt = new AbortController();
|
|
15388
15424
|
const post = (expired = false) => posting = posting.then(async () => {
|
|
@@ -15428,7 +15464,7 @@ async function run3(config, bots = realBots) {
|
|
|
15428
15464
|
if (following.has(s3.sessionId)) return;
|
|
15429
15465
|
const abort = new AbortController();
|
|
15430
15466
|
following.set(s3.sessionId, abort);
|
|
15431
|
-
void upload(s3.sessionId, s3.bot, human, s3.foldedIndex + 1, { signal: abort.signal }).then(
|
|
15467
|
+
void upload({ sessionId: s3.sessionId, eveSessionId: s3.eveSessionId ?? s3.sessionId }, s3.bot, human, s3.foldedIndex + 1, { signal: abort.signal }).then(
|
|
15432
15468
|
// eve's stream reached a boundary: the next pass re-reads what is still owed.
|
|
15433
15469
|
() => {
|
|
15434
15470
|
following.delete(s3.sessionId);
|
|
@@ -15442,7 +15478,7 @@ async function run3(config, bots = realBots) {
|
|
|
15442
15478
|
}
|
|
15443
15479
|
async function cancel(s3, human) {
|
|
15444
15480
|
for (const turnId of s3.cancel) {
|
|
15445
|
-
const res = await cancelTurn(net, eve(s3.bot, human), s3.
|
|
15481
|
+
const res = await cancelTurn(net, eve(s3.bot, human), s3.eveSessionId, turnId);
|
|
15446
15482
|
if (!res.ok) throw new Error(`cancel ${s3.sessionId}: HTTP ${res.status}`);
|
|
15447
15483
|
}
|
|
15448
15484
|
}
|
|
@@ -15478,10 +15514,10 @@ async function run3(config, bots = realBots) {
|
|
|
15478
15514
|
}
|
|
15479
15515
|
const compacting = /* @__PURE__ */ new Map();
|
|
15480
15516
|
const compacted = /* @__PURE__ */ new Map();
|
|
15481
|
-
async function compactOne(sessionId, bot, requestedAt, human) {
|
|
15517
|
+
async function compactOne({ sessionId, eveSessionId, bot, requestedAt }, human) {
|
|
15482
15518
|
if (compacted.get(sessionId)?.requestedAt !== requestedAt) {
|
|
15483
15519
|
const agent = running.get(bot);
|
|
15484
|
-
const answer = agent ? await compactSession(net, eve(bot, human),
|
|
15520
|
+
const answer = agent ? await compactSession(net, eve(bot, human), eveSessionId).then((r2) => ({ status: r2.status, body: typeof r2.body === "string" ? r2.body : JSON.stringify(r2.body) })) : { status: 503, body: "the bot is not running on this Mac" };
|
|
15485
15521
|
compacted.set(sessionId, { requestedAt, answer, reported: false });
|
|
15486
15522
|
}
|
|
15487
15523
|
const entry2 = compacted.get(sessionId);
|
|
@@ -15490,9 +15526,9 @@ async function run3(config, bots = realBots) {
|
|
|
15490
15526
|
}
|
|
15491
15527
|
function compact(work) {
|
|
15492
15528
|
for (const id2 of compacted.keys()) if (!work.compact.some((c3) => c3.sessionId === id2)) compacted.delete(id2);
|
|
15493
|
-
for (const
|
|
15494
|
-
if (compacting.has(sessionId) || compacted.get(sessionId)?.requestedAt === requestedAt && compacted.get(sessionId).reported) continue;
|
|
15495
|
-
compacting.set(sessionId, compactOne(
|
|
15529
|
+
for (const c3 of work.compact) {
|
|
15530
|
+
if (compacting.has(c3.sessionId) || compacted.get(c3.sessionId)?.requestedAt === c3.requestedAt && compacted.get(c3.sessionId).reported) continue;
|
|
15531
|
+
compacting.set(c3.sessionId, compactOne(c3, work.human).then(wake, (e9) => failed("compact", e9)).finally(() => compacting.delete(c3.sessionId)));
|
|
15496
15532
|
}
|
|
15497
15533
|
}
|
|
15498
15534
|
let syncing = null, again = false, drained = null;
|