@runuai/host 0.9.155 → 0.9.156

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,264 @@
1
+ /**
2
+ * Bounded recovery for Docker's local-network address pool.
3
+ *
4
+ * Compose allocates one bridge network per local task. A task that fails after
5
+ * creating its network can remain resumable in `error`, and Docker retains the
6
+ * empty network. Enough such networks eventually make every later task fail
7
+ * with "all predefined address pools have been fully subnetted".
8
+ *
9
+ * This sweep is deliberately narrower than `docker network prune`:
10
+ *
11
+ * - the exact task/project identity must be present in trusted host state;
12
+ * - the task must not be starting or running;
13
+ * - Docker must independently report no attached container; and
14
+ * - deletion happens inside that task's lifecycle slot, so Resume/Stop cannot
15
+ * interleave with the proof.
16
+ *
17
+ * Any unreadable or malformed observation means "do nothing", never
18
+ * permission to delete.
19
+ */
20
+
21
+ import type { HostTask } from "../db/schema";
22
+ import { dockerCli, type DockerResult } from "./docker-exec";
23
+ import { getHostTask } from "./runtime-state";
24
+ import { isSafeHostTaskId } from "./task-identity";
25
+ import { isTerminal } from "./task-status";
26
+ import { parseTaskEnvironmentLocator } from "./task-environment/types";
27
+
28
+ const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
29
+ const NETWORK_LIST_FORMAT =
30
+ `{{.ID}}\t{{.Name}}\t{{.Label "${COMPOSE_PROJECT_LABEL}"}}`;
31
+ const MAX_NETWORK_LIST_BYTES = 2 * 1024 * 1024;
32
+ const MAX_NETWORK_INSPECT_BYTES = 1024 * 1024;
33
+ const DOCKER_TIMEOUT_MS = 10_000;
34
+
35
+ interface ComposeNetworkCandidate {
36
+ id: string;
37
+ name: string;
38
+ composeProject: string;
39
+ taskId: string;
40
+ }
41
+
42
+ export interface ComposeNetworkLifecycle {
43
+ runTaskLifecycle<T>(
44
+ taskId: string,
45
+ operation: () => Promise<T>,
46
+ ): Promise<T>;
47
+ }
48
+
49
+ export interface ComposeNetworkRecoveryDeps {
50
+ docker(args: string[], options?: {
51
+ timeoutMs?: number;
52
+ maxOutputBytes?: number;
53
+ }): Promise<DockerResult>;
54
+ task(taskId: string): HostTask | null;
55
+ }
56
+
57
+ export interface ComposeNetworkRecoveryResult {
58
+ inventory: "complete" | "unavailable" | "invalid";
59
+ candidates: number;
60
+ removed: number;
61
+ }
62
+
63
+ const defaultDeps: ComposeNetworkRecoveryDeps = {
64
+ docker: dockerCli,
65
+ task: getHostTask,
66
+ };
67
+
68
+ function parseCandidate(
69
+ line: string,
70
+ ): ComposeNetworkCandidate | "unrelated" | null {
71
+ const fields = line.split("\t");
72
+ if (fields.length !== 3) return null;
73
+ const [id, name, composeProject] = fields as [string, string, string];
74
+ if (!/^[a-f0-9]{12,64}$/.test(id)) return null;
75
+ if (!composeProject.startsWith("task-")) return "unrelated";
76
+ const taskId = composeProject.slice("task-".length);
77
+ if (!isSafeHostTaskId(taskId) || taskId !== taskId.toLowerCase()) return null;
78
+ if (name !== `${composeProject}_default`) return null;
79
+ return { id, name, composeProject, taskId };
80
+ }
81
+
82
+ function parseCandidates(stdout: string): ComposeNetworkCandidate[] | null {
83
+ const candidates: ComposeNetworkCandidate[] = [];
84
+ const ids = new Set<string>();
85
+ for (const line of stdout.split(/\r?\n/)) {
86
+ if (line.length === 0) continue;
87
+ const candidate = parseCandidate(line);
88
+ if (candidate === "unrelated") continue;
89
+ if (candidate === null || ids.has(candidate.id)) return null;
90
+ ids.add(candidate.id);
91
+ candidates.push(candidate);
92
+ }
93
+ return candidates;
94
+ }
95
+
96
+ function taskOwnsReclaimableNetwork(
97
+ task: HostTask | null,
98
+ candidate: ComposeNetworkCandidate,
99
+ ): boolean {
100
+ if (
101
+ task === null ||
102
+ task.taskId !== candidate.taskId ||
103
+ (task.environmentProvider !== null &&
104
+ task.environmentProvider !== "docker-compose") ||
105
+ (task.statusMirror !== "error" &&
106
+ task.statusMirror !== "stopped" &&
107
+ !(typeof task.statusMirror === "string" && isTerminal(task.statusMirror)))
108
+ ) {
109
+ return false;
110
+ }
111
+ if (task.composeProject === candidate.composeProject) return true;
112
+ if (!task.environmentLocator) return false;
113
+ try {
114
+ const locator = parseTaskEnvironmentLocator(task.environmentLocator);
115
+ if (
116
+ locator.schemaVersion !== 1 ||
117
+ locator.provider !== "docker-compose" ||
118
+ !locator.value ||
119
+ typeof locator.value !== "object" ||
120
+ Array.isArray(locator.value)
121
+ ) {
122
+ return false;
123
+ }
124
+ const value = locator.value as Record<string, unknown>;
125
+ return (
126
+ value.schemaVersion === 2 &&
127
+ value.taskId === candidate.taskId &&
128
+ value.composeProject === candidate.composeProject &&
129
+ value.containerName === `${candidate.composeProject}-app-1`
130
+ );
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ function inspectedEmptyNetwork(
137
+ stdout: string,
138
+ candidate: ComposeNetworkCandidate,
139
+ ): { fullId: string } | null {
140
+ let parsed: unknown;
141
+ try {
142
+ parsed = JSON.parse(stdout);
143
+ } catch {
144
+ return null;
145
+ }
146
+ if (!Array.isArray(parsed) || parsed.length !== 1) return null;
147
+ const network = parsed[0];
148
+ if (!network || typeof network !== "object" || Array.isArray(network)) {
149
+ return null;
150
+ }
151
+ const record = network as Record<string, unknown>;
152
+ const labels = record.Labels;
153
+ const containers = record.Containers;
154
+ if (
155
+ typeof record.Id !== "string" ||
156
+ !/^[a-f0-9]{64}$/.test(record.Id) ||
157
+ !record.Id.startsWith(candidate.id) ||
158
+ record.Name !== candidate.name ||
159
+ record.Driver !== "bridge" ||
160
+ record.Scope !== "local" ||
161
+ !labels ||
162
+ typeof labels !== "object" ||
163
+ Array.isArray(labels) ||
164
+ (labels as Record<string, unknown>)[COMPOSE_PROJECT_LABEL] !==
165
+ candidate.composeProject ||
166
+ !containers ||
167
+ typeof containers !== "object" ||
168
+ Array.isArray(containers) ||
169
+ Object.keys(containers).length !== 0
170
+ ) {
171
+ return null;
172
+ }
173
+ return { fullId: record.Id };
174
+ }
175
+
176
+ async function reclaimCandidate(
177
+ candidate: ComposeNetworkCandidate,
178
+ deps: ComposeNetworkRecoveryDeps,
179
+ ): Promise<boolean> {
180
+ if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
181
+ return false;
182
+ }
183
+ const inspected = await deps.docker(
184
+ ["network", "inspect", candidate.id],
185
+ {
186
+ timeoutMs: DOCKER_TIMEOUT_MS,
187
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
188
+ },
189
+ );
190
+ if (inspected.status !== 0 || inspected.outputTruncated) return false;
191
+ const empty = inspectedEmptyNetwork(inspected.stdout, candidate);
192
+ if (empty === null) return false;
193
+
194
+ // `.Containers` is the network's own endpoint inventory. Independently ask
195
+ // Docker's all-container inventory as a second positive emptiness proof.
196
+ const attached = await deps.docker(
197
+ ["ps", "-aq", "--filter", `network=${empty.fullId}`],
198
+ {
199
+ timeoutMs: DOCKER_TIMEOUT_MS,
200
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
201
+ },
202
+ );
203
+ if (
204
+ attached.status !== 0 ||
205
+ attached.outputTruncated ||
206
+ attached.stdout.trim().length !== 0
207
+ ) {
208
+ return false;
209
+ }
210
+
211
+ const removed = await deps.docker(["network", "rm", empty.fullId], {
212
+ timeoutMs: DOCKER_TIMEOUT_MS,
213
+ maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
214
+ });
215
+ return removed.status === 0 && !removed.outputTruncated;
216
+ }
217
+
218
+ /**
219
+ * Remove only positively-owned, empty task networks. This is best-effort host
220
+ * hygiene: an unavailable inventory never blocks a task that may still fit in
221
+ * Docker's pool, and it never authorizes deletion.
222
+ */
223
+ export async function reclaimUnusedUaiComposeNetworks(
224
+ lifecycle: ComposeNetworkLifecycle,
225
+ deps: ComposeNetworkRecoveryDeps = defaultDeps,
226
+ ): Promise<ComposeNetworkRecoveryResult> {
227
+ const listed = await deps.docker(
228
+ [
229
+ "network",
230
+ "ls",
231
+ "--filter",
232
+ `label=${COMPOSE_PROJECT_LABEL}`,
233
+ "--format",
234
+ NETWORK_LIST_FORMAT,
235
+ ],
236
+ {
237
+ timeoutMs: DOCKER_TIMEOUT_MS,
238
+ maxOutputBytes: MAX_NETWORK_LIST_BYTES,
239
+ },
240
+ );
241
+ if (listed.status !== 0 || listed.outputTruncated) {
242
+ return { inventory: "unavailable", candidates: 0, removed: 0 };
243
+ }
244
+ const candidates = parseCandidates(listed.stdout);
245
+ if (candidates === null) {
246
+ return { inventory: "invalid", candidates: 0, removed: 0 };
247
+ }
248
+
249
+ let removed = 0;
250
+ for (const candidate of candidates) {
251
+ // Avoid queueing behind a live start merely to rediscover in-slot that it
252
+ // is not reclaimable. This observation grants no authority; the complete
253
+ // ownership/status check is repeated after the lifecycle slot is held.
254
+ if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
255
+ continue;
256
+ }
257
+ await lifecycle.runTaskLifecycle(candidate.taskId, async () => {
258
+ // The trusted row and Docker evidence are deliberately read only after
259
+ // entering the candidate task's lifecycle slot.
260
+ if (await reclaimCandidate(candidate, deps)) removed += 1;
261
+ });
262
+ }
263
+ return { inventory: "complete", candidates: candidates.length, removed };
264
+ }
@@ -714,6 +714,24 @@ export function standardImageVolumeMaintenanceCommandForTest(
714
714
  `ensure_real_dir "$node_root" node-root; ` +
715
715
  `ensure_real_dir "$shims_root" shims-root; ` +
716
716
  `ensure_real_dir "$corepack_root" corepack-root; ` +
717
+ // Legacy images could seed individual shims as root even though the
718
+ // directory itself was later repaired. asdf 0.14 then prints permission
719
+ // errors while reshim quietly leaves the old executable behind; task
720
+ // materialization can appear successful but the next CLI launch is stale
721
+ // or non-executable. The trusted root maintenance transaction repairs only
722
+ // the flat, regular-file shim set before dropping to node for every asdf
723
+ // mutation. Anything else is ambiguous volume content and fails closed.
724
+ `repair_shim_metadata() { ` +
725
+ `unsafe_shim_entry="$(/usr/bin/find "$shims_root" -xdev -mindepth 1 ` +
726
+ `-maxdepth 1 ! -type f -print -quit)"; ` +
727
+ `[ -z "$unsafe_shim_entry" ] || fail unsafe-shim-entry; ` +
728
+ `/usr/bin/find "$shims_root" -xdev -mindepth 1 -maxdepth 1 -type f ` +
729
+ `-exec /usr/bin/chown node:node -- {} + ` +
730
+ `|| fail repair-shim-owner; ` +
731
+ `/usr/bin/find "$shims_root" -xdev -mindepth 1 -maxdepth 1 -type f ` +
732
+ `-exec /usr/bin/chmod 0755 -- {} + ` +
733
+ `|| fail repair-shim-mode; }; ` +
734
+ `repair_shim_metadata; ` +
717
735
  `candidate_is_safe() { ` +
718
736
  `lexical_candidate="$(/usr/bin/realpath -m -s -- "$candidate")" ` +
719
737
  `|| return 1; [ "$lexical_candidate" = "$node_root/$default_node" ] ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.155",
3
+ "version": "0.9.156",
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>",
@@ -2607,8 +2607,27 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
2607
2607
  fi
2608
2608
  else
2609
2609
  step "COMPOSE_UP_FAILED" "docker compose up -d"
2610
- printf '%s\n' "$compose_env_override" | docker compose -p "$compose_project" \
2611
- -f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build >/dev/null
2610
+ compose_up_error=""
2611
+ # Keep the diagnostic in memory only, but bound it: a broken plugin or
2612
+ # daemon must not make task-up retain an arbitrarily large stderr stream.
2613
+ # Docker's allocation error is emitted at the end of Compose's failure.
2614
+ if ! compose_up_error=$(printf '%s\n' "$compose_env_override" \
2615
+ | docker compose -p "$compose_project" \
2616
+ -f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build \
2617
+ 2>&1 >/dev/null | tail -c 65536); then
2618
+ [ -z "$compose_up_error" ] || printf '%s\n' "$compose_up_error" >&2
2619
+ require_docker_after_failed_step "docker compose up -d"
2620
+ if printf '%s\n' "$compose_up_error" \
2621
+ | grep -Fq 'all predefined address pools have been fully subnetted'; then
2622
+ emit_err "COMPOSE_UP_FAILED" \
2623
+ "Docker has no subnet left for this task. Uai already reclaimed every empty task network it could prove safe. Close an unused stopped/error task, or enlarge Docker's non-overlapping default address pools and restart Docker, then press Resume." \
2624
+ "docker compose up -d"
2625
+ fi
2626
+ emit_err "COMPOSE_UP_FAILED" \
2627
+ "Uai could not start this task's Docker Compose stack. Fix the Docker error above, then press Resume." \
2628
+ "docker compose up -d"
2629
+ fi
2630
+ unset compose_up_error
2612
2631
  fi
2613
2632
 
2614
2633
  # Rendered YAML is not runtime evidence: Compose could reuse unexpected state,
package/src/index.ts CHANGED
@@ -126,6 +126,7 @@ import {
126
126
  waitForContainerRuntimeOperational,
127
127
  } from "../lib/container-runtime";
128
128
  import { vnodeAdmissionProblem } from "../lib/vnode-pressure";
129
+ import { reclaimUnusedUaiComposeNetworks } from "../lib/compose-network-recovery";
129
130
  import {
130
131
  machineTasksEnabled,
131
132
  provisionTaskEnvironment,
@@ -616,6 +617,41 @@ async function taskUpOn(
616
617
  );
617
618
  }
618
619
  }
620
+ // Every local Docker task consumes one daemon address-pool subnet. A task
621
+ // that failed after Compose created its network can retain an empty subnet
622
+ // while it waits for Resume. Reclaim only networks whose trusted host row,
623
+ // exact Docker identity and two empty-container inventories all agree. The
624
+ // sweep runs before this task's lifecycle slot so concurrent failed starts
625
+ // can never deadlock while trying to inspect one another.
626
+ if (
627
+ !sidecarMode() &&
628
+ !machineTasksEnabled() &&
629
+ pinnedContainerRuntimeProvider() === "docker"
630
+ ) {
631
+ try {
632
+ const networkRecovery = await reclaimUnusedUaiComposeNetworks(
633
+ orchestrator,
634
+ );
635
+ if (networkRecovery.removed > 0) {
636
+ console.warn(
637
+ `[host-agent] reclaimed ${String(networkRecovery.removed)} empty UAI task ` +
638
+ `network(s) before starting ${input.task.id}`,
639
+ );
640
+ } else if (networkRecovery.inventory === "invalid") {
641
+ console.warn(
642
+ "[host-agent] skipped task-network recovery because Docker returned an invalid network inventory",
643
+ );
644
+ }
645
+ } catch (error) {
646
+ // Best-effort capacity recovery must not block a task when Docker still
647
+ // has room. `docker compose up` remains the authoritative allocation.
648
+ console.warn(
649
+ `[host-agent] task-network recovery was unavailable: ${
650
+ error instanceof Error ? error.message : String(error)
651
+ }`,
652
+ );
653
+ }
654
+ }
619
655
  return orchestrator.runTaskLifecycle(input.task.id, releasingHold(cloud, async () => {
620
656
  // A direct duplicate teardown may still be draining operations that
621
657
  // target this task's stable compose/container names. Never recreate