@runuai/host 0.9.145 → 0.9.146

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The warm-pool claim's host half (docs/machine-warm-pool.md): the cloud's
3
+ * `sidecarAssign` names the task this pool machine now serves. The task id
4
+ * is appended to the sidecar unit's EnvironmentFile and the process exits
5
+ * with ASSIGN_RESTART_EXIT_CODE — systemd (`Restart=on-failure`) brings the
6
+ * unit back up in plain sidecar mode, the tested runner-restart path.
7
+ * Rewriting a root-owned file and restarting is deliberately boring: no
8
+ * runtime re-wiring of boot-time gates, no half-pool half-task states.
9
+ */
10
+ import { appendFileSync, readFileSync } from "node:fs";
11
+
12
+ import { sidecarPoolMode } from "./sidecar";
13
+
14
+ /** Anything but 0 and 3: 0 would not restart the unit, 3 is the lease-denied
15
+ * latch (`RestartPreventExitStatus=3`). */
16
+ export const ASSIGN_RESTART_EXIT_CODE = 64;
17
+ const EXIT_DELAY_MS = 500;
18
+
19
+ export function sidecarEnvFile(): string {
20
+ return process.env.UAI_SIDECAR_ENV_FILE ?? "/etc/uai/sidecar.env";
21
+ }
22
+
23
+ export function applySidecarAssign(
24
+ taskId: string,
25
+ deps: {
26
+ envFile?: string;
27
+ exit?: (code: number) => void;
28
+ append?: (file: string, text: string) => void;
29
+ read?: (file: string) => string;
30
+ } = {},
31
+ ): void {
32
+ if (!sidecarPoolMode()) {
33
+ throw new Error("not a pool machine (already assigned, or not a sidecar)");
34
+ }
35
+ if (!/^[a-z0-9]{1,32}$/i.test(taskId)) throw new Error("invalid task id");
36
+ const file = deps.envFile ?? sidecarEnvFile();
37
+ const read = deps.read ?? ((f: string) => readFileSync(f, "utf8"));
38
+ const current = read(file);
39
+ if (/^UAI_SIDECAR_TASK=/m.test(current)) {
40
+ throw new Error("this machine already carries a task assignment");
41
+ }
42
+ const append = deps.append ?? ((f: string, text: string) => appendFileSync(f, text));
43
+ append(file, `UAI_SIDECAR_TASK=${taskId}\n`);
44
+ // Ack first, then die: the delay lets the command reply flush to the
45
+ // bridge before the socket goes with the process.
46
+ const exit = deps.exit ?? ((code: number) => process.exit(code));
47
+ const timer = setTimeout(() => exit(ASSIGN_RESTART_EXIT_CODE), EXIT_DELAY_MS);
48
+ timer.unref?.();
49
+ }
package/lib/sidecar.ts CHANGED
@@ -13,8 +13,19 @@ export function sidecarTaskId(): string | null {
13
13
  return value && /^[a-z0-9]{1,32}$/i.test(value) ? value : null;
14
14
  }
15
15
 
16
+ /**
17
+ * Warm-pool mode (docs/machine-warm-pool.md): a sidecar with no task yet.
18
+ * Same machine gates as sidecar mode, no lease (nothing to scope one to);
19
+ * the process waits for `sidecarAssign` to write `UAI_SIDECAR_TASK` into
20
+ * the unit env and restart it into plain sidecar mode. Once a task id is
21
+ * present, pool mode is over — even before the restart lands.
22
+ */
23
+ export function sidecarPoolMode(): boolean {
24
+ return process.env.UAI_SIDECAR_POOL === "1" && sidecarTaskId() === null;
25
+ }
26
+
16
27
  export function sidecarMode(): boolean {
17
- return sidecarTaskId() !== null;
28
+ return sidecarTaskId() !== null || sidecarPoolMode();
18
29
  }
19
30
 
20
31
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.145",
3
+ "version": "0.9.146",
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
@@ -1459,6 +1459,17 @@ export const hostCommands: HostCommands = {
1459
1459
  }
1460
1460
  },
1461
1461
 
