@runuai/host 0.8.29 → 0.8.34
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/lib/agents/claude.ts +25 -1
- package/lib/agents/factory.ts +26 -0
- package/lib/standard-image.ts +57 -8
- package/package.json +1 -1
- package/runner/runner.mjs +9 -3
- package/scripts/agent/task-up.sh +9 -0
package/lib/agents/claude.ts
CHANGED
|
@@ -209,6 +209,13 @@ export class ClaudeSession implements AgentSession {
|
|
|
209
209
|
private readonly proc: LineTransport;
|
|
210
210
|
private readonly handlers = new Set<AgentEventHandler>();
|
|
211
211
|
private closed = false;
|
|
212
|
+
// Claude's `total_cost_usd` is CUMULATIVE across the persistent session
|
|
213
|
+
// process (ADR-061 keeps one claude alive across turns), so per-turn cost is
|
|
214
|
+
// the delta from the previous turn — otherwise summing turns over-counts
|
|
215
|
+
// wildly (live 2026-07-22: a 4-turn task billed ~4x). Token counts, by
|
|
216
|
+
// contrast, are per-turn already. Reset per session; a respawn makes a fresh
|
|
217
|
+
// ClaudeSession, so this starts at 0 alongside the CLI's own counter.
|
|
218
|
+
private lastCumulativeCostUsd = 0;
|
|
212
219
|
|
|
213
220
|
constructor(args: {
|
|
214
221
|
taskId: string;
|
|
@@ -262,7 +269,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
262
269
|
debugLabel: `claude:${this.agentId}`,
|
|
263
270
|
});
|
|
264
271
|
this.proc.onLine((line) => {
|
|
265
|
-
for (const event of mapClaudeLine(line)) this.emit(event);
|
|
272
|
+
for (const event of mapClaudeLine(line)) this.emit(this.perTurnCost(event));
|
|
266
273
|
});
|
|
267
274
|
this.proc.onExit((code) => {
|
|
268
275
|
if (this.closed) return;
|
|
@@ -301,6 +308,23 @@ export class ClaudeSession implements AgentSession {
|
|
|
301
308
|
for (const h of this.handlers) h(event);
|
|
302
309
|
}
|
|
303
310
|
|
|
311
|
+
/** Convert Claude's cumulative session cost to this turn's delta. Tokens are
|
|
312
|
+
* already per-turn and pass through untouched. */
|
|
313
|
+
private perTurnCost(event: AgentEvent): AgentEvent {
|
|
314
|
+
if (event.type !== "turn_complete" || event.usage?.costUsd === undefined) {
|
|
315
|
+
return event;
|
|
316
|
+
}
|
|
317
|
+
const cumulative = event.usage.costUsd;
|
|
318
|
+
const delta = cumulative - this.lastCumulativeCostUsd;
|
|
319
|
+
this.lastCumulativeCostUsd = cumulative;
|
|
320
|
+
// A negative delta means the CLI's counter reset (a fresh process) — the
|
|
321
|
+
// reported value is then already this turn's cost.
|
|
322
|
+
return {
|
|
323
|
+
...event,
|
|
324
|
+
usage: { ...event.usage, costUsd: delta >= 0 ? delta : cumulative },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
304
328
|
async send(text: string): Promise<void> {
|
|
305
329
|
if (this.closed) return;
|
|
306
330
|
this.proc.writeLine({
|
package/lib/agents/factory.ts
CHANGED
|
@@ -21,9 +21,30 @@ import "./kimi";
|
|
|
21
21
|
import "./grok";
|
|
22
22
|
import "./cursor";
|
|
23
23
|
|
|
24
|
+
import { agentClisReady } from "../standard-image";
|
|
24
25
|
import { factoryFor } from "./registry";
|
|
25
26
|
import type { AgentSession, AgentSessionFactory } from "./types";
|
|
26
27
|
|
|
28
|
+
/** Cap the wait on agentClisReady so a spawn can never hang forever if the
|
|
29
|
+
* boot reconcile never signals (e.g. an unexpected code path). Generous —
|
|
30
|
+
* the reconcile normally settles in seconds; this only guards a stuck boot. */
|
|
31
|
+
const CLIS_READY_TIMEOUT_MS = 90_000;
|
|
32
|
+
|
|
33
|
+
/** Await the shared-volume CLI reconcile, bounded by a self-clearing timeout so
|
|
34
|
+
* neither a stuck boot nor a per-spawn timer leak can bite. */
|
|
35
|
+
async function awaitAgentClisReady(): Promise<void> {
|
|
36
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
37
|
+
const bound = new Promise<void>((resolve) => {
|
|
38
|
+
timer = setTimeout(resolve, CLIS_READY_TIMEOUT_MS);
|
|
39
|
+
timer.unref?.();
|
|
40
|
+
});
|
|
41
|
+
try {
|
|
42
|
+
await Promise.race([agentClisReady, bound]);
|
|
43
|
+
} finally {
|
|
44
|
+
if (timer) clearTimeout(timer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
27
48
|
export const realAgentFactory: AgentSessionFactory = {
|
|
28
49
|
create: async (args): Promise<AgentSession> => {
|
|
29
50
|
const factory = factoryFor(args.agent.kind);
|
|
@@ -32,6 +53,11 @@ export const realAgentFactory: AgentSessionFactory = {
|
|
|
32
53
|
`no agent adapter registered for kind "${args.agent.kind}"`,
|
|
33
54
|
);
|
|
34
55
|
}
|
|
56
|
+
// Don't spawn a CLI until the shared-volume agent CLIs are reconciled:
|
|
57
|
+
// at boot the CLI auto-upgrade briefly removes then reinstalls codex/claude,
|
|
58
|
+
// and a resume that races that window dies with "No codex executable found
|
|
59
|
+
// for nodejs X". Post-boot this is already resolved (instant).
|
|
60
|
+
await awaitAgentClisReady();
|
|
35
61
|
return factory.create(args);
|
|
36
62
|
},
|
|
37
63
|
};
|
package/lib/standard-image.ts
CHANGED
|
@@ -176,17 +176,28 @@ const VOLUME_AGENT_CLIS: { bin: string; pkg: string }[] = [
|
|
|
176
176
|
*/
|
|
177
177
|
async function ensureVolumeAgentClis(): Promise<void> {
|
|
178
178
|
const bins = VOLUME_AGENT_CLIS.map((c) => c.bin).join(" ");
|
|
179
|
-
//
|
|
180
|
-
//
|
|
179
|
+
// Probe by EXECUTING each bin (`--version`), not `command -v`. A stale asdf
|
|
180
|
+
// shim — the shim file survives in the volume but the reshim DB no longer
|
|
181
|
+
// maps it to an installed exe — passes `command -v` (the file is present and
|
|
182
|
+
// executable) yet aborts at runtime with "unknown command: codex. Perhaps
|
|
183
|
+
// you have to reshim?" (exit 1). That made a broken codex/claude read as
|
|
184
|
+
// "present," so the self-heal skipped it and every task/resume spawn failed,
|
|
185
|
+
// restart after restart (live 2026-07-22). Running the bin is the only probe
|
|
186
|
+
// that catches a stale shim. `-w /home/node` so asdf resolves the node
|
|
187
|
+
// version from /home/node/.tool-versions — the same one the repair installs
|
|
188
|
+
// against — and the sentinel prefix keeps login-shell noise from being
|
|
189
|
+
// misread as a missing bin.
|
|
181
190
|
const check = await run("docker", [
|
|
182
191
|
"run",
|
|
183
192
|
"--rm",
|
|
193
|
+
"-w",
|
|
194
|
+
"/home/node",
|
|
184
195
|
"-v",
|
|
185
196
|
`${ASDF_DATA_VOLUME}:/opt/asdf-data`,
|
|
186
197
|
STANDARD_IMAGE_TAG,
|
|
187
198
|
"bash",
|
|
188
199
|
"-lc",
|
|
189
|
-
`for b in ${bins}; do
|
|
200
|
+
`for b in ${bins}; do "$b" --version >/dev/null 2>&1 || echo "UAI_MISSING:$b"; done`,
|
|
190
201
|
]);
|
|
191
202
|
if (check.code !== 0) {
|
|
192
203
|
console.warn(
|
|
@@ -207,7 +218,7 @@ async function ensureVolumeAgentClis(): Promise<void> {
|
|
|
207
218
|
.join(" ");
|
|
208
219
|
console.log(
|
|
209
220
|
`[host-agent] repairing ${ASDF_DATA_VOLUME}: agent CLI(s) [${missing.join(", ")}] ` +
|
|
210
|
-
`missing
|
|
221
|
+
`broken/missing in the shared volume — reshim + install ${pkgs}`,
|
|
211
222
|
);
|
|
212
223
|
const repair = await run("docker", [
|
|
213
224
|
"run",
|
|
@@ -225,7 +236,17 @@ async function ensureVolumeAgentClis(): Promise<void> {
|
|
|
225
236
|
STANDARD_IMAGE_TAG,
|
|
226
237
|
"bash",
|
|
227
238
|
"-lc",
|
|
228
|
-
|
|
239
|
+
// Reshim FIRST and with `;` (not `&&`): the common failure is a stale shim
|
|
240
|
+
// over an already-installed package, which a bare reshim fixes offline —
|
|
241
|
+
// gating it behind a network `npm install` (which may be down) would leave
|
|
242
|
+
// it broken. Then install to cover a genuinely-missing package, and reshim
|
|
243
|
+
// again for the freshly-installed one. Every step runs regardless of the
|
|
244
|
+
// previous one's exit. Final loop re-probes by execution so the exit code
|
|
245
|
+
// (and the log below) reflect whether the bins ACTUALLY run now, not just
|
|
246
|
+
// the trailing reshim's own exit.
|
|
247
|
+
`asdf reshim nodejs; npm install -g ${pkgs}; asdf reshim nodejs; ` +
|
|
248
|
+
`for b in ${bins}; do "$b" --version >/dev/null 2>&1 || ` +
|
|
249
|
+
`{ echo "UAI_STILL_BROKEN:$b" >&2; exit 1; }; done`,
|
|
229
250
|
]);
|
|
230
251
|
if (repair.code === 0) {
|
|
231
252
|
console.log(
|
|
@@ -313,14 +334,38 @@ export interface StandardImageResult {
|
|
|
313
334
|
error?: string;
|
|
314
335
|
}
|
|
315
336
|
|
|
337
|
+
let signalClisReady: (() => void) | null = null;
|
|
338
|
+
/**
|
|
339
|
+
* Resolves once ensureStandardImage has reconciled the shared-volume agent CLIs
|
|
340
|
+
* at least once (or bailed because docker is down / the build failed). The agent
|
|
341
|
+
* factory awaits this before spawning a CLI, so a boot-time resume never races
|
|
342
|
+
* the CLI auto-upgrade's brief remove-then-reinstall window — the "No codex
|
|
343
|
+
* executable found for nodejs X" (exit 2) resume failure. Post-boot it is
|
|
344
|
+
* already resolved, so the await is a no-op.
|
|
345
|
+
*/
|
|
346
|
+
export const agentClisReady: Promise<void> = new Promise<void>((resolve) => {
|
|
347
|
+
signalClisReady = resolve;
|
|
348
|
+
});
|
|
349
|
+
|
|
316
350
|
/**
|
|
317
351
|
* Ensure the standard image and the shared asdf data volume exist, and that the
|
|
318
352
|
* agent CLIs are present inside the volume. Builds the image only when `docker
|
|
319
353
|
* image inspect` fails or the content hash is stale. Never throws — returns
|
|
320
354
|
* `{ ok, error? }` so the caller decides (boot: fire-and-forget; taskUp:
|
|
321
|
-
* surface the reason).
|
|
355
|
+
* surface the reason). Resolves `agentClisReady` on every exit path.
|
|
322
356
|
*/
|
|
323
357
|
export async function ensureStandardImage(): Promise<StandardImageResult> {
|
|
358
|
+
try {
|
|
359
|
+
return await ensureStandardImageInner();
|
|
360
|
+
} finally {
|
|
361
|
+
// Always signal — even a docker-down bail — so agent spawns never hang
|
|
362
|
+
// waiting on a reconcile that will not happen this boot.
|
|
363
|
+
signalClisReady?.();
|
|
364
|
+
signalClisReady = null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function ensureStandardImageInner(): Promise<StandardImageResult> {
|
|
324
369
|
// 1. Shared asdf data volume — idempotent.
|
|
325
370
|
const vol = await run("docker", ["volume", "create", ASDF_DATA_VOLUME]);
|
|
326
371
|
if (vol.code !== 0) {
|
|
@@ -412,11 +457,15 @@ export async function ensureStandardImage(): Promise<StandardImageResult> {
|
|
|
412
457
|
}
|
|
413
458
|
}
|
|
414
459
|
|
|
415
|
-
// 3. Reconcile the agent CLIs
|
|
460
|
+
// 3. Reconcile the agent CLIs in the shared volume (it shadows the image's
|
|
416
461
|
// shims, so a stale volume can be missing one — the claude-127 bug).
|
|
462
|
+
// Upgrade FIRST, self-heal LAST: the upgrade's `rm -rf`+reinstall fallback
|
|
463
|
+
// can leave a stale shim (a failed reinstall over a removed package), so
|
|
464
|
+
// ensureVolumeAgentClis must be the final word — it verifies every bin
|
|
465
|
+
// actually runs and repairs whatever the upgrade left broken.
|
|
417
466
|
if (imageReady) {
|
|
418
|
-
await ensureVolumeAgentClis();
|
|
419
467
|
await upgradeVolumeAgentClis();
|
|
468
|
+
await ensureVolumeAgentClis();
|
|
420
469
|
return { ok: true };
|
|
421
470
|
}
|
|
422
471
|
return {
|
package/package.json
CHANGED
package/runner/runner.mjs
CHANGED
|
@@ -64,11 +64,17 @@ function meta(kind, extra = {}) {
|
|
|
64
64
|
// exports its resolved version (ASDF_NODEJS_VERSION et al.) into our env.
|
|
65
65
|
// Passing that through would pin the CLI's own asdf shim to the WORKSPACE's
|
|
66
66
|
// node version — "No claude executable found for nodejs X" when the agent
|
|
67
|
-
// CLIs are installed under a different one.
|
|
68
|
-
//
|
|
67
|
+
// CLIs are installed under a different one. So strip the per-tool VERSION
|
|
68
|
+
// PINS only (ASDF_NODEJS_VERSION, ASDF_<TOOL>_VERSION) — NOT the whole ASDF_*
|
|
69
|
+
// namespace: ASDF_DATA_DIR (/opt/asdf-data) and ASDF_DIR (/opt/asdf) tell the
|
|
70
|
+
// shim's `asdf exec` WHERE the tools live. Drop those and asdf falls back to
|
|
71
|
+
// $HOME/.asdf (empty), so EVERY shim aborts with "unknown command: codex.
|
|
72
|
+
// Perhaps you have to reshim?" (exit 1) — the codex/claude resume failure on
|
|
73
|
+
// every host restart, host 0.8.32. `ASDF_VERSION` (asdf's own version) has no
|
|
74
|
+
// middle segment, so the pin regex leaves it — harmless either way.
|
|
69
75
|
const cliEnv = { ...process.env };
|
|
70
76
|
for (const key of Object.keys(cliEnv)) {
|
|
71
|
-
if (key
|
|
77
|
+
if (/^ASDF_.+_VERSION$/.test(key)) delete cliEnv[key];
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: cliEnv });
|
package/scripts/agent/task-up.sh
CHANGED
|
@@ -206,6 +206,15 @@ while IFS= read -r project_obj; do
|
|
|
206
206
|
# (found live 2026-07-21). The task branch and any uncommitted work are
|
|
207
207
|
# exactly what the user expects back.
|
|
208
208
|
log "worktree for $project_slug already present — resuming with it"
|
|
209
|
+
# Self-heal an ORPHANED worktree. The bare mirror is shared by every task
|
|
210
|
+
# on this project, so a prune of it (auto-gc, or a sibling's task-down)
|
|
211
|
+
# can delete THIS worktree's admin dir (repo.git/worktrees/<name>) while
|
|
212
|
+
# its working tree is momentarily unreachable — e.g. the re-mount window on
|
|
213
|
+
# resume. The .git file is then a dangling pointer and every in-container
|
|
214
|
+
# git op dies "fatal: not a git repository" (found live 2026-07-24, a
|
|
215
|
+
# blocked agent unable to commit). `git worktree repair` rebuilds the admin
|
|
216
|
+
# dir from the intact working tree; it is a no-op when the link is healthy.
|
|
217
|
+
git -C "$mirror_dir" worktree repair "$worktree_target" 2>/dev/null || true
|
|
209
218
|
elif git -C "$mirror_dir" rev-parse --verify --quiet \
|
|
210
219
|
"refs/remotes/origin/$project_default" >/dev/null 2>&1; then
|
|
211
220
|
# Normal case: branch the task worktree off origin/<defaultBranch>.
|