@runuai/host 0.9.168 → 0.9.169

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
@@ -1,3 +1,4 @@
1
+ import { TASK_PROJECT_ADD_WAIT_MS } from "../src/protocol";
1
2
  import { validateShareablePath, ENGINE_ACCOUNT_PATH_RULES } from "../src/workspace-path-policy";
2
3
  /**
3
4
  * In-container `uai` CLI materialisation (ADR-048).
@@ -321,11 +322,26 @@ function opLine(op) {
321
322
  }
322
323
  return "adding " + op.slug + " (" + op.state + "); check again with: uai task project status " + op.operationId;
323
324
  }
325
+ function opProgress(op) {
326
+ const attempts = Number.isInteger(op.attempts) ? "; attempts: " + op.attempts : "";
327
+ const lastError = op.lastError || (op.error && op.error.message);
328
+ return "[" + op.operationId + "] " + opLine(op) + attempts +
329
+ (lastError && op.state !== "failed" ? "; last error: " + lastError : "");
330
+ }
324
331
  async function waitForProjectOp(op) {
325
- const deadline = Date.now() + 25 * 60 * 1000;
332
+ const deadline = Date.now() + ${TASK_PROJECT_ADD_WAIT_MS};
333
+ let progress = opProgress(op);
334
+ let reportedAt = Date.now();
335
+ console.error(progress);
326
336
  while (!op.done && Date.now() < deadline) {
327
- await new Promise((resolve) => setTimeout(resolve, 2000));
337
+ await new Promise((resolve) => setTimeout(resolve, Math.min(2000, Math.max(0, deadline - Date.now()))));
328
338
  op = (await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(op.operationId))).operation;
339
+ const next = opProgress(op);
340
+ if (!op.done && (next !== progress || Date.now() - reportedAt >= 30_000)) {
341
+ console.error(next);
342
+ progress = next;
343
+ reportedAt = Date.now();
344
+ }
329
345
  }
330
346
  return op;
331
347
  }
@@ -333,7 +349,11 @@ async function addProjectToTask(project, wait) {
333
349
  const started = await api("POST", "/api/agent/task/projects", { projectId: project, idempotencyKey: opKey() });
334
350
  const op = wait ? await waitForProjectOp(started.operation) : started.operation;
335
351
  if (op.state === "failed") { console.error("uai: " + opLine(op)); process.exit(1); }
336
- out(opLine(op));
352
+ if (wait && !op.done) {
353
+ console.error("uai: timed out waiting; the add is still in progress. " + opProgress(op));
354
+ process.exit(1);
355
+ }
356
+ out(wait ? opLine(op) : opProgress(op));
337
357
  }
338
358
 
339
359
  // ADR-161 §1.3. UAI_FILES_ROOT and UAI_FILES_STATE_FILE exist so tests can
@@ -1278,7 +1298,9 @@ async function main() {
1278
1298
  break;
1279
1299
  }
1280
1300
  if (sub === "status" && words.length === 2) {
1281
- out(opLine((await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(words[1]))).operation));
1301
+ const op = (await api("GET", "/api/agent/task/projects/operations/" + encodeURIComponent(words[1]))).operation;
1302
+ out(opProgress(op));
1303
+ if (op.state === "failed") process.exit(1);
1282
1304
  break;
1283
1305
  }
1284
1306
  console.error("uai: task project add <projectId|slug> [--no-wait] | task project status <operationId>");
@@ -0,0 +1,42 @@
1
+ import { execFile } from "node:child_process";
2
+ import { statfs } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import type { HostCapabilities } from "../src/protocol";
5
+
6
+ type DiskSample = NonNullable<HostCapabilities["disk"]>;
7
+ const dockerInfo = (args: string[]): Promise<string | null> => new Promise(resolve => {
8
+ execFile("docker", args, { timeout: 5_000, maxBuffer: 4096 }, (error, stdout) => {
9
+ resolve(error ? null : stdout.trim());
10
+ });
11
+ });
12
+ async function dockerRoot(): Promise<string | null> {
13
+ // A remote daemon (including Docker Desktop's VM) has no local filesystem
14
+ // we can measure. Never report a coincidentally matching local path.
15
+ if (process.platform !== "linux") return null;
16
+ const endpoint = process.env.DOCKER_HOST && !process.env.DOCKER_CONTEXT
17
+ ? process.env.DOCKER_HOST
18
+ : await dockerInfo(["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]);
19
+ if (!endpoint?.startsWith("unix://")) return null;
20
+ // Do not guess /var/lib/docker: data-root can be configured elsewhere.
21
+ const root = await dockerInfo(["info", "--format", "{{.DockerRootDir}}"]);
22
+ return root?.startsWith("/") ? root : null;
23
+ }
24
+
25
+ async function measure(path: string | null): Promise<DiskSample["home"]> {
26
+ if (!path) return null;
27
+ try {
28
+ const fs = await statfs(path);
29
+ const totalBytes = fs.blocks * fs.bsize;
30
+ // bavail (not bfree) accounts for root-reserved blocks: the host's
31
+ // unprivileged updater cannot use those even though root can.
32
+ const availableBytes = Math.max(0, fs.bavail * fs.bsize);
33
+ if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0 || !Number.isSafeInteger(availableBytes) || availableBytes < 0 || availableBytes > totalBytes) return null;
34
+ return { totalBytes, availableBytes };
35
+ } catch { return null; }
36
+ }
37
+
38
+ export async function sampleDiskPressure(): Promise<DiskSample> {
39
+ const [home, docker] = await Promise.all([measure(homedir()), dockerRoot().then(measure)]);
40
+ // No local paths are sent to the cloud; an unavailable probe is unknown.
41
+ return { sampledAt: Date.now(), home, docker };
42
+ }
@@ -0,0 +1,117 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { CONTEXT_HASH_LABEL } from "./standard-image-pin";
5
+
6
+ type Result = { code: number | null; stdout: string; stderr: string };
7
+ export type DockerCommand = (command: string, args: string[], timeoutMs?: number) => Promise<Result>;
8
+ const IMAGE_ID = /^sha256:[a-f0-9]{64}$/;
9
+ const SWEEP_LIMIT = 16;
10
+ const HISTORY_LIMIT = 128;
11
+ const HISTORY_BYTES = 16 * 1024;
12
+
13
+ /** Context labels are inherited by task images. Authority to delete comes
14
+ * from observing this exact id under the standard tag, never from a scan. */
15
+ export class StandardImageCleanup {
16
+ private readonly observed = new Set<string>();
17
+ private loading: Promise<void> | undefined;
18
+ private statePath: string | undefined;
19
+
20
+ constructor(private readonly stateFile?: () => Promise<string>) {}
21
+
22
+ private async load(): Promise<void> {
23
+ if (!this.stateFile) return;
24
+ try {
25
+ this.statePath = await this.stateFile();
26
+ const fd = openSync(this.statePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
27
+ try {
28
+ const stat = fstatSync(fd);
29
+ if (!stat.isFile() || stat.size > HISTORY_BYTES) return;
30
+ const bytes = Buffer.alloc(HISTORY_BYTES + 1);
31
+ const length = readSync(fd, bytes, 0, bytes.length, 0);
32
+ if (length > HISTORY_BYTES) return;
33
+ const value = JSON.parse(bytes.subarray(0, length).toString("utf8")) as { version?: unknown; ids?: unknown };
34
+ if (value?.version !== 1 || !Array.isArray(value.ids) || value.ids.length > HISTORY_LIMIT || !value.ids.every(id => typeof id === "string" && IMAGE_ID.test(id))) return;
35
+ for (const id of value.ids) this.observed.add(id as string);
36
+ } finally { closeSync(fd); }
37
+ } catch { /* Missing, corrupt or unreadable history authorizes nothing. */ }
38
+ }
39
+
40
+ private save(): void {
41
+ if (!this.statePath) return;
42
+ const temporary = `${this.statePath}.${process.pid}.${randomUUID()}.tmp`;
43
+ try {
44
+ mkdirSync(dirname(this.statePath), { recursive: true, mode: 0o700 });
45
+ writeFileSync(temporary, JSON.stringify({ version: 1, ids: [...this.observed] }), { flag: "wx", mode: 0o600 });
46
+ renameSync(temporary, this.statePath);
47
+ } catch {
48
+ // ENOSPC must not block a build or safe cleanup using in-memory proof.
49
+ console.warn("[host-agent] could not persist standard-image history");
50
+ } finally {
51
+ try { unlinkSync(temporary); } catch { /* Renamed or never created. */ }
52
+ }
53
+ }
54
+
55
+ async observe(run: DockerCommand, ref = "uai-standard:dev"): Promise<string | null> {
56
+ await (this.loading ??= this.load());
57
+ const result = await run("docker", ["image", "inspect", "--format", "{{json .}}", ref]);
58
+ if (result.code !== 0) return null;
59
+ try {
60
+ const image = JSON.parse(result.stdout) as { Id?: string; Config?: { Labels?: Record<string, string> } };
61
+ if (!IMAGE_ID.test(image.Id ?? "") || !/^[a-f0-9]{32}$/.test(image.Config?.Labels?.[CONTEXT_HASH_LABEL] ?? "")) return null;
62
+ if (ref === "uai-standard:dev") {
63
+ this.observed.delete(image.Id!);
64
+ this.observed.add(image.Id!);
65
+ if (this.observed.size > HISTORY_LIMIT) this.observed.delete(this.observed.values().next().value!);
66
+ this.save();
67
+ }
68
+ return image.Id!;
69
+ } catch { return null; }
70
+ }
71
+
72
+ async sweep(run: DockerCommand, previousId: string | null): Promise<void> {
73
+ const deadline = Date.now() + 30_000;
74
+ const boundedRun: DockerCommand = (command, args) => run(command, args, 5_000);
75
+ const currentId = await this.observe(boundedRun);
76
+ if (!currentId) return;
77
+ const history = await boundedRun("docker", ["image", "history", "--quiet", "--no-trunc", currentId]);
78
+ if (history.code !== 0) return;
79
+ const currentChain = new Set([currentId, ...history.stdout.trim().split(/\s+/)]);
80
+ const candidates = new Set([...(previousId ? [previousId] : []), ...this.observed]);
81
+ for (const id of [...candidates].filter(id => this.observed.has(id) && !currentChain.has(id)).slice(0, SWEEP_LIMIT)) {
82
+ if (Date.now() >= deadline) break;
83
+ // Round-robin retained/in-use ids so a busy prefix cannot starve later ids.
84
+ this.observed.delete(id);
85
+ this.observed.add(id);
86
+ const tags = await boundedRun("docker", ["image", "inspect", "--format", "{{json .RepoTags}}", id]);
87
+ if (tags.code !== 0) continue;
88
+ try { const parsed: unknown = JSON.parse(tags.stdout); if (parsed !== null && (!Array.isArray(parsed) || parsed.length !== 0)) continue; } catch { continue; }
89
+ const containers = await boundedRun("docker", ["ps", "--all", "--quiet", "--filter", `ancestor=${id}`]);
90
+ if (containers.code !== 0 || containers.stdout.trim()) continue;
91
+ // Normal Docker removal reclaims otherwise-unused untagged parents.
92
+ // Shared ancestors of the current image (or any other child/container)
93
+ // remain protected by the daemon. Never force or target a parent id.
94
+ const removed = await boundedRun("docker", ["image", "rm", id]);
95
+ if (removed.code === 0) {
96
+ this.observed.delete(id);
97
+ console.log("[host-agent] removed unused previous standard image", id);
98
+ }
99
+ }
100
+ this.save();
101
+ }
102
+ }
103
+
104
+ /** Per-process exponential retry, keyed by the desired content/configuration.
105
+ * A new host payload/engine configuration retries immediately. A restart also
106
+ * retries once; repeated failures back off 2h, 4h, …, 24h instead of hourly. */
107
+ export class StandardImageBuildBackoff {
108
+ private state: { key: string; failures: number; retryAt: number; error: string } | null = null;
109
+ remaining(key: string, now = Date.now()): { delay: number; retryAt: number; error: string } | null {
110
+ return this.state?.key === key && this.state.retryAt > now ? { delay: this.state.retryAt - now, retryAt: this.state.retryAt, error: this.state.error } : null;
111
+ }
112
+ failed(key: string, error: string, now = Date.now()): void {
113
+ const failures = this.state?.key === key ? this.state.failures + 1 : 1;
114
+ this.state = { key, failures, retryAt: now + Math.min(24, 2 ** Math.min(failures, 5)) * 3_600_000, error };
115
+ }
116
+ clear(): void { this.state = null; }
117
+ }
@@ -2,7 +2,8 @@
2
2
  * Standard-image build pipeline (ADR-022 / docs/runtime.md).
3
3
  *
4
4
  * Every host builds exactly one standard image (`uai-standard:dev`) and
5
- * caches it. `ensureStandardImage()` is called once at host startup:
5
+ * caches it. `ensureStandardImage()` runs at startup and on the owned-host
6
+ * hourly maintenance schedule (local build failures back off):
6
7
  *
7
8
  * 1. `docker volume create uai-asdf-data` — the host-wide asdf data volume
8
9
  * that caches lazily-installed runtime versions across tasks (idempotent;
@@ -52,6 +53,7 @@ import {
52
53
  standardImagePullDigest,
53
54
  } from "./standard-image-pin";
54
55
  import { sidecarMode } from "./sidecar";
56
+ import { StandardImageCleanup, StandardImageBuildBackoff } from "./standard-image-cleanup";
55
57
 
56
58
  /** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
57
59
  export const STANDARD_IMAGE_TAG = "uai-standard:dev";
@@ -1296,8 +1298,15 @@ export type ActivationIsCurrent = () => boolean;
1296
1298
  /** Tests: forget every remembered pass. */
1297
1299
  export function resetStandardImageMemo(): void {
1298
1300
  lastOkByKey.clear();
1301
+ buildBackoff.clear();
1299
1302
  }
1300
1303
 
1304
+ const buildBackoff = new StandardImageBuildBackoff();
1305
+ const imageCleanup = new StandardImageCleanup(async () => {
1306
+ const { env } = await import("./env");
1307
+ return resolve(env.dataDir, "standard-image-seen.json");
1308
+ });
1309
+
1301
1310
  /** The owned-host schedule (docs/agent-notes.md, "image maintenance"). */
1302
1311
  export const MAINTENANCE_INTERVAL_MS = 60 * 60_000;
1303
1312
 
@@ -1418,6 +1427,7 @@ async function ensureStandardImageInner(
1418
1427
  // hash, not this host's per-config hash, which would disagree by design
1419
1428
  // and rebuild forever). Any pull-path failure falls through to the local
1420
1429
  // build below, so offline and registry-down hosts behave exactly as today.
1430
+ const previousImageId = engine.apple ? null : await imageCleanup.observe(run);
1421
1431
  const pin = await readStandardImagePin();
1422
1432
  if (pin !== null) {
1423
1433
  if (inspect.code === 0 && labeledHash === pin.contextHash) {
@@ -1473,13 +1483,18 @@ async function ensureStandardImageInner(
1473
1483
  imageReady = true;
1474
1484
  } else {
1475
1485
  const context = standardImageDir();
1476
- console.log(
1477
- inspect.code === 0
1478
- ? `[host-agent] standard image ${STANDARD_IMAGE_TAG} stale (context changed) — rebuilding from ${context}`
1479
- : `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
1480
- );
1481
- const build = await run(engine.command, [
1486
+ const buildKey = `${contextHash ?? "unavailable"}:${JSON.stringify(engines)}:${pin?.contextHash ?? "local"}`;
1487
+ const deferred = engine.apple || labeledHash === null ? null : buildBackoff.remaining(buildKey);
1488
+ if (!deferred) {
1489
+ console.log(
1490
+ inspect.code === 0
1491
+ ? `[host-agent] standard image ${STANDARD_IMAGE_TAG} stale (context changed) — rebuilding from ${context}`
1492
+ : `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
1493
+ );
1494
+ }
1495
+ const build = deferred ? { code: 1, stdout: "", stderr: `${deferred.error}\nStandard image build deferred until ${new Date(deferred.retryAt).toISOString()}.` } : await run(engine.command, [
1482
1496
  "build",
1497
+ ...(engine.apple ? [] : ["--force-rm"]),
1483
1498
  "-t",
1484
1499
  STANDARD_IMAGE_TAG,
1485
1500
  ...engineArgs,
@@ -1491,11 +1506,14 @@ async function ensureStandardImageInner(
1491
1506
  if (build.code === 0) {
1492
1507
  console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
1493
1508
  imageReady = true;
1509
+ buildBackoff.clear();
1494
1510
  } else {
1495
1511
  buildError =
1496
1512
  build.stderr.trim() ||
1497
1513
  `docker build exited ${build.code ?? "spawn error"}`;
1514
+ if (!deferred && !engine.apple) buildBackoff.failed(buildKey, buildError);
1498
1515
  console.warn(
1516
+ deferred ? `[host-agent] ${buildError}` :
1499
1517
  `[host-agent] standard image build failed (exit ${build.code ?? "spawn"}); ` +
1500
1518
  "continuing. Tasks needing the image will surface this error.\n" +
1501
1519
  buildError,
@@ -1505,6 +1523,10 @@ async function ensureStandardImageInner(
1505
1523
  }
1506
1524
  }
1507
1525
 
1526
+ // Revisit ids durably observed under the standard tag across restarts.
1527
+ // Inherited labels never authorize sweeping task/compose images.
1528
+ if (imageReady && !engine.apple) await imageCleanup.sweep(run, previousImageId);
1529
+
1508
1530
  // 3. Reconcile every Uai-owned writer under the volume-global flock. Lock
1509
1531
  // bootstrap/permission repair precedes the single upgrade→repair→proof
1510
1532
  // transaction so node-run task materializers can acquire legacy volumes.
@@ -1648,7 +1670,7 @@ async function ensureStandardImageInner(
1648
1670
  }
1649
1671
  return {
1650
1672
  ok: false,
1651
- error: `Could not prepare shared runtime maintenance lock ${ASDF_MAINTENANCE_LOCK_PATH}.`,
1673
+ error: `${buildError ? `${buildError} ` : ""}Could not prepare shared runtime maintenance lock ${ASDF_MAINTENANCE_LOCK_PATH}.`,
1652
1674
  };
1653
1675
  }
1654
1676
  if (!(await reconcileVolumeAgentClis(activation))) {
@@ -1661,7 +1683,7 @@ async function ensureStandardImageInner(
1661
1683
  return {
1662
1684
  ok: false,
1663
1685
  error:
1664
- "Shared runtime maintenance failed; agent CLIs or Corepack did not pass execution proof.",
1686
+ `${buildError ? `${buildError} ` : ""}Shared runtime maintenance failed; agent CLIs or Corepack did not pass execution proof.`,
1665
1687
  };
1666
1688
  }
1667
1689
  return { ok: true };
@@ -393,6 +393,11 @@ export function assertWorkspaceScopedPath(
393
393
  }
394
394
  }
395
395
 
396
+ /** A malformed process request, distinct from provider/runtime failures. */
397
+ export class TaskEnvironmentRequestValidationError extends Error {
398
+ override name = "TaskEnvironmentRequestValidationError";
399
+ }
400
+
396
401
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
397
402
 
398
403
  /** Shared admission guard for provider implementations and fake providers. */
@@ -402,7 +407,7 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
402
407
  export function assertTaskEnvironmentExecRequest(request: TaskEnvironmentExecRequest): void {
403
408
  const { privileged, ...unprivileged } = request;
404
409
  if (privileged !== undefined && (privileged !== true || request.user !== "root")) {
405
- throw new Error("a privileged task environment exec must run as root");
410
+ throw new TaskEnvironmentRequestValidationError("a privileged task environment exec must run as root");
406
411
  }
407
412
  assertTaskEnvironmentProcessRequest(unprivileged);
408
413
  }
@@ -412,7 +417,7 @@ export function assertTaskEnvironmentProcessRequest(
412
417
  ): void {
413
418
  assertTaskEnvironmentSessionRequest(request);
414
419
  if (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs <= 0) {
415
- throw new Error("task environment timeout must be a positive integer");
420
+ throw new TaskEnvironmentRequestValidationError("task environment timeout must be a positive integer");
416
421
  }
417
422
  }
418
423
 
@@ -420,7 +425,7 @@ export function assertTaskEnvironmentProcessRequest(
420
425
  * step without the capability it asked for. */
421
426
  export function refusePrivilegedExec(request: TaskEnvironmentExecRequest): void {
422
427
  if (request.privileged === true) {
423
- throw new Error("this task environment provider has no privileged exec");
428
+ throw new TaskEnvironmentRequestValidationError("this task environment provider has no privileged exec");
424
429
  }
425
430
  }
426
431
 
@@ -434,13 +439,13 @@ export function assertTaskEnvironmentSessionRequest(
434
439
  // Only a one-shot exec may be privileged (assertTaskEnvironmentExecRequest
435
440
  // strips it after checking); anywhere else it is refused outright.
436
441
  if ("privileged" in request) {
437
- throw new Error("only a one-shot task environment exec may be privileged");
442
+ throw new TaskEnvironmentRequestValidationError("only a one-shot task environment exec may be privileged");
438
443
  }
439
444
  if (
440
445
  request.projectEnvProfile !== "maintenance" &&
441
446
  request.projectEnvProfile !== "task"
442
447
  ) {
443
- throw new Error(
448
+ throw new TaskEnvironmentRequestValidationError(
444
449
  "task environment project env profile must be maintenance or task",
445
450
  );
446
451
  }
@@ -451,19 +456,19 @@ export function assertTaskEnvironmentSessionRequest(
451
456
  (part) => typeof part !== "string" || part.length === 0 || part.includes("\0"),
452
457
  )
453
458
  ) {
454
- throw new Error("task environment argv must contain nonempty NUL-free strings");
459
+ throw new TaskEnvironmentRequestValidationError("task environment argv must contain nonempty NUL-free strings");
455
460
  }
456
461
  if (
457
462
  request.cwd !== undefined &&
458
- (!request.cwd.startsWith("/") || request.cwd.includes("\0"))
463
+ (typeof request.cwd !== "string" || !request.cwd.startsWith("/") || request.cwd.includes("\0"))
459
464
  ) {
460
- throw new Error("task environment cwd must be an absolute NUL-free path");
465
+ throw new TaskEnvironmentRequestValidationError("task environment cwd must be an absolute NUL-free path");
461
466
  }
462
467
  if (
463
468
  request.user !== undefined &&
464
- (request.user.length === 0 || request.user.includes("\0"))
469
+ (typeof request.user !== "string" || request.user.length === 0 || request.user.includes("\0"))
465
470
  ) {
466
- throw new Error("task environment user must be a nonempty NUL-free value");
471
+ throw new TaskEnvironmentRequestValidationError("task environment user must be a nonempty NUL-free value");
467
472
  }
468
473
  if (
469
474
  request.anchor !== undefined &&
@@ -474,7 +479,7 @@ export function assertTaskEnvironmentSessionRequest(
474
479
  (part) => typeof part !== "string" || part.length === 0 || part.includes("\0"),
475
480
  ))
476
481
  ) {
477
- throw new Error("a task environment anchor must be a NUL-free argv for a task user's process");
482
+ throw new TaskEnvironmentRequestValidationError("a task environment anchor must be a NUL-free argv for a task user's process");
478
483
  }
479
484
  if (
480
485
  request.inheritEnv !== undefined &&
@@ -485,7 +490,7 @@ export function assertTaskEnvironmentSessionRequest(
485
490
  (name) => typeof name !== "string" || !ENV_NAME.test(name),
486
491
  ))
487
492
  ) {
488
- throw new Error("task environment inherited env names are invalid");
493
+ throw new TaskEnvironmentRequestValidationError("task environment inherited env names are invalid");
489
494
  }
490
495
  for (const [name, value] of Object.entries(request.env ?? {})) {
491
496
  if (
@@ -493,21 +498,21 @@ export function assertTaskEnvironmentSessionRequest(
493
498
  typeof value !== "string" ||
494
499
  value.includes("\0")
495
500
  ) {
496
- throw new Error("task environment env must contain valid NUL-free entries");
501
+ throw new TaskEnvironmentRequestValidationError("task environment env must contain valid NUL-free entries");
497
502
  }
498
503
  }
499
504
  if (
500
505
  !Number.isSafeInteger(request.maxOutputBytes) ||
501
506
  request.maxOutputBytes <= 0
502
507
  ) {
503
- throw new Error("task environment output limit must be a positive integer");
508
+ throw new TaskEnvironmentRequestValidationError("task environment output limit must be a positive integer");
504
509
  }
505
510
  if (
506
511
  "launchTimeoutMs" in request &&
507
512
  (!Number.isSafeInteger(request.launchTimeoutMs) ||
508
513
  request.launchTimeoutMs <= 0)
509
514
  ) {
510
- throw new Error(
515
+ throw new TaskEnvironmentRequestValidationError(
511
516
  "task environment detached launch timeout must be a positive integer",
512
517
  );
513
518
  }
@@ -74,6 +74,7 @@ import type {
74
74
  TaskProjectStatusResult,
75
75
  } from "../src/protocol";
76
76
  import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
77
+ import { TaskEnvironmentRequestValidationError } from "./task-environment/types";
77
78
  import type {
78
79
  TaskEnvironmentExecRequest,
79
80
  TaskEnvironmentExecResult,
@@ -271,7 +272,7 @@ export class TaskProjectJournalStore {
271
272
 
272
273
  /**
273
274
  * $1 staging repo path, $2 target path, $3 repo URL, $4 task branch,
274
- * $5 activation id, $6 the project's fallback `.tool-versions` ("" for none).
275
+ * $5 activation id, optional $6 the project's fallback `.tool-versions`.
275
276
  * Exit 3: the target already exists. The last stdout line is the checked-out
276
277
  * commit, or `empty` for a repository with no commits.
277
278
  */
@@ -684,13 +685,13 @@ export class TaskProjectOps {
684
685
  }
685
686
 
686
687
  prepare(input: TaskProjectPrepareInput): Promise<TaskProjectPrepareResult> {
687
- return this.serialized(input.taskId, async () => {
688
- if (!OPERATION_ID.test(input.operationId)) throw new Error("invalid operation id");
689
- if (!SLUG.test(input.project.slug)) throw new Error("invalid project slug");
688
+ return this.serialized<TaskProjectPrepareResult>(input.taskId, async () => {
689
+ if (!OPERATION_ID.test(input.operationId)) throw new TaskEnvironmentRequestValidationError("invalid operation id");
690
+ if (!SLUG.test(input.project.slug)) throw new TaskEnvironmentRequestValidationError("invalid project slug");
690
691
  if (!REPO_URL.test(input.project.repoUrl)) {
691
- return refused<TaskProjectPrepareResult>("clone_failed", "the repository URL is not an HTTPS or SSH Git remote");
692
+ throw new TaskEnvironmentRequestValidationError("the repository URL is not an HTTPS or SSH Git remote");
692
693
  }
693
- if (!BRANCH.test(input.branch)) throw new Error("invalid task branch");
694
+ if (!BRANCH.test(input.branch)) throw new TaskEnvironmentRequestValidationError("invalid task branch");
694
695
  const fenced = this.fenceFails(input);
695
696
  if (fenced) return refused<TaskProjectPrepareResult>(fenced.reason, fenced.message);
696
697
 
@@ -728,7 +729,9 @@ export class TaskProjectOps {
728
729
  input.project.repoUrl,
729
730
  input.branch,
730
731
  activationId,
731
- input.project.toolVersions ?? "",
732
+ // The provider rejects empty argv elements. The script already
733
+ // defaults an absent final argument to no fallback (${6:-}).
734
+ ...(input.project.toolVersions ? [input.project.toolVersions] : []),
732
735
  ],
733
736
  { cwd: environment.descriptor.workspacePath, timeoutMs: TASK_PROJECT_CLONE_TIMEOUT_MS },
734
737
  ),
@@ -780,6 +783,13 @@ export class TaskProjectOps {
780
783
  updatedAt: Date.now(),
781
784
  });
782
785
  return { outcome: "prepared", head, envSchema };
786
+ }).catch((error: unknown) => {
787
+ if (error instanceof TaskEnvironmentRequestValidationError) {
788
+ return refused<TaskProjectPrepareResult>("invalid_request", error.message);
789
+ }
790
+ // Provider availability, rebuilds and journal I/O remain INTERNAL so
791
+ // the cloud can retry them; never classify an error by its message.
792
+ throw error;
783
793
  });
784
794
  }
785
795
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.168",
3
+ "version": "0.9.169",
4
4
  "description": "Uai host \u2014 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/main.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import type { AssignmentDelivery, DeliveryOrigin } from "./protocol";
3
3
 
4
4
  import "./load-env";
5
+ import { sampleDiskPressure } from "../lib/disk-pressure";
5
6
  import { TASK_SECRETS_FEATURE, isTaskSecretStagingPut } from "./task-secrets";
6
7
  import { writeTaskSecretStaging } from "../lib/task-secret-staging";
7
8
 
@@ -1473,12 +1474,28 @@ async function startLocalUi(): Promise<void> {
1473
1474
  }
1474
1475
  }
1475
1476
 
1477
+ // Capacity fits the existing capability/status snapshot; no new messages.
1478
+ let diskSnapshot: HostCapabilities["disk"];
1479
+ let diskSampling = false;
1480
+ async function refreshDiskSnapshot(): Promise<void> {
1481
+ if (diskSampling || sidecarMode()) return;
1482
+ diskSampling = true;
1483
+ try {
1484
+ diskSnapshot = await sampleDiskPressure();
1485
+ sendCapabilities();
1486
+ } finally { diskSampling = false; }
1487
+ }
1488
+ void refreshDiskSnapshot();
1489
+ const diskTimer = setInterval(() => void refreshDiskSnapshot(), 60_000);
1490
+ diskTimer.unref?.();
1491
+
1476
1492
  /** Build the current host capability advertisement (ADR-021). */
1477
1493
  function buildCapabilities(): HostCapabilities {
1478
1494
  const runtime = containerRuntimeCapabilityState();
1479
1495
  const projectEnvBindings = projectEnvBindingCapability();
1480
1496
  return {
1481
1497
  version: packageVersion(),
1498
+ ...(diskSnapshot ? { disk: diskSnapshot } : {}),
1482
1499
  protocolFeatures: [
1483
1500
  "host-workspace-file-read-v1",
1484
1501
  ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
package/src/protocol.ts CHANGED
@@ -4068,6 +4068,15 @@ export function isHostConfigAckFrame(
4068
4068
  * changes. The cloud caches it per hostId to drive the task-creation pickers.
4069
4069
  */
4070
4070
  export interface HostCapabilities {
4071
+ /** Filesystem capacity, refreshed each minute. Null means unmeasurable
4072
+ * (for example Docker in a VM); available excludes root-reserved blocks. */
4073
+ disk?: {
4074
+ sampledAt: number;
4075
+ /** Cloud-only: overwritten on authenticated receipt, never trusted from the host. */
4076
+ receivedAt?: number;
4077
+ home: { totalBytes: number; availableBytes: number } | null;
4078
+ docker: { totalBytes: number; availableBytes: number } | null;
4079
+ };
4071
4080
  /** The host-agent package version — the cloud UI shows it on the host page
4072
4081
  * and flags when npm has a newer release. Optional (older hosts omit it). */
4073
4082
  version?: string;
@@ -4337,6 +4346,15 @@ export interface TaskDiffInput {
4337
4346
  projects: Array<{ id: string; slug: string }>;
4338
4347
  }
4339
4348
 
4349
+ /** Cloud Prepare retry timing. */
4350
+ export const TASK_PROJECT_PREPARE_MAX_ATTEMPTS = 12;
4351
+ export const TASK_PROJECT_PREPARE_TIMEOUT_MS = 11 * 60_000;
4352
+ export function taskProjectRetryDelayMs(attempts: number): number {
4353
+ return Math.min(5 * 60_000, 2_000 * 2 ** Math.min(attempts, 7));
4354
+ }
4355
+ /** Bound the CLI tool call; the durable cloud operation may outlast it. */
4356
+ export const TASK_PROJECT_ADD_WAIT_MS = 40 * 60_000;
4357
+
4340
4358
  /**
4341
4359
  * ADR-161 §2.3: every live-project command names the task's run, the
4342
4360
  * operation, and the project-set revision the operation commits. The runner
@@ -4362,6 +4380,7 @@ export interface TaskProjectPrepareInput extends TaskProjectOpFence {
4362
4380
  /** Why a live-project command did nothing. `grant_not_installed` and
4363
4381
  * `busy` are retryable; the rest are final for that operation. */
4364
4382
  export type TaskProjectRefusal =
4383
+ | "invalid_request"
4365
4384
  | "repo_access_denied"
4366
4385
  | "clone_failed"
4367
4386
  | "workspace_path_taken"