@runuai/host 0.9.70 → 0.9.72

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
@@ -130,6 +130,42 @@ export function writeAgentCli(
130
130
  }
131
131
  }
132
132
 
133
+ /**
134
+ * ADR-121: the same CLI materialization, but through the environment's own
135
+ * transport — machine workspaces are not host-reachable via taskWorkspaceDir.
136
+ * Same no-token invariant: uai.json carries the apiUrl only.
137
+ */
138
+ export async function writeAgentCliViaEnvironment(
139
+ environment: {
140
+ readonly descriptor: { workspacePath: string };
141
+ writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void>;
142
+ },
143
+ roster: RosterAgent[],
144
+ apiUrl: string | null,
145
+ allowPermissionless = false,
146
+ ): Promise<boolean> {
147
+ if (
148
+ !apiUrl ||
149
+ (rosterPermissions(roster).length === 0 && !allowPermissionless)
150
+ ) {
151
+ return false;
152
+ }
153
+ try {
154
+ const root = environment.descriptor.workspacePath;
155
+ await environment.writeWorkspaceFile(
156
+ `${root}/.uai/uai.json`,
157
+ Buffer.from(`${JSON.stringify({ apiUrl }, null, 2)}\n`),
158
+ );
159
+ await environment.writeWorkspaceFile(
160
+ `${root}/.uai/cli.mjs`,
161
+ Buffer.from(CLI_SOURCE),
162
+ );
163
+ return true;
164
+ } catch {
165
+ return false;
166
+ }
167
+ }
168
+
133
169
  /**
134
170
  * Per-agent env for the `docker exec` that spawns one agent (ADR-048): its OWN
135
171
  * task token (carrying only ITS permissions) + the API url. Empty when the agent
@@ -0,0 +1,492 @@
1
+ /**
2
+ * ADR-121 + ADR-061: durable agent sessions for MACHINE-backed tasks.
3
+ *
4
+ * The container path's DurableProcess polls the session files on a host
5
+ * bind mount — a machine shares no filesystem with the host, so this
6
+ * variant speaks the same runner protocol over the environment transport:
7
+ *
8
+ * outbox one long-lived `tail -c +<offset+1> -F` streamed over ssh;
9
+ * byte offset = initial + bytes received (append-only file).
10
+ * A dropped ssh stream is respawned from the current offset.
11
+ * inbox chained one-shot appends (`cat >> inbox.jsonl` with stdin).
12
+ * liveness periodic heartbeat mtime probe via exec; the runner beats
13
+ * every 5s, so >25s of silence without an exit meta = dead.
14
+ *
15
+ * The runner itself (runner.mjs) is dependency-free Node and runs on the
16
+ * machine unchanged; it is delivered per-session via writeWorkspaceFile so
17
+ * it can never skew against this host's version.
18
+ */
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { posix } from "node:path";
22
+
23
+ import type {
24
+ TaskEnvironmentHandle,
25
+ TaskEnvironmentProcess,
26
+ } from "../task-environment/types";
27
+ import { runnerScriptPath } from "./durable-proc";
28
+ import type { ExitHandler, LineHandler } from "./proc";
29
+ import type { LineTransport } from "./transport";
30
+
31
+ /** Runner beats every 5 s; 25 s of silence without an exit meta = dead. */
32
+ const HEARTBEAT_STALE_MS = 25_000;
33
+ const HEARTBEAT_PROBE_MS = 10_000;
34
+ const CLOSE_GRACE_MS = 8_000;
35
+ const STEP_TIMEOUT_MS = 30_000;
36
+ const STEP_OUTPUT_BYTES = 64 * 1024;
37
+ const TAIL_RESPAWN_DELAY_MS = 1_000;
38
+
39
+ interface RunnerMeta {
40
+ __uai: string;
41
+ code?: number | null;
42
+ stderrTail?: string;
43
+ }
44
+
45
+ /** The handle ops this transport needs beyond the session surface. */
46
+ export type MachineSessionEnvironment = Pick<
47
+ TaskEnvironmentHandle<unknown>,
48
+ | "descriptor"
49
+ | "exec"
50
+ | "spawnSession"
51
+ | "writeWorkspaceFile"
52
+ | "launchDetachedSession"
53
+ >;
54
+
55
+ async function execStep(
56
+ environment: MachineSessionEnvironment,
57
+ argv: readonly [string, ...string[]],
58
+ stdin?: Uint8Array,
59
+ ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
60
+ const result = await environment.exec({
61
+ argv,
62
+ ...(stdin ? { stdin } : {}),
63
+ timeoutMs: STEP_TIMEOUT_MS,
64
+ maxOutputBytes: STEP_OUTPUT_BYTES,
65
+ });
66
+ return {
67
+ exitCode: result.exitCode,
68
+ stdout: Buffer.from(result.stdout).toString("utf8"),
69
+ stderr: Buffer.from(result.stderr).toString("utf8"),
70
+ };
71
+ }
72
+
73
+ /** Append bytes to an environment file (creating parents), via exec stdin. */
74
+ export async function appendEnvironmentFile(
75
+ environment: MachineSessionEnvironment,
76
+ path: string,
77
+ bytes: string,
78
+ ): Promise<void> {
79
+ const result = await execStep(
80
+ environment,
81
+ [
82
+ "/bin/sh",
83
+ "-c",
84
+ 'mkdir -p "$(dirname "$1")" && cat >> "$1"',
85
+ "append",
86
+ path,
87
+ ],
88
+ Buffer.from(bytes),
89
+ );
90
+ if (result.exitCode !== 0) {
91
+ throw new Error(`environment append to ${path} exited ${result.exitCode}`);
92
+ }
93
+ }
94
+
95
+ /** Heartbeat freshness of an environment-side session dir (attach check). */
96
+ export async function machineHeartbeatFresh(
97
+ environment: MachineSessionEnvironment,
98
+ sessionDir: string,
99
+ freshMs: number,
100
+ ): Promise<boolean> {
101
+ try {
102
+ const result = await execStep(environment, [
103
+ "/bin/sh",
104
+ "-c",
105
+ 'stat -c %Y "$1/heartbeat"',
106
+ "stat",
107
+ sessionDir,
108
+ ]);
109
+ if (result.exitCode !== 0) return false;
110
+ const mtimeSeconds = Number(result.stdout.trim());
111
+ if (!Number.isFinite(mtimeSeconds)) return false;
112
+ return Date.now() - mtimeSeconds * 1000 < freshMs;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ export interface MachineDurableOptions {
119
+ environment: MachineSessionEnvironment;
120
+ /** Environment-absolute session dir (inside the workspace). */
121
+ sessionDir: string;
122
+ /** Resume consumption from this outbox byte offset (attach path). */
123
+ initialOutboxOffset?: number;
124
+ onOffsetAdvance?: (offset: number) => void;
125
+ onCloseRequested?: () => void;
126
+ /** Provider-owned detached launch. Omit to attach to a live runner. */
127
+ launch?: () => Promise<{ exitCode: number | null; stderr: Uint8Array }>;
128
+ debugLabel?: string;
129
+ }
130
+
131
+ export class MachineDurableProcess implements LineTransport {
132
+ readonly #environment: MachineSessionEnvironment;
133
+ readonly #sessionDir: string;
134
+ readonly #outboxPath: string;
135
+ readonly #inboxPath: string;
136
+
137
+ readonly #lineHandlers = new Set<LineHandler>();
138
+ readonly #exitHandlers = new Set<ExitHandler>();
139
+
140
+ #offset: number;
141
+ #lineBuf = "";
142
+ #stderrBuf = "";
143
+ #closed = false;
144
+ #detached = false;
145
+ #sawSpawnMeta = false;
146
+ readonly #startedAt = Date.now();
147
+ #tail: TaskEnvironmentProcess | null = null;
148
+ #tailGeneration = 0;
149
+ #heartbeatTimer: ReturnType<typeof setInterval> | null = null;
150
+ #closeTimer: ReturnType<typeof setTimeout> | null = null;
151
+ #respawnTimer: ReturnType<typeof setTimeout> | null = null;
152
+ #probing = false;
153
+ #inboxChain: Promise<void> = Promise.resolve();
154
+ readonly #onOffsetAdvance: ((offset: number) => void) | null;
155
+ readonly #onCloseRequested: (() => void) | null;
156
+ readonly #debug: string | null;
157
+
158
+ constructor(opts: MachineDurableOptions) {
159
+ this.#environment = opts.environment;
160
+ this.#sessionDir = opts.sessionDir;
161
+ this.#outboxPath = posix.join(opts.sessionDir, "outbox.jsonl");
162
+ this.#inboxPath = posix.join(opts.sessionDir, "inbox.jsonl");
163
+ this.#offset = opts.initialOutboxOffset ?? 0;
164
+ this.#onOffsetAdvance = opts.onOffsetAdvance ?? null;
165
+ this.#onCloseRequested = opts.onCloseRequested ?? null;
166
+ this.#debug =
167
+ opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
168
+
169
+ if (opts.launch) {
170
+ void opts.launch().then(
171
+ (result) => {
172
+ const stderr = Buffer.from(result.stderr).toString("utf8");
173
+ if (stderr) this.#stderrBuf = (this.#stderrBuf + stderr).slice(-8192);
174
+ if (result.exitCode !== 0) this.#finish(null);
175
+ },
176
+ (error: unknown) => {
177
+ this.#stderrBuf = (
178
+ this.#stderrBuf +
179
+ (error instanceof Error ? error.message : String(error))
180
+ ).slice(-8192);
181
+ this.#finish(null);
182
+ },
183
+ );
184
+ } else {
185
+ this.#sawSpawnMeta = true; // attach: the runner pre-exists
186
+ // The container path pre-checks heartbeat freshness with a sync stat;
187
+ // over ssh that check is async, so attach optimistically and probe
188
+ // NOW — a dead runner finishes within a round-trip instead of a
189
+ // full probe interval, and the orchestrator respawns fresh.
190
+ void this.#probeLiveness();
191
+ }
192
+
193
+ void this.#spawnTail();
194
+ this.#heartbeatTimer = setInterval(() => {
195
+ void this.#probeLiveness();
196
+ }, HEARTBEAT_PROBE_MS);
197
+ }
198
+
199
+ #log(message: string): void {
200
+ console.error(`[uai-agent ${this.#debug}] ${message}`);
201
+ }
202
+
203
+ // ---- outbox tail over ssh ------------------------------------------------
204
+
205
+ async #spawnTail(): Promise<void> {
206
+ if (this.#closed || this.#detached) return;
207
+ const generation = ++this.#tailGeneration;
208
+ let tail: TaskEnvironmentProcess;
209
+ try {
210
+ // spawnSession, deliberately: a session request carries NO timeoutMs,
211
+ // and the streaming runner only arms its kill timer when one exists —
212
+ // the tail must live as long as the runner does.
213
+ tail = await this.#environment.spawnSession({
214
+ // -c +N is 1-based (start AT byte N): resume just past the consumed
215
+ // prefix. -F survives the outbox not existing yet; its chatter goes
216
+ // to stderr, never the byte stream.
217
+ argv: [
218
+ "/bin/sh",
219
+ "-c",
220
+ 'exec tail -c +"$1" -F "$2" 2>/dev/null',
221
+ "tail",
222
+ String(this.#offset + 1),
223
+ this.#outboxPath,
224
+ ],
225
+ maxOutputBytes: 8 * 1024 * 1024,
226
+ });
227
+ } catch {
228
+ this.#scheduleTailRespawn(generation);
229
+ return;
230
+ }
231
+ if (this.#closed || this.#detached || generation !== this.#tailGeneration) {
232
+ await tail.terminate().catch(() => {});
233
+ return;
234
+ }
235
+ this.#tail = tail;
236
+ void (async () => {
237
+ try {
238
+ for await (const chunk of tail.stdout) {
239
+ if (this.#closed || this.#detached) break;
240
+ if (generation !== this.#tailGeneration) break;
241
+ const text = Buffer.from(chunk);
242
+ this.#offset += text.byteLength;
243
+ this.#lineBuf += text.toString("utf8");
244
+ this.#drainLines();
245
+ if (this.#onOffsetAdvance && !this.#closed) {
246
+ try {
247
+ this.#onOffsetAdvance(this.#offset);
248
+ } catch {
249
+ // Persistence hiccup — the next batch retries larger.
250
+ }
251
+ }
252
+ }
253
+ } catch {
254
+ // Stream error — the respawn below re-establishes the tail.
255
+ }
256
+ // The ssh stream ended (drop, machine restart, terminate). If the
257
+ // session is still live, come back from the current offset.
258
+ if (!this.#closed && !this.#detached && generation === this.#tailGeneration) {
259
+ this.#scheduleTailRespawn(generation);
260
+ }
261
+ })();
262
+ }
263
+
264
+ #scheduleTailRespawn(generation: number): void {
265
+ if (this.#closed || this.#detached) return;
266
+ if (generation !== this.#tailGeneration) return;
267
+ if (this.#respawnTimer) return;
268
+ this.#respawnTimer = setTimeout(() => {
269
+ this.#respawnTimer = null;
270
+ void this.#spawnTail();
271
+ }, TAIL_RESPAWN_DELAY_MS);
272
+ }
273
+
274
+ #drainLines(): void {
275
+ let nl: number;
276
+ while ((nl = this.#lineBuf.indexOf("\n")) >= 0) {
277
+ const line = this.#lineBuf.slice(0, nl).trim();
278
+ this.#lineBuf = this.#lineBuf.slice(nl + 1);
279
+ if (line.length === 0) continue;
280
+ if (line.startsWith('{"__uai"')) {
281
+ this.#handleMeta(line);
282
+ continue;
283
+ }
284
+ if (this.#debug) this.#log(`<- ${line.slice(0, 1000)}`);
285
+ for (const handler of this.#lineHandlers) {
286
+ try {
287
+ handler(line);
288
+ } catch {
289
+ // A broken handler must not wedge the tail loop.
290
+ }
291
+ }
292
+ }
293
+ }
294
+
295
+ #handleMeta(line: string): void {
296
+ let meta: RunnerMeta;
297
+ try {
298
+ meta = JSON.parse(line) as RunnerMeta;
299
+ } catch {
300
+ return;
301
+ }
302
+ if (meta.__uai === "spawn") {
303
+ this.#sawSpawnMeta = true;
304
+ } else if (meta.__uai === "exit") {
305
+ if (typeof meta.stderrTail === "string" && meta.stderrTail) {
306
+ this.#stderrBuf = meta.stderrTail.slice(-8192);
307
+ }
308
+ if (this.#debug) this.#log(`exit meta: code ${meta.code ?? null}`);
309
+ this.#finish(typeof meta.code === "number" ? meta.code : null);
310
+ }
311
+ }
312
+
313
+ // ---- liveness ------------------------------------------------------------
314
+
315
+ async #probeLiveness(): Promise<void> {
316
+ if (this.#closed || this.#detached || this.#probing) return;
317
+ this.#probing = true;
318
+ try {
319
+ if (!this.#sawSpawnMeta) {
320
+ if (Date.now() - this.#startedAt > HEARTBEAT_STALE_MS) this.#finish(null);
321
+ return;
322
+ }
323
+ const fresh = await machineHeartbeatFresh(
324
+ this.#environment,
325
+ this.#sessionDir,
326
+ HEARTBEAT_STALE_MS,
327
+ );
328
+ if (this.#closed || this.#detached) return;
329
+ if (!fresh) this.#finish(null);
330
+ } finally {
331
+ this.#probing = false;
332
+ }
333
+ }
334
+
335
+ #finish(code: number | null): void {
336
+ if (this.#closed) return;
337
+ this.#closed = true;
338
+ this.#stopTimers();
339
+ const tail = this.#tail;
340
+ this.#tail = null;
341
+ if (tail) void tail.terminate().catch(() => {});
342
+ for (const handler of this.#exitHandlers) handler(code);
343
+ }
344
+
345
+ #stopTimers(): void {
346
+ if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
347
+ if (this.#closeTimer) clearTimeout(this.#closeTimer);
348
+ if (this.#respawnTimer) clearTimeout(this.#respawnTimer);
349
+ this.#heartbeatTimer = null;
350
+ this.#closeTimer = null;
351
+ this.#respawnTimer = null;
352
+ }
353
+
354
+ // ---- LineProcess-compatible surface ---------------------------------------
355
+
356
+ onLine(handler: LineHandler): void {
357
+ this.#lineHandlers.add(handler);
358
+ }
359
+
360
+ onExit(handler: ExitHandler): void {
361
+ this.#exitHandlers.add(handler);
362
+ }
363
+
364
+ /** `__uai` MUST stay the first key: both runner and host filter meta lines
365
+ * with a byte-prefix check, not a parse (see DurableProcess.writeLine). */
366
+ writeLine(value: unknown): void {
367
+ if (this.#closed || this.#detached) return;
368
+ const json = JSON.stringify(value);
369
+ const inputMeta = JSON.stringify({ __uai: "input", ts: Date.now() });
370
+ if (this.#debug) this.#log(`-> ${json.slice(0, 1000)}`);
371
+ this.#inboxChain = this.#inboxChain
372
+ .then(() =>
373
+ appendEnvironmentFile(
374
+ this.#environment,
375
+ this.#inboxPath,
376
+ `${inputMeta}\n${json}\n`,
377
+ ),
378
+ )
379
+ .catch(() => {
380
+ // Transport error — liveness probes will surface a dead session.
381
+ });
382
+ }
383
+
384
+ get stderrTail(): string {
385
+ return this.#stderrBuf;
386
+ }
387
+
388
+ get isClosed(): boolean {
389
+ return this.#closed;
390
+ }
391
+
392
+ get outboxOffset(): number {
393
+ return this.#offset;
394
+ }
395
+
396
+ /** Stop consuming WITHOUT touching the runner (host restart in miniature). */
397
+ detach(): void {
398
+ if (this.#detached || this.#closed) return;
399
+ this.#detached = true;
400
+ this.#stopTimers();
401
+ const tail = this.#tail;
402
+ this.#tail = null;
403
+ if (tail) void tail.terminate().catch(() => {});
404
+ }
405
+
406
+ /** Real teardown: ask the runner to stop its CLI, then observe the exit. */
407
+ async close(): Promise<void> {
408
+ if (this.#closed || this.#detached) return;
409
+ try {
410
+ this.#onCloseRequested?.();
411
+ } catch {
412
+ // Bookkeeping only.
413
+ }
414
+ try {
415
+ await this.#inboxChain;
416
+ await appendEnvironmentFile(
417
+ this.#environment,
418
+ this.#inboxPath,
419
+ '{"__uai":"stop"}\n',
420
+ );
421
+ } catch {
422
+ // Inbox unwritable — fall through to the grace timer.
423
+ }
424
+ this.#closeTimer = setTimeout(() => this.#finish(null), CLOSE_GRACE_MS);
425
+ }
426
+ }
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // Session-dir plumbing shared with the transport layer.
430
+ // ---------------------------------------------------------------------------
431
+
432
+ export function machineSessionDir(
433
+ workspacePath: string,
434
+ agentId: string,
435
+ ): string {
436
+ return posix.join(
437
+ workspacePath,
438
+ ".uai",
439
+ "sessions",
440
+ agentId,
441
+ `${String(Date.now())}-${process.pid}`,
442
+ );
443
+ }
444
+
445
+ /** Deliver this host's runner into the environment session dir. */
446
+ export async function deployRunner(
447
+ environment: MachineSessionEnvironment,
448
+ sessionDir: string,
449
+ ): Promise<string> {
450
+ const runnerPath = posix.join(sessionDir, "runner.mjs");
451
+ await environment.writeWorkspaceFile(
452
+ runnerPath,
453
+ readFileSync(runnerScriptPath()),
454
+ );
455
+ return runnerPath;
456
+ }
457
+
458
+ /** Environment-side `current` pointer (agents read the path from their
459
+ * preamble). Best-effort: failure must never kill a session. */
460
+ export async function publishMachineCurrentSession(
461
+ environment: MachineSessionEnvironment,
462
+ sessionDir: string,
463
+ ): Promise<void> {
464
+ try {
465
+ await execStep(environment, [
466
+ "/bin/sh",
467
+ "-c",
468
+ 'ln -sfn "$(basename "$1")" "$(dirname "$1")/current"',
469
+ "publish",
470
+ sessionDir,
471
+ ]);
472
+ } catch {
473
+ // Diagnostic pointer only.
474
+ }
475
+ }
476
+
477
+ /** Ask a (possibly dead) predecessor runner to stop — same gesture as the
478
+ * host-FS append, over the transport, and just as harmless when it's gone. */
479
+ export async function stopMachinePredecessor(
480
+ environment: MachineSessionEnvironment,
481
+ sessionDir: string,
482
+ ): Promise<void> {
483
+ try {
484
+ await appendEnvironmentFile(
485
+ environment,
486
+ posix.join(sessionDir, "inbox.jsonl"),
487
+ '{"__uai":"stop"}\n',
488
+ );
489
+ } catch {
490
+ // Dir gone / runner dead — nothing to stop.
491
+ }
492
+ }