@runuai/host 0.9.131 → 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.
@@ -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 next
1039
- // session ensure relaunches it.
1040
- `[ -x /usr/bin/Xvfb ] || exit 0; [ -e ${X_LOCK} ] || /usr/bin/nohup /usr/bin/${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
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 &`,
@@ -145,8 +145,15 @@ export async function mountSharedFilesFromLease(
145
145
  argv: [
146
146
  "/bin/sh",
147
147
  "-c",
148
- // Idempotent: an already-mounted directory is left alone.
149
- 'mkdir -p "$1" && chown "$2:$3" "$1"; if mountpoint -q "$1"; then exit 0; fi; shift 3; exec "$@"',
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(container, ["bash", "-lc", s.value], "/workspace");
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.131",
3
+ "version": "0.9.132",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
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
  },