@runuai/host 0.9.130 → 0.9.132
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/browser-testing.ts +7 -3
- package/lib/shared-files-s3.ts +13 -4
- package/lib/sidecar-resume.ts +108 -0
- package/lib/skills.ts +33 -1
- package/lib/task-environment/machine-task-up.ts +1 -1
- package/lib/task-environment/self.ts +15 -6
- package/package.json +1 -1
- package/src/index.ts +23 -0
- package/src/main.ts +16 -0
package/lib/browser-testing.ts
CHANGED
|
@@ -1035,9 +1035,13 @@ console.log(
|
|
|
1035
1035
|
*/
|
|
1036
1036
|
const VIEWER_STEPS: string[] = [
|
|
1037
1037
|
// Xvfb — one-shot; the X lockfile is the truth (also set when an agent
|
|
1038
|
-
// started the display itself). If it dies the lock clears and the
|
|
1039
|
-
// session ensure relaunches it.
|
|
1040
|
-
|
|
1038
|
+
// started the display itself). If it dies cleanly the lock clears and the
|
|
1039
|
+
// next session ensure relaunches it. A lock whose pid is DEAD is the
|
|
1040
|
+
// leftover of a SIGKILLed server — a sidecar restart kills its whole
|
|
1041
|
+
// cgroup (live 2026-09-10, smoke 10: Xvfb gone, lock present, x11vnc
|
|
1042
|
+
// looping on a display that never came back) — so clear it and the
|
|
1043
|
+
// socket first, then launch as usual.
|
|
1044
|
+
`[ -x /usr/bin/Xvfb ] || exit 0; if [ -e ${X_LOCK} ] && ! /usr/bin/kill -0 "$(/usr/bin/tr -d ' \\n' < ${X_LOCK})" 2>/dev/null; then /bin/rm -f ${X_LOCK} /tmp/.X11-unix/X${SERVER_DISPLAY.slice(1)}; fi; [ -e ${X_LOCK} ] || /usr/bin/nohup /usr/bin/${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
|
|
1041
1045
|
// x11vnc under a restart loop (it exits whenever the X server isn't up
|
|
1042
1046
|
// yet). Pidfile-guarded.
|
|
1043
1047
|
`[ -x /usr/bin/x11vnc ] || exit 0; [ -f /tmp/uai-x11vnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -A -f 'x11vnc -display' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do /usr/bin/x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
|
package/lib/shared-files-s3.ts
CHANGED
|
@@ -145,8 +145,15 @@ export async function mountSharedFilesFromLease(
|
|
|
145
145
|
argv: [
|
|
146
146
|
"/bin/sh",
|
|
147
147
|
"-c",
|
|
148
|
-
// Idempotent:
|
|
149
|
-
|
|
148
|
+
// Idempotent: a healthy mount is left alone. A mount whose daemon is
|
|
149
|
+
// gone still shows as mounted but every access fails with "Transport
|
|
150
|
+
// endpoint is not connected" (a sidecar restart SIGKILLs mount-s3 —
|
|
151
|
+
// live 2026-09-10, smoke 10); it is lazily unmounted and mounted
|
|
152
|
+
// again. The mkdir/chown tolerate the broken mount too.
|
|
153
|
+
'mkdir -p "$1" 2>/dev/null; chown "$2:$3" "$1" 2>/dev/null; ' +
|
|
154
|
+
'if mountpoint -q "$1" 2>/dev/null; then if ls "$1" >/dev/null 2>&1; then exit 0; fi; ' +
|
|
155
|
+
'umount -l "$1" 2>/dev/null || true; chown "$2:$3" "$1" 2>/dev/null; fi; ' +
|
|
156
|
+
'shift 3; exec "$@"',
|
|
150
157
|
"mount",
|
|
151
158
|
plan.directory,
|
|
152
159
|
String(owner.uid),
|
|
@@ -168,9 +175,11 @@ export async function mountSharedFilesFromLease(
|
|
|
168
175
|
}
|
|
169
176
|
if (mountedAny) {
|
|
170
177
|
// The same marker the container and machine paths write: it gates the
|
|
171
|
-
// "## Shared files" briefing in every agent's preamble.
|
|
178
|
+
// "## Shared files" briefing in every agent's preamble. The sidecar
|
|
179
|
+
// writes the MODE into it so a restart can mount again the same way
|
|
180
|
+
// (lib/sidecar-resume.ts); readers only test for the file.
|
|
172
181
|
await handle
|
|
173
|
-
.writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(
|
|
182
|
+
.writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(mode))
|
|
174
183
|
.catch(() => {});
|
|
175
184
|
}
|
|
176
185
|
return warnings.length > 0 ? warnings.join("\n") : null;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-133: what a sidecar re-asserts when it starts on a machine whose task
|
|
3
|
+
* is ALREADY up — a restart of the unit, not a fresh launch.
|
|
4
|
+
*
|
|
5
|
+
* Task-up mounts the shared files and launches the editor once; nothing ran
|
|
6
|
+
* them again when the runner came back, so an operator's restart (smoke 10,
|
|
7
|
+
* 2026-09-10) left /workspace/files as two dead endpoints and the Editor tab
|
|
8
|
+
* empty for the rest of the task. The unit now keeps those processes alive
|
|
9
|
+
* across a stop (KillMode=process), and this pass covers what that cannot:
|
|
10
|
+
* a crash, an older unit, a SIGKILL. Both steps are idempotent — a healthy
|
|
11
|
+
* mount and a live code-server are left alone — so running it on every
|
|
12
|
+
* lease-ready costs one stat each.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { eq } from "drizzle-orm";
|
|
16
|
+
|
|
17
|
+
import * as schema from "../db/schema";
|
|
18
|
+
import { getDb } from "./db";
|
|
19
|
+
import { mountSharedFilesFromLease } from "./shared-files-s3";
|
|
20
|
+
import { sidecarTaskId } from "./sidecar";
|
|
21
|
+
import { reconstructPersistedTaskEnvironment } from "./task-environment";
|
|
22
|
+
import { ensureCodeServer } from "./task-environment/machine-task-up";
|
|
23
|
+
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
24
|
+
|
|
25
|
+
type PersistedTask = typeof schema.hostTasks.$inferSelect;
|
|
26
|
+
|
|
27
|
+
export interface SidecarResumeDeps {
|
|
28
|
+
taskId?: () => string | null;
|
|
29
|
+
persistedTask?: (taskId: string) => PersistedTask | undefined;
|
|
30
|
+
reconstruct?: (task: PersistedTask) => Promise<TaskEnvironmentHandle<unknown> | null>;
|
|
31
|
+
mount?: (
|
|
32
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
33
|
+
workspacePath: string,
|
|
34
|
+
task: { sharedFiles?: string },
|
|
35
|
+
) => Promise<string | null>;
|
|
36
|
+
editor?: (handle: TaskEnvironmentHandle<unknown>, workspacePath: string) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SidecarResumeResult {
|
|
40
|
+
/** `no-task`: not a sidecar. `not-up`: the task has not been provisioned on
|
|
41
|
+
* this machine yet (a fresh launch — task-up does the work). `resumed`: the
|
|
42
|
+
* task was up before this runner started; its mounts and editor were
|
|
43
|
+
* re-asserted, with any per-step warning listed. */
|
|
44
|
+
outcome: "no-task" | "not-up" | "resumed";
|
|
45
|
+
warnings: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The mode task-up mounted with, recorded in the files-mounted marker; null
|
|
49
|
+
* when the files were never mounted (mode off, or the mount failed then). */
|
|
50
|
+
function mountedModeFromMarker(bytes: Uint8Array | null): "ro" | "rw" | null {
|
|
51
|
+
if (bytes === null) return null;
|
|
52
|
+
return Buffer.from(bytes).toString("utf8").trim() === "rw" ? "rw" : "ro";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function resumeSidecarTask(
|
|
56
|
+
deps: SidecarResumeDeps = {},
|
|
57
|
+
): Promise<SidecarResumeResult> {
|
|
58
|
+
const taskId = (deps.taskId ?? sidecarTaskId)();
|
|
59
|
+
if (!taskId) return { outcome: "no-task", warnings: [] };
|
|
60
|
+
const task = (deps.persistedTask ?? defaultPersistedTask)(taskId);
|
|
61
|
+
if (!task?.environmentLocator) return { outcome: "not-up", warnings: [] };
|
|
62
|
+
const handle = await (deps.reconstruct ?? reconstructPersistedTaskEnvironment)(task);
|
|
63
|
+
if (!handle) return { outcome: "not-up", warnings: [] };
|
|
64
|
+
const workspacePath = handle.descriptor.workspacePath;
|
|
65
|
+
const warnings: string[] = [];
|
|
66
|
+
|
|
67
|
+
const marker = await handle
|
|
68
|
+
.readWorkspaceFile(`${workspacePath}/.uai/files-mounted`)
|
|
69
|
+
.catch(() => null);
|
|
70
|
+
const mode = mountedModeFromMarker(marker);
|
|
71
|
+
if (mode) {
|
|
72
|
+
try {
|
|
73
|
+
const warning = await (deps.mount ?? mountSharedFilesFromLease)(handle, workspacePath, {
|
|
74
|
+
sharedFiles: mode,
|
|
75
|
+
});
|
|
76
|
+
if (warning) warnings.push(warning);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
warnings.push(
|
|
79
|
+
`shared files: ${error instanceof Error ? error.message : String(error)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await (deps.editor ?? defaultEditor)(handle, workspacePath);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
warnings.push(`editor: ${error instanceof Error ? error.message : String(error)}`);
|
|
88
|
+
}
|
|
89
|
+
return { outcome: "resumed", warnings };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function defaultPersistedTask(taskId: string): PersistedTask | undefined {
|
|
93
|
+
return getDb()
|
|
94
|
+
.select()
|
|
95
|
+
.from(schema.hostTasks)
|
|
96
|
+
.where(eq(schema.hostTasks.taskId, taskId))
|
|
97
|
+
.get();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultEditor(
|
|
101
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
102
|
+
workspacePath: string,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
// The theme rode task-up's preview env and is not persisted; the settings
|
|
105
|
+
// seed is skipped when the file already exists, so an existing task keeps
|
|
106
|
+
// the theme it started with.
|
|
107
|
+
return ensureCodeServer(handle, workspacePath, undefined);
|
|
108
|
+
}
|
package/lib/skills.ts
CHANGED
|
@@ -203,6 +203,34 @@ const dockerExec: SkillExec = async (container, cmd, cwd) => {
|
|
|
203
203
|
return { status: res.status, stderr: res.stderr };
|
|
204
204
|
};
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* A `skills add …` install command (github.com/vercel-labs/skills, the CLI
|
|
208
|
+
* behind the skills marketplace) asks which agents to install for and for a
|
|
209
|
+
* confirmation unless an agent is detected or `-y`/`--agent` are given. The
|
|
210
|
+
* install runs at task-up with no TTY and, on a fresh machine, before any
|
|
211
|
+
* agent has left its footprint, so the CLI refused: "Interactive prompt
|
|
212
|
+
* required but stdin is not a TTY … Use --agent <name> and -y" (every task
|
|
213
|
+
* start since the Marketing persona gained the skill; live 2026-09-10).
|
|
214
|
+
* Insert the two flags right after `skills add` when the author left them
|
|
215
|
+
* out — after the subcommand, so a trailing `&& …` or a leading `cd …`
|
|
216
|
+
* keeps working. Any other install command is returned as written.
|
|
217
|
+
*/
|
|
218
|
+
export function nonInteractiveInstallCommand(command: string): string {
|
|
219
|
+
const add = /\bskills\s+add\b/;
|
|
220
|
+
const found = add.exec(command);
|
|
221
|
+
if (!found) return command;
|
|
222
|
+
// Only the flags AFTER the subcommand count: `npx -y skills add …` is npx's
|
|
223
|
+
// own -y, not the CLI's.
|
|
224
|
+
const rest = command.slice(found.index + found[0].length);
|
|
225
|
+
const flags: string[] = [];
|
|
226
|
+
if (!/(^|\s)(-y|--yes)(\s|$)/.test(rest)) flags.push("-y");
|
|
227
|
+
if (!/(^|\s)(-a|--agent)(\s|=)/.test(rest)) flags.push("--agent claude-code");
|
|
228
|
+
if (flags.length === 0) return command;
|
|
229
|
+
return (
|
|
230
|
+
command.slice(0, found.index + found[0].length) + ` ${flags.join(" ")}` + rest
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
206
234
|
/**
|
|
207
235
|
* Install a roster's package skills into the task container (ADR-047). Runs at
|
|
208
236
|
* task-up (after the container is running), mirroring the git-identity/github
|
|
@@ -266,7 +294,11 @@ export async function installPackageSkills(
|
|
|
266
294
|
continue;
|
|
267
295
|
}
|
|
268
296
|
// The author's CLI drops files into .claude/skills; run it in the repo.
|
|
269
|
-
const c = await exec(
|
|
297
|
+
const c = await exec(
|
|
298
|
+
container,
|
|
299
|
+
["bash", "-lc", nonInteractiveInstallCommand(s.value)],
|
|
300
|
+
"/workspace",
|
|
301
|
+
);
|
|
270
302
|
if (c.status !== 0) {
|
|
271
303
|
console.warn(`[skills] ${taskId}: install cmd for ${s.name} failed: ${c.stderr.trim()}`);
|
|
272
304
|
continue;
|
|
@@ -388,7 +388,7 @@ async function copySharedFiles(
|
|
|
388
388
|
/** Launch code-server for the Editor pane, exactly once. The pgrep guard
|
|
389
389
|
* makes re-provision and resume idempotent; the exec chain leaves only
|
|
390
390
|
* code-server holding the detached job. */
|
|
391
|
-
async function ensureCodeServer(
|
|
391
|
+
export async function ensureCodeServer(
|
|
392
392
|
handle: TaskEnvironmentHandle<unknown>,
|
|
393
393
|
workspacePath: string,
|
|
394
394
|
editorTheme: string | undefined,
|
|
@@ -287,13 +287,22 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
287
287
|
) {
|
|
288
288
|
throw new Error("self copy paths must be absolute and NUL-free");
|
|
289
289
|
}
|
|
290
|
-
// Both sides are this machine.
|
|
291
|
-
//
|
|
292
|
-
//
|
|
290
|
+
// Both sides are this machine. A directory source lands as the
|
|
291
|
+
// destination's CONTENTS, the way the machine provider's tar stream
|
|
292
|
+
// does: engine-account provisioning pre-creates the destination, and a
|
|
293
|
+
// plain `cp -a dir dest` into an existing dest nests `dest/dir/…`, so
|
|
294
|
+
// CODEX_HOME / GROK_HOME pointed at a dir without auth.json and both
|
|
295
|
+
// engines ran signed out and silent (live 2026-09-10, first codex + grok
|
|
296
|
+
// agents on a task machine). A file source copies as before. The result
|
|
297
|
+
// is chowned to the task user when this process is root, so the agents
|
|
298
|
+
// can read what was copied in for them.
|
|
299
|
+
const copy =
|
|
300
|
+
'if [ -d "$1" ]; then mkdir -p "$2" && cp -a "$1/." "$2/"; ' +
|
|
301
|
+
'else mkdir -p "$(dirname "$2")" && cp -a "$1" "$2"; fi';
|
|
293
302
|
const script =
|
|
294
|
-
request.direction === "into"
|
|
295
|
-
?
|
|
296
|
-
:
|
|
303
|
+
request.direction === "into" && this.#privileged()
|
|
304
|
+
? `${copy} && chown -R ${SELF_TASK_USER}:${SELF_TASK_USER} "$2"`
|
|
305
|
+
: copy;
|
|
297
306
|
const result = await this.exec({
|
|
298
307
|
argv: ["/bin/sh", "-c", script, "copy", request.source, request.destination],
|
|
299
308
|
user: "root",
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -201,11 +201,32 @@ export function setManagedHostRestartHook(hook: () => void): void {
|
|
|
201
201
|
managedHostRestartHook = hook;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* ADR-133: a task machine's runner is pinned by the cloud at launch and its
|
|
206
|
+
* unit's cgroup holds the task itself — the S3 mounts, the display, the
|
|
207
|
+
* editor, the agent sessions. An in-place update or restart replaces
|
|
208
|
+
* nothing (the prepare step reinstalls the pin) and killed all of those
|
|
209
|
+
* (live 2026-09-10, smoke 10). Both verbs are refused here; a new task gets
|
|
210
|
+
* a fresh machine with the current runner.
|
|
211
|
+
*/
|
|
212
|
+
function sidecarRunnerIsPinned(): HostCommandResult<never> | null {
|
|
213
|
+
if (!sidecarMode()) return null;
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
code: HostErrorCode.Internal,
|
|
217
|
+
message:
|
|
218
|
+
"a task machine's runner is pinned for the task's life — it neither updates nor restarts in place; close the task and start another for the current runner",
|
|
219
|
+
retryable: false,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
204
223
|
export const hostCommands: HostCommands = {
|
|
205
224
|
// The hostId argument is cloud-side routing (host-scoped commands carry
|
|
206
225
|
// their target in args, like filesOp); this process IS the target.
|
|
207
226
|
async hostUpdate(ctx, _hostId) {
|
|
208
227
|
logCommand(ctx, "hostUpdate");
|
|
228
|
+
const pinned = sidecarRunnerIsPinned();
|
|
229
|
+
if (pinned) return pinned;
|
|
209
230
|
try {
|
|
210
231
|
const { updateManagedRuntime } = await import("../lib/managed-runtime");
|
|
211
232
|
const outcome = await updateManagedRuntime();
|
|
@@ -244,6 +265,8 @@ export const hostCommands: HostCommands = {
|
|
|
244
265
|
},
|
|
245
266
|
async hostRestart(ctx, _hostId) {
|
|
246
267
|
logCommand(ctx, "hostRestart");
|
|
268
|
+
const pinned = sidecarRunnerIsPinned();
|
|
269
|
+
if (pinned) return pinned;
|
|
247
270
|
if (!managedHostRestartHook) {
|
|
248
271
|
return {
|
|
249
272
|
ok: false,
|
package/src/main.ts
CHANGED
|
@@ -252,6 +252,7 @@ import {
|
|
|
252
252
|
import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
|
|
253
253
|
import { LeaseManager } from "../lib/lease";
|
|
254
254
|
import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
|
|
255
|
+
import { resumeSidecarTask } from "../lib/sidecar-resume";
|
|
255
256
|
import { declareAgentClisReady } from "../lib/standard-image";
|
|
256
257
|
|
|
257
258
|
setContainerRuntimeOperationalCheck(() => containerRuntimeProblem() === null);
|
|
@@ -552,6 +553,21 @@ const leaseManager: LeaseManager | null = sidecarMode()
|
|
|
552
553
|
// tokens, and project env on the machine (lib/sidecar.ts).
|
|
553
554
|
markSidecarReady();
|
|
554
555
|
console.log("[lease] credential store reconciled — the task may start");
|
|
556
|
+
// A runner that came back on a machine whose task is already up
|
|
557
|
+
// re-asserts the mounts and the editor (lib/sidecar-resume.ts).
|
|
558
|
+
void resumeSidecarTask()
|
|
559
|
+
.then((resumed) => {
|
|
560
|
+
if (resumed.outcome !== "resumed") return;
|
|
561
|
+
console.log(
|
|
562
|
+
`[sidecar] task ${sidecarTaskId()} resumed after a runner restart: shared files and editor re-asserted` +
|
|
563
|
+
(resumed.warnings.length > 0 ? ` (${resumed.warnings.join("; ")})` : ""),
|
|
564
|
+
);
|
|
565
|
+
})
|
|
566
|
+
.catch((error: unknown) => {
|
|
567
|
+
console.warn(
|
|
568
|
+
`[sidecar] resume failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
569
|
+
);
|
|
570
|
+
});
|
|
555
571
|
});
|
|
556
572
|
startCredentialStorePickup({ repush: repushBrokeredCopies });
|
|
557
573
|
},
|