@runuai/host 0.9.78 → 0.9.80

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.
@@ -419,9 +419,25 @@ install_folder_deps() {
419
419
 
420
420
  reject_workspace_runtime_shadow || exit 78
421
421
 
422
- if [ -d "$WORKSPACE" ] && [ -f "$RUNTIME_PROJECTS" ] \
423
- && [ ! -L "$RUNTIME_PROJECTS" ]; then
424
- seen_runtime_projects="|"
422
+ # ADR-122: dependency installs leave the task-start critical path. The loop
423
+ # below runs in a DETACHED background process so uai-init (and with it
424
+ # task-up) returns as soon as the cheap, provable work is done — the editor
425
+ # and the agents start immediately, warm or cold. Progress is observable in
426
+ # the workspace itself:
427
+ #
428
+ # $WORKSPACE/.uai/init/deps-status running | done | failed | authority-violation
429
+ # $WORKSPACE/.uai/init/deps.log the full install transcript
430
+ #
431
+ # The runtime-authority posture narrows deliberately: the synchronous
432
+ # workspace-shadow proofs above and below still exit 78 (task-up
433
+ # quarantines), but a shadow that APPEARS MID-INSTALL can no longer stop the
434
+ # task — agents are already running by then, and an agent could author the
435
+ # same shadow a second later anyway. The runner still refuses to run any
436
+ # further dependency command under a shadowed root and records
437
+ # `authority-violation`; the shared asdf volume stays read-only-proven
438
+ # regardless.
439
+ run_workspace_installs() {
440
+ local seen_runtime_projects="|" project_slug folder git_marker
425
441
  while IFS= read -r project_slug || [ -n "$project_slug" ]; do
426
442
  if [[ ! "$project_slug" =~ ^[a-z0-9-]{1,64}$ ]]; then
427
443
  log "warning: unsafe runtime-project allowlist entry — skipping"
@@ -441,11 +457,67 @@ if [ -d "$WORKSPACE" ] && [ -f "$RUNTIME_PROJECTS" ] \
441
457
  log "warning: allowlisted project $project_slug is not a safe Git worktree — skipping"
442
458
  continue
443
459
  fi
444
- reject_workspace_runtime_shadow || exit 78
460
+ if ! reject_workspace_runtime_shadow; then
461
+ printf 'authority-violation\n' > "$deps_status_file"
462
+ return 78
463
+ fi
445
464
  # Run in a subshell so a `cd` (or a failing command under the relaxed
446
465
  # error mode) in one folder never leaks into the next.
447
466
  ( install_folder_deps "$folder" )
448
467
  done < "$RUNTIME_PROJECTS"
468
+ return 0
469
+ }
470
+
471
+ if [ -d "$WORKSPACE" ] && [ -f "$RUNTIME_PROJECTS" ] \
472
+ && [ ! -L "$RUNTIME_PROJECTS" ]; then
473
+ deps_state_dir="$WORKSPACE/.uai/init"
474
+ deps_status_file="$deps_state_dir/deps-status"
475
+ deps_log_file="$deps_state_dir/deps.log"
476
+ deps_lock_dir="$deps_state_dir/deps.lock.d"
477
+ mkdir -p "$deps_state_dir"
478
+
479
+ # One runner at a time. uai-init is re-runnable (task-up retry, recovery),
480
+ # and each re-run SHOULD refresh dependencies — but never concurrently.
481
+ # mkdir is the portable atomic lock; a pid that no longer answers marks a
482
+ # crashed runner whose lock is stale and may be stolen.
483
+ deps_spawn=1
484
+ if mkdir "$deps_lock_dir" 2>/dev/null; then
485
+ # Hold the lock as ourselves until the runner's pid replaces it, so a
486
+ # concurrent uai-init never reads an empty pid file as a stale lock.
487
+ printf '%s\n' "$$" > "$deps_lock_dir/pid"
488
+ else
489
+ deps_holder=$(cat "$deps_lock_dir/pid" 2>/dev/null || true)
490
+ if [ -n "$deps_holder" ] && kill -0 "$deps_holder" 2>/dev/null; then
491
+ log "dependency install already running (pid $deps_holder) — leaving it be"
492
+ deps_spawn=0
493
+ else
494
+ rm -rf "$deps_lock_dir"
495
+ if mkdir "$deps_lock_dir" 2>/dev/null; then
496
+ printf '%s\n' "$$" > "$deps_lock_dir/pid"
497
+ else
498
+ log "warning: could not acquire the dependency install lock — skipping installs"
499
+ deps_spawn=0
500
+ fi
501
+ fi
502
+ fi
503
+ if [ "$deps_spawn" -eq 1 ]; then
504
+ printf 'running\n' > "$deps_status_file"
505
+ log "installing workspace dependencies in the background (status: $deps_status_file, log: $deps_log_file)"
506
+ (
507
+ trap 'rm -rf "$deps_lock_dir"' EXIT
508
+ deps_rc=0
509
+ run_workspace_installs || deps_rc=$?
510
+ if [ "$deps_rc" -eq 0 ]; then
511
+ printf 'done\n' > "$deps_status_file"
512
+ log "workspace dependency install complete"
513
+ elif [ "$deps_rc" -ne 78 ]; then
514
+ printf 'failed\n' > "$deps_status_file"
515
+ log "workspace dependency install failed (exit $deps_rc)"
516
+ fi
517
+ ) >>"$deps_log_file" 2>&1 </dev/null &
518
+ printf '%s\n' "$!" > "$deps_lock_dir/pid"
519
+ disown
520
+ fi
449
521
  elif [ ! -d "$WORKSPACE" ]; then
450
522
  log "workspace $WORKSPACE missing — scratchpad/no-project task, skipping installs"
451
523
  else
@@ -50,6 +50,7 @@ import {
50
50
  } from "./task-container-cli";
51
51
  import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
52
52
  import {
53
+ MACHINE_RUNTIME_AUTHORITY_ENV,
53
54
  RUNTIME_AUTHORITY_ENV,
54
55
  runtimeAuthorityDockerExecArgs,
55
56
  } from "./runtime-authority";
@@ -994,8 +995,19 @@ export async function setupBrowserTesting(
994
995
  containerName: string,
995
996
  hasCodex: boolean,
996
997
  codexHomes: readonly string[] = hasCodex ? [DEFAULT_CODEX_HOME] : [],
997
- environment?: Pick<TaskEnvironmentHandle, "exec" | "launchDetachedSession">,
998
+ environment?: Pick<TaskEnvironmentHandle, "exec" | "launchDetachedSession"> &
999
+ Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
998
1000
  ): Promise<BrowserSetup> {
1001
+ // Machine sessions run under MACHINE_RUNTIME_AUTHORITY_ENV, so every setup
1002
+ // exec here must too — the prewarm in particular. Warming npx under the
1003
+ // CONTAINER authority (private NPM_CONFIG_CACHE) leaves the session-default
1004
+ // npm cache cold, and Codex's 10s MCP startup deadline then kills the
1005
+ // browser (and every npx-launched server) for the session's whole lifetime
1006
+ // while Claude's 30s survives — live 2026-08-28, first codex machine task.
1007
+ const authorityEnv =
1008
+ environment?.descriptor?.locator.provider === "machine"
1009
+ ? MACHINE_RUNTIME_AUTHORITY_ENV
1010
+ : RUNTIME_AUTHORITY_ENV;
999
1011
  let ready = false;
1000
1012
  let configured = false;
1001
1013
  let changed = false;
@@ -1029,7 +1041,7 @@ export async function setupBrowserTesting(
1029
1041
  if (environment) {
1030
1042
  await environment.launchDetachedSession({
1031
1043
  argv: ["sh", "-c", step],
1032
- env: RUNTIME_AUTHORITY_ENV,
1044
+ env: authorityEnv,
1033
1045
  maxOutputBytes: 64 * 1024,
1034
1046
  launchTimeoutMs: 10_000,
1035
1047
  });
@@ -1067,14 +1079,14 @@ export async function setupBrowserTesting(
1067
1079
  await environment.exec({
1068
1080
  argv: ["/usr/bin/chown", "node:node", "/opt/pw-browsers"],
1069
1081
  user: "root",
1070
- env: RUNTIME_AUTHORITY_ENV,
1082
+ env: authorityEnv,
1071
1083
  timeoutMs: 5_000,
1072
1084
  maxOutputBytes: 64 * 1024,
1073
1085
  });
1074
1086
  return environment.exec({
1075
1087
  argv: ["sh", "-lc", PREWARM_CMD],
1076
1088
  cwd: "/workspace",
1077
- env: RUNTIME_AUTHORITY_ENV,
1089
+ env: authorityEnv,
1078
1090
  timeoutMs: INSTALL_TIMEOUT_MS,
1079
1091
  maxOutputBytes: 256 * 1024,
1080
1092
  });
@@ -1140,7 +1152,7 @@ export async function setupBrowserTesting(
1140
1152
  (10 + Math.max(45, 15 + selectedCodexHomes.length * 12) + 5 + 10) *
1141
1153
  1_000;
1142
1154
  const writeEnv = {
1143
- ...RUNTIME_AUTHORITY_ENV,
1155
+ ...authorityEnv,
1144
1156
  UAI_BROWSER_DEF: JSON.stringify(SERVER_DEF),
1145
1157
  UAI_MCP_PATH: MCP_CONFIG_PATH,
1146
1158
  UAI_CLAUDE_SETTINGS_PATH: CLAUDE_SETTINGS_PATH,
@@ -1276,7 +1288,7 @@ export async function setupBrowserTesting(
1276
1288
  ].join("; ");
1277
1289
 
1278
1290
  const rootWorkerEnv = {
1279
- ...RUNTIME_AUTHORITY_ENV,
1291
+ ...authorityEnv,
1280
1292
  NPM_CONFIG_CACHE: "/tmp/uai-root-npm-cache",
1281
1293
  npm_config_cache: "/tmp/uai-root-npm-cache",
1282
1294
  NPM_CONFIG_LOGS_DIR: "/tmp/uai-root-npm-cache/_logs",
@@ -42,8 +42,14 @@ export interface AwsMachineConfig {
42
42
  /** Override the size ladder; the default picks the smallest Graviton
43
43
  * type satisfying both cpu and memory. */
44
44
  instanceType?: (spec: MachineSpec) => string;
45
+ /** Root EBS volume size. The AMI snapshot default (8 GiB Debian) starves
46
+ * real work — a monorepo install plus a browser left ~36 MB free live
47
+ * (2026-08-28). cloud-init's growpart expands the filesystem on boot. */
48
+ diskGiB?: number;
45
49
  }
46
50
 
51
+ const DEFAULT_DISK_GIB = 50;
52
+
47
53
  type Runner = (args: string[]) => Promise<DockerResult>;
48
54
 
49
55
  const DEFAULT_TIMEOUT_MS = 60_000;
@@ -224,6 +230,41 @@ export function createAwsMachineProvider(
224
230
  return machineId.slice("uai-machine-".length);
225
231
  };
226
232
 
233
+ /** Root device name per AMI, resolved once per process. A failure throws:
234
+ * guessing the device would silently attach a blank second volume while
235
+ * the root stays at its 8 GiB snapshot size. */
236
+ const rootDeviceByImage = new Map<string, string>();
237
+ async function rootDeviceName(image: string): Promise<string> {
238
+ const cached = rootDeviceByImage.get(image);
239
+ if (cached) return cached;
240
+ const res = await runner([
241
+ "ec2",
242
+ "describe-images",
243
+ "--image-ids",
244
+ image,
245
+ "--query",
246
+ "Images[0].RootDeviceName",
247
+ ]);
248
+ if (res.status !== 0) {
249
+ throw new Error(
250
+ `could not resolve the root device of ${image}: ${
251
+ res.stderr.trim() || `exit ${res.status ?? "killed"}`
252
+ }`,
253
+ );
254
+ }
255
+ let device: unknown;
256
+ try {
257
+ device = JSON.parse(res.stdout);
258
+ } catch {
259
+ device = null;
260
+ }
261
+ if (typeof device !== "string" || !device.startsWith("/dev/")) {
262
+ throw new Error(`image ${image} reports no root device name`);
263
+ }
264
+ rootDeviceByImage.set(image, device);
265
+ return device;
266
+ }
267
+
227
268
  /** Resolve the logical machine id to its live instances via the tag. */
228
269
  async function resolveLive(
229
270
  machineId: string,
@@ -292,6 +333,12 @@ export function createAwsMachineProvider(
292
333
  // identical retry still cannot mint a second instance; a CHANGED
293
334
  // launch legitimately gets a fresh token, and the resume-aware
294
335
  // provision's describe-first step remains the guard against doubling
336
+ // The AMI snapshot's root volume (8 GiB on the Debian payload) starves
337
+ // real work; request a task-sized gp3 root instead. The mapping must
338
+ // name the AMI's actual root device — a wrong name would ADD a blank
339
+ // volume while the root stays small — so resolve it per image.
340
+ const diskGiB = config.diskGiB ?? DEFAULT_DISK_GIB;
341
+ const rootDevice = await rootDeviceName(spec.image);
295
342
  // a live machine.
296
343
  const launchFingerprint = createHash("sha256")
297
344
  .update(
@@ -302,6 +349,7 @@ export function createAwsMachineProvider(
302
349
  config.subnetId ?? null,
303
350
  config.securityGroupId ?? null,
304
351
  config.iamInstanceProfileArn ?? null,
352
+ diskGiB,
305
353
  ]),
306
354
  )
307
355
  .digest("hex")
@@ -321,6 +369,17 @@ export function createAwsMachineProvider(
321
369
  `${machineId}-${launchFingerprint}`.slice(0, 64),
322
370
  "--tag-specifications",
323
371
  `ResourceType=instance,Tags=[{Key=${AWS_MACHINE_TAG},Value=${spec.taskId}},{Key=Name,Value=${machineId}}]`,
372
+ "--block-device-mappings",
373
+ JSON.stringify([
374
+ {
375
+ DeviceName: rootDevice,
376
+ Ebs: {
377
+ VolumeSize: diskGiB,
378
+ VolumeType: "gp3",
379
+ DeleteOnTermination: true,
380
+ },
381
+ },
382
+ ]),
324
383
  ...(spec.authorizedPublicKey
325
384
  ? ["--user-data", awsUserData(spec.authorizedPublicKey)]
326
385
  : []),
@@ -138,11 +138,13 @@ function machineBackend() {
138
138
  if (!region) {
139
139
  throw new Error("UAI_MACHINE_PROVIDER=aws requires UAI_AWS_REGION");
140
140
  }
141
+ const diskGiB = Number(process.env.UAI_MACHINE_DISK_GIB ?? "");
141
142
  return createAwsMachineProvider({
142
143
  region,
143
144
  subnetId: process.env.UAI_AWS_SUBNET_ID,
144
145
  securityGroupId: process.env.UAI_AWS_SECURITY_GROUP_ID,
145
146
  iamInstanceProfileArn: process.env.UAI_AWS_INSTANCE_PROFILE_ARN,
147
+ ...(Number.isInteger(diskGiB) && diskGiB > 0 ? { diskGiB } : {}),
146
148
  });
147
149
  }
148
150
  return createLocalMachineProvider();
@@ -0,0 +1,103 @@
1
+ /**
2
+ * ADR-118 follow-up: the macOS vnode-table pressure sensor.
3
+ *
4
+ * Apple's Virtualization.framework virtiofs pins one host vnode per distinct
5
+ * guest-touched file for the life of the task VM (~12k per task after
6
+ * ADR-118 moved the pnpm store off virtiofs; ~130k+ before it). The table
7
+ * ceiling (`kern.maxvnodes`) is a boot-time default the host cannot raise
8
+ * without root, and AT the ceiling unrelated host software starts failing
9
+ * with ENFILE — the suspected 2026-08-24 kernel-panic precursor.
10
+ *
11
+ * Three surfaces ride this module:
12
+ * - `readVnodePressure()` — the sensor (cached; null off-macOS).
13
+ * - `vnodeAdmissionProblem()` — task-up guard: refuse to start ANOTHER
14
+ * apple-container VM above the threshold instead of letting the kernel
15
+ * hit the wall. Existing tasks keep running.
16
+ * - `logVnodePressure()` — periodic observability, warn-level near the
17
+ * ceiling so host logs explain refusals before they happen.
18
+ *
19
+ * Raising the ceiling needs root once: `sudo uai-host tune` (see cli.ts)
20
+ * installs a LaunchDaemon that reasserts the sysctls at every boot —
21
+ * sysctl writes alone are ephemeral.
22
+ */
23
+
24
+ import { execFile } from "node:child_process";
25
+ import { promisify } from "node:util";
26
+
27
+ const exec = promisify(execFile);
28
+
29
+ /** Refuse NEW apple task VMs above this fraction of the vnode ceiling. */
30
+ const ADMISSION_THRESHOLD = 0.9;
31
+ /** Warn-log above this fraction. */
32
+ const WARN_THRESHOLD = 0.8;
33
+ const CACHE_MS = 15_000;
34
+
35
+ export interface VnodePressure {
36
+ current: number;
37
+ max: number;
38
+ ratio: number;
39
+ }
40
+
41
+ let cached: { at: number; value: VnodePressure | null } | null = null;
42
+
43
+ export async function readVnodePressure(): Promise<VnodePressure | null> {
44
+ if (process.platform !== "darwin") return null;
45
+ if (cached && Date.now() - cached.at < CACHE_MS) return cached.value;
46
+ let value: VnodePressure | null = null;
47
+ try {
48
+ const { stdout } = await exec(
49
+ "/usr/sbin/sysctl",
50
+ ["-n", "kern.num_vnodes", "kern.maxvnodes"],
51
+ { timeout: 5_000 },
52
+ );
53
+ const parts = stdout.trim().split(/\s+/).map(Number);
54
+ const current = parts[0] ?? Number.NaN;
55
+ const max = parts[1] ?? Number.NaN;
56
+ if (Number.isFinite(current) && Number.isFinite(max) && max > 0) {
57
+ value = { current, max, ratio: current / max };
58
+ }
59
+ } catch {
60
+ // Sensor failure is never a task failure; admission simply has no data.
61
+ }
62
+ cached = { at: Date.now(), value };
63
+ return value;
64
+ }
65
+
66
+ /** Non-null = refuse to start another apple-container task VM right now. */
67
+ export async function vnodeAdmissionProblem(): Promise<string | null> {
68
+ const pressure = await readVnodePressure();
69
+ if (!pressure || pressure.ratio < ADMISSION_THRESHOLD) return null;
70
+ return (
71
+ `the macOS vnode table is at ${Math.round(pressure.ratio * 100)}% ` +
72
+ `(${pressure.current}/${pressure.max}); starting another task VM risks ` +
73
+ "system-wide file-table exhaustion. Close a running task, or raise the " +
74
+ "ceiling once with: sudo uai-host tune"
75
+ );
76
+ }
77
+
78
+ let lastLoggedBand: "ok" | "warn" | "critical" = "ok";
79
+
80
+ /** Log on band TRANSITIONS only, so a host sitting near the ceiling does not
81
+ * spam its own log every interval. */
82
+ export async function logVnodePressure(): Promise<void> {
83
+ const pressure = await readVnodePressure();
84
+ if (!pressure) return;
85
+ const band =
86
+ pressure.ratio >= ADMISSION_THRESHOLD
87
+ ? "critical"
88
+ : pressure.ratio >= WARN_THRESHOLD
89
+ ? "warn"
90
+ : "ok";
91
+ if (band === lastLoggedBand) return;
92
+ lastLoggedBand = band;
93
+ const detail = `${pressure.current}/${pressure.max} (${Math.round(pressure.ratio * 100)}%)`;
94
+ if (band === "critical") {
95
+ console.warn(
96
+ `[vnode] table at ${detail} — new apple task VMs will be refused; run: sudo uai-host tune`,
97
+ );
98
+ } else if (band === "warn") {
99
+ console.warn(`[vnode] table filling: ${detail}`);
100
+ } else {
101
+ console.log(`[vnode] table pressure back to normal: ${detail}`);
102
+ }
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.78",
3
+ "version": "0.9.80",
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/cli.ts CHANGED
@@ -22,7 +22,7 @@ import "./load-env";
22
22
 
23
23
  import { spawn, spawnSync } from "node:child_process";
24
24
  import { createHash, randomBytes } from "node:crypto";
25
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
25
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
26
  import { homedir, hostname } from "node:os";
27
27
  import { dirname, join, resolve } from "node:path";
28
28
 
@@ -137,6 +137,8 @@ async function main(): Promise<void> {
137
137
  return cmdLogs(follow);
138
138
  case "runtime":
139
139
  return cmdRuntime(rest);
140
+ case "tune":
141
+ return cmdTune();
140
142
  case "update":
141
143
  return cmdUpdate(rest);
142
144
  case "rollback":
@@ -498,6 +500,139 @@ async function boundedResponseJson(
498
500
 
499
501
  // --- runtime ---------------------------------------------------------------
500
502
 
503
+ // --- tune (ADR-118 follow-up: vnode/file-table ceilings) ---------------------
504
+
505
+ const TUNE_DAEMON_LABEL = "com.runuai.tune";
506
+ const TUNE_DAEMON_PLIST = `/Library/LaunchDaemons/${TUNE_DAEMON_LABEL}.plist`;
507
+ /** Floors, not targets — an operator-raised ceiling is never lowered. Each
508
+ * apple-container task VM pins host vnodes for its life (~12k after ADR-118,
509
+ * far more during dependency churn), and the boot defaults sit low enough
510
+ * that two heavy tasks have filled the table live (2026-08-25). ~2M vnodes
511
+ * is roughly half a GB of kernel memory IF fully populated. */
512
+ const TUNE_MAXVNODES_FLOOR = 2_097_152;
513
+ const TUNE_MAXFILES_FLOOR = 2_097_152;
514
+ const TUNE_MAXFILESPERPROC_FLOOR = 1_048_576;
515
+
516
+ function readSysctlNumber(name: string): number | null {
517
+ const res = spawnSync("/usr/sbin/sysctl", ["-n", name], {
518
+ encoding: "utf8",
519
+ });
520
+ const value = Number((res.stdout ?? "").trim());
521
+ return res.status === 0 && Number.isFinite(value) ? value : null;
522
+ }
523
+
524
+ function tunePlistContent(settings: Array<[string, number]>): string {
525
+ const args = ["/usr/sbin/sysctl", ...settings.map(([k, v]) => `${k}=${v}`)]
526
+ .map((a) => ` <string>${a}</string>`)
527
+ .join("\n");
528
+ return `<?xml version="1.0" encoding="UTF-8"?>
529
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
530
+ <plist version="1.0">
531
+ <dict>
532
+ <key>Label</key>
533
+ <string>${TUNE_DAEMON_LABEL}</string>
534
+ <key>ProgramArguments</key>
535
+ <array>
536
+ ${args}
537
+ </array>
538
+ <key>RunAtLoad</key>
539
+ <true/>
540
+ </dict>
541
+ </plist>
542
+ `;
543
+ }
544
+
545
+ async function cmdTune(): Promise<void> {
546
+ if (process.platform !== "darwin") {
547
+ console.error(red("tune is macOS-only (apple-container task VMs)"));
548
+ process.exitCode = 1;
549
+ return;
550
+ }
551
+ const current = {
552
+ maxvnodes: readSysctlNumber("kern.maxvnodes"),
553
+ maxfiles: readSysctlNumber("kern.maxfiles"),
554
+ maxfilesperproc: readSysctlNumber("kern.maxfilesperproc"),
555
+ };
556
+ if (
557
+ current.maxvnodes === null ||
558
+ current.maxfiles === null ||
559
+ current.maxfilesperproc === null
560
+ ) {
561
+ console.error(red("could not read the current kernel limits via sysctl"));
562
+ process.exitCode = 1;
563
+ return;
564
+ }
565
+ // Order matters at apply time: maxfilesperproc must stay <= maxfiles.
566
+ const settings: Array<[string, number]> = [
567
+ ["kern.maxfiles", Math.max(current.maxfiles, TUNE_MAXFILES_FLOOR)],
568
+ [
569
+ "kern.maxfilesperproc",
570
+ Math.max(current.maxfilesperproc, TUNE_MAXFILESPERPROC_FLOOR),
571
+ ],
572
+ ["kern.maxvnodes", Math.max(current.maxvnodes, TUNE_MAXVNODES_FLOOR)],
573
+ ];
574
+ const desiredPlist = tunePlistContent(settings);
575
+ const plistCurrent = existsSync(TUNE_DAEMON_PLIST)
576
+ ? readFileSync(TUNE_DAEMON_PLIST, "utf8")
577
+ : null;
578
+ const changes = settings.filter(
579
+ ([key, value]) => value !== current[key.slice("kern.".length) as keyof typeof current],
580
+ );
581
+ if (changes.length === 0 && plistCurrent === desiredPlist) {
582
+ const summary = settings.map(([k, v]) => `${k}=${v}`).join(" ");
583
+ console.log(`already tuned (${summary}); boot persistence in place`);
584
+ return;
585
+ }
586
+
587
+ if (typeof process.getuid !== "function" || process.getuid() !== 0) {
588
+ console.log(`${bold("uai-host tune")} will (needs sudo):`);
589
+ for (const [key, value] of changes) {
590
+ console.log(
591
+ ` raise ${key}: ${current[key.slice("kern.".length) as keyof typeof current]} -> ${value}`,
592
+ );
593
+ }
594
+ console.log(
595
+ ` install ${TUNE_DAEMON_PLIST} so the limits survive reboots`,
596
+ );
597
+ console.log(`\nrun: ${cyan("sudo uai-host tune")}`);
598
+ process.exitCode = 1;
599
+ return;
600
+ }
601
+
602
+ for (const [key, value] of settings) {
603
+ const res = spawnSync("/usr/sbin/sysctl", [`${key}=${value}`], {
604
+ encoding: "utf8",
605
+ });
606
+ if (res.status !== 0) {
607
+ console.error(
608
+ red(`sysctl ${key}=${value} failed: ${(res.stderr ?? "").trim()}`),
609
+ );
610
+ process.exitCode = 1;
611
+ return;
612
+ }
613
+ }
614
+ writeFileSync(TUNE_DAEMON_PLIST, desiredPlist, { mode: 0o644 });
615
+ spawnSync("/usr/sbin/chown", ["root:wheel", TUNE_DAEMON_PLIST]);
616
+ // Re-bootstrap so launchd owns the daemon under its current definition.
617
+ spawnSync("/bin/launchctl", ["bootout", `system/${TUNE_DAEMON_LABEL}`]);
618
+ const bootstrap = spawnSync(
619
+ "/bin/launchctl",
620
+ ["bootstrap", "system", TUNE_DAEMON_PLIST],
621
+ { encoding: "utf8" },
622
+ );
623
+ if (bootstrap.status !== 0) {
624
+ console.error(
625
+ red(
626
+ `limits applied, but launchctl bootstrap failed (${(bootstrap.stderr ?? "").trim()}) — they will not survive a reboot`,
627
+ ),
628
+ );
629
+ process.exitCode = 1;
630
+ return;
631
+ }
632
+ const summary = settings.map(([k, v]) => `${k}=${v}`).join(" ");
633
+ console.log(`tuned: ${summary} (persisted via ${TUNE_DAEMON_PLIST})`);
634
+ }
635
+
501
636
  async function cmdRuntime(rest: string[]): Promise<void> {
502
637
  if (rest[0] === "begin-install") {
503
638
  return cmdBeginRuntimeInstall(rest.slice(1));
@@ -1882,6 +2017,8 @@ function printHelp(): void {
1882
2017
  status connection, service info, active tasks (same as the UI)
1883
2018
  logs [--follow] tail the service log
1884
2019
  runtime recheck probe Docker again and refresh cloud capabilities
2020
+ tune (macOS, sudo) raise kernel vnode/file-table ceilings for
2021
+ apple-container task VMs and persist them across boots
1885
2022
  setup --cloud <wss-url> --enroll <token>
1886
2023
  claim this machine via an enrollment token from the web
1887
2024
  app (mints + writes the host credential); on an
package/src/index.ts CHANGED
@@ -70,10 +70,12 @@ import {
70
70
  containerRuntimeTeardownProblem,
71
71
  ensureContainerRuntimeForTask,
72
72
  initializeContainerRuntime,
73
+ pinnedContainerRuntimeProvider,
73
74
  reprobeContainerRuntimeMachineIdentity,
74
75
  suspendContainerRuntime,
75
76
  waitForContainerRuntimeOperational,
76
77
  } from "../lib/container-runtime";
78
+ import { vnodeAdmissionProblem } from "../lib/vnode-pressure";
77
79
  import {
78
80
  provisionTaskEnvironment,
79
81
  reconstructPersistedTaskEnvironment,
@@ -264,6 +266,21 @@ export const hostCommands: HostCommands = {
264
266
  if (readyAdmissionFailure) return readyAdmissionFailure;
265
267
  const readyRuntimeFailure = runtimeUnavailable();
266
268
  if (readyRuntimeFailure) return readyRuntimeFailure;
269
+ // ADR-118 follow-up: each apple-container task is its own VM, and each
270
+ // VM pins host vnodes for its life. Refuse to start ANOTHER one when the
271
+ // table is nearly full — a clear, retryable refusal now beats ENFILE
272
+ // storms (and the suspected panic path) for the whole machine later.
273
+ if (pinnedContainerRuntimeProvider() === "apple-container") {
274
+ const vnodeProblem = await vnodeAdmissionProblem();
275
+ if (vnodeProblem) {
276
+ return {
277
+ ok: false,
278
+ code: HostErrorCode.HostUnavailable,
279
+ message: vnodeProblem,
280
+ retryable: true,
281
+ };
282
+ }
283
+ }
267
284
  const orchestrator = getOrchestrator();
268
285
  return orchestrator.runTaskLifecycle(input.task.id, async () => {
269
286
  // A direct duplicate teardown may still be draining operations that
package/src/main.ts CHANGED
@@ -110,6 +110,7 @@ import {
110
110
  publishActivationPhase,
111
111
  } from "../lib/container-runtime";
112
112
  import { reconstructPersistedTaskEnvironment } from "../lib/task-environment";
113
+ import { logVnodePressure } from "../lib/vnode-pressure";
113
114
  import { resolveAppleTunnelRoute } from "./apple-tunnel-route";
114
115
  import {
115
116
  configureBundledContainerRuntime,
@@ -516,6 +517,11 @@ void connect();
516
517
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
517
518
  // Best-effort: a UI bind failure must not take the host service down.
518
519
  void startLocalUi();
520
+ // ADR-118 follow-up: vnode-pressure observability (macOS/apple-container
521
+ // hosts). Logs only on band transitions; the sensor caches its sysctl read.
522
+ const vnodePressureTimer = setInterval(() => void logVnodePressure(), 60_000);
523
+ vnodePressureTimer.unref?.();
524
+ void logVnodePressure();
519
525
 
520
526
  async function startLocalUi(): Promise<void> {
521
527
  try {