1462
+ async sidecarAssign(ctx, input) {
1463
+ logCommand(ctx, "sidecarAssign", input.taskId);
1464
+ try {
1465
+ const { applySidecarAssign } = await import("../lib/sidecar-assign");
1466
+ applySidecarAssign(input.taskId);
1467
+ return ok(undefined);
1468
+ } catch (err) {
1469
+ return failFromUnknown(err);
1470
+ }
1471
+ },
1472
+
1462
1473
  async channelInterrupt(ctx, taskId, agentId) {
1463
1474
  logCommand(ctx, "channelInterrupt", taskId, agentId);
1464
1475
  try {
package/src/main.ts CHANGED
@@ -257,7 +257,7 @@ import {
257
257
  } from "./protocol";
258
258
  import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
259
259
  import { LeaseManager } from "../lib/lease";
260
- import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
260
+ import { markSidecarReady, sidecarMode, sidecarPoolMode, sidecarTaskId } from "../lib/sidecar";
261
261
  import {
262
262
  purgeDefaultEngineSlots,
263
263
  SIGN_IN_BOX_TASK_REFUSAL,
@@ -638,7 +638,9 @@ if (signInBoxMode()) {
638
638
  * reconciles and the S3 file mounts run. A denial means the task is no
639
639
  * longer ours to run — exit 3 and let the cloud's sweep reclaim the machine.
640
640
  */
641
- const leaseManager: LeaseManager | null = sidecarMode()
641
+ // A POOL-mode sidecar requests no lease: it has no task to scope one to,
642
+ // and the cloud would refuse it anyway (docs/machine-warm-pool.md).
643
+ const leaseManager: LeaseManager | null = sidecarTaskId()
642
644
  ? new LeaseManager({
643
645
  taskId: sidecarTaskId()!,
644
646
  send: (frame) => {
@@ -931,7 +933,11 @@ function buildCapabilities(): HostCapabilities {
931
933
  },
932
934
  }
933
935
  : {}),
934
- ...(sidecarTaskId() ? { sidecar: { taskId: sidecarTaskId()! } } : {}),
936
+ ...(sidecarTaskId()
937
+ ? { sidecar: { taskId: sidecarTaskId()! } }
938
+ : sidecarPoolMode()
939
+ ? { sidecar: { pool: true } }
940
+ : {}),
935
941
  maintenanceReady: areAgentClisReady(),
936
942
  mcpGateway: mcpGateway.state(),
937
943
  engineLogins: engineLoginManager.capabilities(),
@@ -2777,6 +2783,8 @@ async function dispatchCommand(
2777
2783
  );
2778
2784
  case "filesOp":
2779
2785
  return hostCommands.filesOp(ctx, expectFilesOpInput(args, 0));
2786
+ case "sidecarAssign":
2787
+ return hostCommands.sidecarAssign(ctx, expectSidecarAssignInput(args, 0));
2780
2788
  case "channelInterrupt":
2781
2789
  return hostCommands.channelInterrupt(
2782
2790
  ctx,
@@ -2910,6 +2918,26 @@ function expectEngineAccountRemoveInput(
2910
2918
  };
2911
2919
  }
2912
2920
 
2921
+ function expectSidecarAssignInput(
2922
+ args: unknown[],
2923
+ index: number,
2924
+ ): { hostId: string; taskId: string } {
2925
+ const value = args[index];
2926
+ const hostId = (value as { hostId?: unknown } | null)?.hostId;
2927
+ const taskId = (value as { taskId?: unknown } | null)?.taskId;
2928
+ if (
2929
+ !value ||
2930
+ typeof value !== "object" ||
2931
+ typeof hostId !== "string" ||
2932
+ hostId.length === 0 ||
2933
+ typeof taskId !== "string" ||
2934
+ !/^[a-z0-9]{1,32}$/i.test(taskId)
2935
+ ) {
2936
+ throw new Error(`invalid command args: expected sidecar assignment at ${index}`);
2937
+ }
2938
+ return { hostId, taskId };
2939
+ }
2940
+
2913
2941
  function expectNumberArg(args: unknown[], index: number): number {
2914
2942
  const value = args[index];
2915
2943
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -3505,6 +3533,7 @@ const HOST_COMMAND_NAMES = {
3505
3533
  attachmentWrite: true,
3506
3534
  attachmentRead: true,
3507
3535
  filesOp: true,
3536
+ sidecarAssign: true,
3508
3537
  channelInterrupt: true,
3509
3538
  appendTranscript: true,
3510
3539
  previewEnsure: true,
package/src/protocol.ts CHANGED
@@ -1075,8 +1075,10 @@ export interface HostCapabilities {
1075
1075
  * provider/region on the host page and, eventually, per-task backing
1076
1076
  * choice keys off it. */
1077
1077
  /** ADR-133 sidecar mode: this host IS the task's machine — exactly one
1078
- * task, no container runtime, tunnels on loopback. */
1079
- sidecar?: { taskId: string };
1078
+ * task, no container runtime, tunnels on loopback. A warm-pool machine
1079
+ * (docs/machine-warm-pool.md) advertises `pool` instead of a task until
1080
+ * `sidecarAssign` restarts it into task mode. */
1081
+ sidecar?: { taskId?: string; pool?: boolean };
1080
1082
  machineTasks?: {
1081
1083
  provider: string;
1082
1084
  region?: string;
@@ -1550,6 +1552,14 @@ export interface HostCommands {
1550
1552
  ctx: CommandContext,
1551
1553
  input: FilesOpInput,
1552
1554
  ): Promise<HostCommandResult<FilesOpValue>>;
1555
+ /** Warm pool claim (docs/machine-warm-pool.md): tell a POOL-mode sidecar
1556
+ * which task it now serves. The sidecar writes `UAI_SIDECAR_TASK` into
1557
+ * its unit env, acks, and restarts into plain sidecar mode; a runner not
1558
+ * in pool mode refuses. */
1559
+ sidecarAssign(
1560
+ ctx: CommandContext,
1561
+ input: { hostId: string; taskId: string },
1562
+ ): Promise<HostCommandResult<void>>;
1553
1563
  channelInterrupt(
1554
1564
  ctx: CommandContext,
1555
1565
  taskId: string,