@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 +26 -4
- package/lib/disk-pressure.ts +42 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image.ts +31 -9
- package/lib/task-environment/types.ts +20 -15
- package/lib/task-project-ops.ts +17 -7
- package/package.json +1 -1
- package/src/main.ts +17 -0
- package/src/protocol.ts +19 -0
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() +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/standard-image.ts
CHANGED
|
@@ -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()`
|
|
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
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
515
|
+
throw new TaskEnvironmentRequestValidationError(
|
|
511
516
|
"task environment detached launch timeout must be a positive integer",
|
|
512
517
|
);
|
|
513
518
|
}
|
package/lib/task-project-ops.ts
CHANGED
|
@@ -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
|
|
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
|
|
689
|
-
if (!SLUG.test(input.project.slug)) throw new
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
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"
|