@runuai/host 0.9.97 → 0.9.98

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.97",
3
+ "version": "0.9.98",
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>",
@@ -0,0 +1,136 @@
1
+ /**
2
+ * ADR-121 machine tunnel routing, extracted from main.ts so its one expensive
3
+ * step is bounded and testable. Resolving a machine's address is a cloud API
4
+ * call — `aws ec2 describe-instances` through a spawned CLI, 0.5–1.5 s on the
5
+ * default t4g.medium host — and the cloud opens one tunnel per HTTP request,
6
+ * so a browser pane (noVNC ships dozens of unbundled modules) or the editor
7
+ * (code-server, more) paid that call dozens of times per load. Live
8
+ * 2026-09-06: "connecting, but very slow" on every AWS machine task, while
9
+ * Docker and apple-container hosts, whose routes are a local inspect, felt
10
+ * instant.
11
+ *
12
+ * The memo is per task, short (MACHINE_ROUTE_TTL_MS), and holds only a proven
13
+ * `running` route: a stopped or unknown machine is asked again on every open
14
+ * so a wake is seen promptly; a dial failure drops the entry so a machine that
15
+ * moved (a fresh public IP after stop/start) is re-described on the next open;
16
+ * a stop/down command drops it too — and bumps a per-task generation, so a
17
+ * description that was already in flight when the stop landed cannot
18
+ * re-memoise the pre-stop address. Concurrent cold opens share one resolution
19
+ * (the pane's first burst is half a dozen parallel requests), and a machine
20
+ * seen not running is not asked again for a few seconds (noVNC retries every
21
+ * couple of seconds against a stopped machine; without that, each retry was
22
+ * a CLI spawn). A refused dial invalidates at once; an address that
23
+ * black-holes (an instance stopped while memoised) hangs on the OS connect
24
+ * timeout instead — machine dials carry no connect timeout of their own,
25
+ * which predates this module — so the TTL is the real bound there.
26
+ */
27
+
28
+ export type MachineTunnelRoute =
29
+ | { kind: "running"; ipv4Address: string }
30
+ | { kind: "unavailable" }
31
+ | { kind: "unknown"; detail: string };
32
+
33
+ export interface MachineTunnelRouteDeps<Task> {
34
+ reconstruct(task: Task): Promise<{
35
+ inspectRoute?: () => Promise<MachineTunnelRoute>;
36
+ } | null>;
37
+ now(): number;
38
+ ttlMs?: number;
39
+ }
40
+
41
+ export interface MachineTunnelRouteRequest {
42
+ taskId: string;
43
+ target: string;
44
+ containerPort?: number;
45
+ }
46
+
47
+ /** Long enough to cover a pane or editor load (dozens of opens inside a few
48
+ * seconds), short enough that a machine's address is never trusted past the
49
+ * next stop/start cycle by much; a failed dial drops it sooner. */
50
+ export const MACHINE_ROUTE_TTL_MS = 45_000;
51
+ /** How long a not-running answer is trusted before the machine is asked
52
+ * again — long enough to absorb a reconnecting pane, short enough that a wake
53
+ * is seen within seconds. */
54
+ export const MACHINE_ROUTE_NEGATIVE_TTL_MS = 5_000;
55
+
56
+ const memo = new Map<string, { ipv4Address: string; expiresAt: number }>();
57
+ const negative = new Map<string, number>();
58
+ const inFlight = new Map<string, Promise<string | null>>();
59
+ /** Bumped by every invalidation; a resolution only writes the memo if the
60
+ * generation it started under is still current. */
61
+ const generation = new Map<string, number>();
62
+
63
+ export async function resolveMachineTunnelRoute<
64
+ Task extends { codeServerPort?: number | null },
65
+ >(
66
+ request: MachineTunnelRouteRequest,
67
+ task: Task,
68
+ deps: MachineTunnelRouteDeps<Task>,
69
+ ): Promise<{ host: string; port: number } | null> {
70
+ // The cheap refusal first: an unroutable request never costs an API call.
71
+ const port =
72
+ request.target === "editor"
73
+ ? (task.codeServerPort ?? null)
74
+ : (request.containerPort ?? null);
75
+ if (!port) return null;
76
+ const host = await machineAddress(request.taskId, task, deps);
77
+ return host === null ? null : { host, port };
78
+ }
79
+
80
+ async function machineAddress<Task>(
81
+ taskId: string,
82
+ task: Task,
83
+ deps: MachineTunnelRouteDeps<Task>,
84
+ ): Promise<string | null> {
85
+ const now = deps.now();
86
+ const hit = memo.get(taskId);
87
+ if (hit && hit.expiresAt > now) return hit.ipv4Address;
88
+ memo.delete(taskId);
89
+ const notRunningUntil = negative.get(taskId);
90
+ if (notRunningUntil !== undefined && notRunningUntil > now) return null;
91
+ negative.delete(taskId);
92
+ const pending = inFlight.get(taskId);
93
+ if (pending) return pending;
94
+ const startedUnder = generation.get(taskId) ?? 0;
95
+ const resolution = (async (): Promise<string | null> => {
96
+ const environment = await deps.reconstruct(task);
97
+ const route = environment?.inspectRoute
98
+ ? await environment.inspectRoute()
99
+ : null;
100
+ const settledAt = deps.now();
101
+ const stillCurrent = (generation.get(taskId) ?? 0) === startedUnder;
102
+ if (!route || route.kind !== "running") {
103
+ if (stillCurrent) {
104
+ negative.set(taskId, settledAt + MACHINE_ROUTE_NEGATIVE_TTL_MS);
105
+ }
106
+ return null;
107
+ }
108
+ if (stillCurrent) {
109
+ memo.set(taskId, {
110
+ ipv4Address: route.ipv4Address,
111
+ expiresAt: settledAt + (deps.ttlMs ?? MACHINE_ROUTE_TTL_MS),
112
+ });
113
+ }
114
+ return route.ipv4Address;
115
+ })().finally(() => {
116
+ inFlight.delete(taskId);
117
+ });
118
+ inFlight.set(taskId, resolution);
119
+ return resolution;
120
+ }
121
+
122
+ /** A dial to the memoised address failed, or the task was stopped, started or
123
+ * torn down: the next open must describe the machine again, and a description
124
+ * already in flight must not write what it learned before this point. */
125
+ export function invalidateMachineTunnelRoute(taskId: string): void {
126
+ memo.delete(taskId);
127
+ negative.delete(taskId);
128
+ generation.set(taskId, (generation.get(taskId) ?? 0) + 1);
129
+ }
130
+
131
+ export function resetMachineTunnelRoutesForTesting(): void {
132
+ memo.clear();
133
+ negative.clear();
134
+ inFlight.clear();
135
+ generation.clear();
136
+ }
package/src/main.ts CHANGED
@@ -112,6 +112,10 @@ import {
112
112
  import { reconstructPersistedTaskEnvironment } from "../lib/task-environment";
113
113
  import { logVnodePressure } from "../lib/vnode-pressure";
114
114
  import { resolveAppleTunnelRoute } from "./apple-tunnel-route";
115
+ import {
116
+ invalidateMachineTunnelRoute,
117
+ resolveMachineTunnelRoute,
118
+ } from "./machine-tunnel-route";
115
119
  import {
116
120
  configureBundledContainerRuntime,
117
121
  selectedRuntimeMatchesPersistedTaskEnvironments,
@@ -554,9 +558,16 @@ async function startLocalUi(): Promise<void> {
554
558
  stopTask: async (taskId) => {
555
559
  const runtimeProblem = containerRuntimeProblem();
556
560
  if (runtimeProblem) return { ok: false, error: runtimeProblem };
557
- const result = await getOrchestrator().stopTask(taskId);
558
- if (result.ok) queueStoppedTaskStatus(taskId);
559
- return result;
561
+ // ADR-121: a stopped machine loses its memoised address (same
562
+ // before-and-after discipline as the cloud's taskStop command).
563
+ invalidateMachineTunnelRoute(taskId);
564
+ try {
565
+ const result = await getOrchestrator().stopTask(taskId);
566
+ if (result.ok) queueStoppedTaskStatus(taskId);
567
+ return result;
568
+ } finally {
569
+ invalidateMachineTunnelRoute(taskId);
570
+ }
560
571
  },
561
572
  });
562
573
  console.log(`[host-agent] local UI on http://127.0.0.1:${handle.port}`);
@@ -1861,6 +1872,8 @@ function handleHttpTunnelOpen(
1861
1872
  // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
1862
1873
  // the next request recreates it (no-op for published / container-IP targets).
1863
1874
  invalidatePreviewSidecar(target.port);
1875
+ // ADR-121: a memoised machine address that no longer answers is stale.
1876
+ invalidateMachineTunnelRoute(frame.taskId);
1864
1877
  if (!acked) {
1865
1878
  send(wsSocket, {
1866
1879
  kind: "tunnel.ack",
@@ -1927,6 +1940,8 @@ function handleRawTunnelOpen(
1927
1940
  // ADR-043: if this was an ad-hoc preview sidecar, drop its cached port so
1928
1941
  // the next request recreates it (no-op for published / container-IP targets).
1929
1942
  invalidatePreviewSidecar(target.port);
1943
+ // ADR-121: a memoised machine address that no longer answers is stale.
1944
+ invalidateMachineTunnelRoute(frame.taskId);
1930
1945
  if (!acked) {
1931
1946
  send(wsSocket, {
1932
1947
  kind: "tunnel.ack",
@@ -2004,17 +2019,23 @@ async function resolveTunnelTarget(
2004
2019
  // the container runtime's health. macOS-local machines need a published
2005
2020
  // fallback (bridge IPs are not host-routable there) — recorded ADR-121
2006
2021
  // follow-up, not silently misrouted: the connect simply fails.
2007
- const environment = await reconstructPersistedTaskEnvironment(machineTask);
2008
- const route = environment?.inspectRoute
2009
- ? await environment.inspectRoute()
2010
- : null;
2011
- if (!route || route.kind !== "running") return null;
2012
- const port =
2013
- frame.target === "editor"
2014
- ? (machineTask.codeServerPort ?? null)
2015
- : (frame.containerPort ?? null);
2016
- if (!port) return null;
2017
- return { host: route.ipv4Address, port };
2022
+ // The address resolution is a cloud API call and the cloud opens one
2023
+ // tunnel per request, so it is memoised per task for a short TTL and
2024
+ // dropped on a failed dial or a stop — see machine-tunnel-route.ts.
2025
+ return resolveMachineTunnelRoute(
2026
+ {
2027
+ taskId: frame.taskId,
2028
+ target: frame.target,
2029
+ ...(frame.containerPort !== undefined
2030
+ ? { containerPort: frame.containerPort }
2031
+ : {}),
2032
+ },
2033
+ machineTask,
2034
+ {
2035
+ reconstruct: (task) => reconstructPersistedTaskEnvironment(task),
2036
+ now: Date.now,
2037
+ },
2038
+ );
2018
2039
  }
2019
2040
  if (containerRuntimeProblem()) return null;
2020
2041
  const task = getHostTask(frame.taskId);
@@ -2275,18 +2296,36 @@ async function dispatchCommand(
2275
2296
  args: unknown[],
2276
2297
  ): Promise<HostCommandResult<unknown>> {
2277
2298
  switch (command) {
2278
- case "taskUp":
2279
- return hostCommands.taskUp(ctx, expectTaskLaunchInput(args, 0));
2299
+ case "taskUp": {
2300
+ const launchInput = expectTaskLaunchInput(args, 0);
2301
+ // A (re)started machine may come back on a new address, and one just
2302
+ // seen as not-running is about to run: forget both, before and after.
2303
+ invalidateMachineTunnelRoute(launchInput.task.id);
2304
+ return hostCommands
2305
+ .taskUp(ctx, launchInput)
2306
+ .finally(() => invalidateMachineTunnelRoute(launchInput.task.id));
2307
+ }
2280
2308
  case "taskDown": {
2281
2309
  const downInput = expectTaskDownInput(args, 0);
2282
- return hostCommands.taskDown(ctx, downInput);
2310
+ // A torn-down machine loses its address: drop the memo before (opens in
2311
+ // flight must not re-memoise) and after (opens that raced the command).
2312
+ invalidateMachineTunnelRoute(downInput.taskId);
2313
+ return hostCommands
2314
+ .taskDown(ctx, downInput)
2315
+ .finally(() => invalidateMachineTunnelRoute(downInput.taskId));
2316
+ }
2317
+ case "taskStop": {
2318
+ const stopTaskId = typeof args[0] === "string" ? args[0] : "";
2319
+ // A stopped machine loses its address; same before-and-after discipline.
2320
+ invalidateMachineTunnelRoute(stopTaskId);
2321
+ return hostCommands
2322
+ .taskStop(
2323
+ ctx,
2324
+ stopTaskId,
2325
+ (args[1] ?? { reason: "manual" }) as { reason: "idle" | "manual" },
2326
+ )
2327
+ .finally(() => invalidateMachineTunnelRoute(stopTaskId));
2283
2328
  }
2284
- case "taskStop":
2285
- return hostCommands.taskStop(
2286
- ctx,
2287
- typeof args[0] === "string" ? args[0] : "",
2288
- (args[1] ?? { reason: "manual" }) as { reason: "idle" | "manual" },
2289
- );
2290
2329
  case "taskStatus":
2291
2330
  return hostCommands.taskStatus(ctx, expectString(args, 0));
2292
2331
  case "channelEnsure":