@runuai/host 0.9.145 → 0.9.147

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/agent-cli.ts CHANGED
@@ -305,7 +305,22 @@ async function main() {
305
305
  case "memory search": {
306
306
  const q = pos.join(" ") || flags.q || "";
307
307
  const author = flags.as ? "&author=" + encodeURIComponent(flags.as) : "";
308
- out((await api("GET", "/api/agent/memory?q=" + encodeURIComponent(q) + author)).memories);
308
+ const limit = flags.limit ? "&limit=" + encodeURIComponent(String(flags.limit)) : "";
309
+ const res = await api(
310
+ "GET",
311
+ "/api/agent/memory?q=" + encodeURIComponent(q) + author + limit,
312
+ );
313
+ out(res.memories);
314
+ // Truncation goes to stderr so it cannot corrupt piped JSON, and it is
315
+ // stated rather than implied: recalling across tasks means reading the
316
+ // WHOLE store, and a short page that says nothing reads as all there is.
317
+ // NOTE: this file is a template literal. No backticks, no \${} in here.
318
+ if (res.truncated) {
319
+ console.error(
320
+ "uai: showing " + res.memories.length + " of " + res.total +
321
+ " — re-run with --limit all for every row.",
322
+ );
323
+ }
309
324
  break;
310
325
  }
311
326
  case "memory save": {
@@ -453,7 +468,7 @@ async function main() {
453
468
  " uai task list",
454
469
  " uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
455
470
  ' uai task close --status finished|canceled [--reason "final report"]',
456
- " uai memory search <query>",
471
+ " uai memory search <query> [--as handle] [--limit N|all]",
457
472
  " uai memory save <text> [--project id] [--tags a,b]",
458
473
  " uai memory delete <id>",
459
474
  " uai todo list",
@@ -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
  /**
@@ -4,8 +4,21 @@
4
4
  * mirror remains the authority at flush time so a later Resume wins over an
5
5
  * older queued stop.
6
6
  */
7
+ /**
8
+ * Why the host stopped the task, when the host knows.
9
+ *
10
+ * `"manual"` is ADR-028's local-UI Stop — a person at the machine pressed it.
11
+ * Everything else the host reports through this frame is automatic (writable
12
+ * -container quarantine during a runtime activation or a shared-volume
13
+ * repair), and carries no reason. The cloud needs the difference because
14
+ * ADR-144 lets a collaborator resume a task that stopped on its own but not
15
+ * one a human deliberately paused, and until now both arrived as the same
16
+ * three fields.
17
+ */
18
+ export type StoppedTaskReason = "manual";
19
+
7
20
  export class StoppedTaskStatusQueue {
8
- private readonly pending = new Set<string>();
21
+ private readonly pending = new Map<string, StoppedTaskReason | undefined>();
9
22
 
10
23
  constructor(
11
24
  private readonly statusForTask: (
@@ -13,8 +26,13 @@ export class StoppedTaskStatusQueue {
13
26
  ) => string | null | undefined,
14
27
  ) {}
15
28
 
16
- enqueue(taskId: string): void {
17
- this.pending.add(taskId);
29
+ enqueue(taskId: string, reason?: StoppedTaskReason): void {
30
+ // A reason, once known, STICKS across a re-enqueue. The same task can be
31
+ // queued twice before a flush — an operator Stop, then a quarantine sweep
32
+ // that finds its container already down — and the automatic pass arriving
33
+ // second does not make the human's decision retroactively automatic.
34
+ // Dropping it would silently hand the task back to every collaborator.
35
+ this.pending.set(taskId, reason ?? this.pending.get(taskId));
18
36
  }
19
37
 
20
38
  /** A failed taskUp may have stopped a legacy runtime before recreation lost
@@ -35,9 +53,9 @@ export class StoppedTaskStatusQueue {
35
53
  return true;
36
54
  }
37
55
 
38
- flush(publish: (taskId: string) => void): void {
39
- for (const taskId of [...this.pending]) {
40
- if (this.statusForTask(taskId) === "stopped") publish(taskId);
56
+ flush(publish: (taskId: string, reason?: StoppedTaskReason) => void): void {
57
+ for (const [taskId, reason] of [...this.pending]) {
58
+ if (this.statusForTask(taskId) === "stopped") publish(taskId, reason);
41
59
  this.pending.delete(taskId);
42
60
  }
43
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.145",
3
+ "version": "0.9.147",
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
@@ -28,7 +28,10 @@ import {
28
28
  getHostTask,
29
29
  hasInFlightHostTaskLifecycle,
30
30
  } from "../lib/runtime-state";
31
- import { StoppedTaskStatusQueue } from "../lib/stopped-task-status-queue";
31
+ import {
32
+ StoppedTaskStatusQueue,
33
+ type StoppedTaskReason,
34
+ } from "../lib/stopped-task-status-queue";
32
35
  import {
33
36
  getOrchestrator,
34
37
  quarantineWritableRuntimeContainers,
@@ -257,7 +260,7 @@ import {
257
260
  } from "./protocol";
258
261
  import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
259
262
  import { LeaseManager } from "../lib/lease";
260
- import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
263
+ import { markSidecarReady, sidecarMode, sidecarPoolMode, sidecarTaskId } from "../lib/sidecar";
261
264
  import {
262
265
  purgeDefaultEngineSlots,
263
266
  SIGN_IN_BOX_TASK_REFUSAL,
@@ -638,7 +641,9 @@ if (signInBoxMode()) {
638
641
  * reconciles and the S3 file mounts run. A denial means the task is no
639
642
  * longer ours to run — exit 3 and let the cloud's sweep reclaim the machine.
640
643
  */
641
- const leaseManager: LeaseManager | null = sidecarMode()
644
+ // A POOL-mode sidecar requests no lease: it has no task to scope one to,
645
+ // and the cloud would refuse it anyway (docs/machine-warm-pool.md).
646
+ const leaseManager: LeaseManager | null = sidecarTaskId()
642
647
  ? new LeaseManager({
643
648
  taskId: sidecarTaskId()!,
644
649
  send: (frame) => {
@@ -864,7 +869,11 @@ async function startLocalUi(): Promise<void> {
864
869
  invalidateMachineTunnelRoute(taskId);
865
870
  try {
866
871
  const result = await getOrchestrator().stopTask(taskId);
867
- if (result.ok) queueStoppedTaskStatus(taskId);
872
+ // A PERSON pressed Stop on this machine's own UI — ADR-028. Labelled
873
+ // as such because the cloud otherwise cannot tell it apart from the
874
+ // quarantine sweeps below, which reach the bridge through this same
875
+ // frame, and ADR-144 turns that difference into who may undo it.
876
+ if (result.ok) queueStoppedTaskStatus(taskId, "manual");
868
877
  return result;
869
878
  } finally {
870
879
  invalidateMachineTunnelRoute(taskId);
@@ -931,7 +940,11 @@ function buildCapabilities(): HostCapabilities {
931
940
  },
932
941
  }
933
942
  : {}),
934
- ...(sidecarTaskId() ? { sidecar: { taskId: sidecarTaskId()! } } : {}),
943
+ ...(sidecarTaskId()
944
+ ? { sidecar: { taskId: sidecarTaskId()! } }
945
+ : sidecarPoolMode()
946
+ ? { sidecar: { pool: true } }
947
+ : {}),
935
948
  maintenanceReady: areAgentClisReady(),
936
949
  mcpGateway: mcpGateway.state(),
937
950
  engineLogins: engineLoginManager.capabilities(),
@@ -1036,15 +1049,18 @@ function sendCapabilities(): void {
1036
1049
 
1037
1050
  /** Keep a rolling-isolation/local-UI stop from remaining cloud-ghost-running
1038
1051
  * merely because the bridge was between connections at the transition. */
1039
- function queueStoppedTaskStatus(taskId: string): void {
1040
- pendingRuntimeIsolationStatuses.enqueue(taskId);
1052
+ function queueStoppedTaskStatus(
1053
+ taskId: string,
1054
+ reason?: StoppedTaskReason,
1055
+ ): void {
1056
+ pendingRuntimeIsolationStatuses.enqueue(taskId, reason);
1041
1057
  flushStoppedTaskStatuses();
1042
1058
  }
1043
1059
 
1044
1060
  function flushStoppedTaskStatuses(socket: WebSocket | null = ws): void {
1045
1061
  if (!socket || socket.readyState !== WebSocket.OPEN) return;
1046
- pendingRuntimeIsolationStatuses.flush((taskId) => {
1047
- send(socket, { kind: "task.status", taskId, status: "stopped" });
1062
+ pendingRuntimeIsolationStatuses.flush((taskId, reason) => {
1063
+ send(socket, { kind: "task.status", taskId, status: "stopped", reason });
1048
1064
  });
1049
1065
  }
1050
1066
 
@@ -2777,6 +2793,8 @@ async function dispatchCommand(
2777
2793
  );
2778
2794
  case "filesOp":
2779
2795
  return hostCommands.filesOp(ctx, expectFilesOpInput(args, 0));
2796
+ case "sidecarAssign":
2797
+ return hostCommands.sidecarAssign(ctx, expectSidecarAssignInput(args, 0));
2780
2798
  case "channelInterrupt":
2781
2799
  return hostCommands.channelInterrupt(
2782
2800
  ctx,
@@ -2910,6 +2928,26 @@ function expectEngineAccountRemoveInput(
2910
2928
  };
2911
2929
  }
2912
2930
 
2931
+ function expectSidecarAssignInput(
2932
+ args: unknown[],
2933
+ index: number,
2934
+ ): { hostId: string; taskId: string } {
2935
+ const value = args[index];
2936
+ const hostId = (value as { hostId?: unknown } | null)?.hostId;
2937
+ const taskId = (value as { taskId?: unknown } | null)?.taskId;
2938
+ if (
2939
+ !value ||
2940
+ typeof value !== "object" ||
2941
+ typeof hostId !== "string" ||
2942
+ hostId.length === 0 ||
2943
+ typeof taskId !== "string" ||
2944
+ !/^[a-z0-9]{1,32}$/i.test(taskId)
2945
+ ) {
2946
+ throw new Error(`invalid command args: expected sidecar assignment at ${index}`);
2947
+ }
2948
+ return { hostId, taskId };
2949
+ }
2950
+
2913
2951
  function expectNumberArg(args: unknown[], index: number): number {
2914
2952
  const value = args[index];
2915
2953
  if (typeof value !== "number" || !Number.isFinite(value)) {
@@ -3505,6 +3543,7 @@ const HOST_COMMAND_NAMES = {
3505
3543
  attachmentWrite: true,
3506
3544
  attachmentRead: true,
3507
3545
  filesOp: true,
3546
+ sidecarAssign: true,
3508
3547
  channelInterrupt: true,
3509
3548
  appendTranscript: true,
3510
3549
  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,
@@ -1802,9 +1812,21 @@ export type HostToCloud =
1802
1812
  // ADR-103: `seq` is the outbox sequence number (monotonic within a bootId).
1803
1813
  // Absent from pre-103 hosts; the cloud dedups replay overlap by it.
1804
1814
  | { kind: "event"; event: HostEvent; seq?: number }
1805
- // Host-pushed task lifecycle (ADR-028 local-UI Stop): the host operator paused
1806
- // a task's containers, so the cloud mirrors the status (currently "stopped").
1807
- | { kind: "task.status"; taskId: string; status: string }
1815
+ // Host-pushed task lifecycle: the cloud mirrors the status (currently
1816
+ // "stopped"). TWO different events arrive through this one frame — ADR-028's
1817
+ // local-UI Stop, where the host operator paused the task's containers, and
1818
+ // the automatic writable-container quarantine a runtime activation or
1819
+ // shared-volume repair performs. `reason: "manual"` is the first; its
1820
+ // absence is the second.
1821
+ //
1822
+ // The cloud cannot infer it. ADR-144 lets a collaborator resume a task that
1823
+ // stopped on its own and not one a human deliberately paused, and with both
1824
+ // stops indistinguishable on the wire the deliberate one was reversible by
1825
+ // anyone on the task. OPTIONAL so an older host stays compatible: it sends
1826
+ // no reason, the cloud records none, and that host's operator Stop keeps the
1827
+ // pre-ADR-144 behaviour rather than mislabelling its quarantines as manual
1828
+ // and blocking the recovery path they exist to enable.
1829
+ | { kind: "task.status"; taskId: string; status: string; reason?: string }
1808
1830
  | { kind: "ping"; ts: number }
1809
1831
  // ADR-133: a sidecar asks for (or refreshes) its task-scoped AWS lease.
1810
1832
  | { kind: "lease.request"; opId: string; taskId: string }