@runuai/host 0.4.3 → 0.6.0

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,15 @@
1
+ -- ADR-061: durable agent sessions. One row per (task, agent) tracking the
2
+ -- live in-container runner and how far the host has consumed its outbox —
3
+ -- the byte offset is what makes a host restart resume instead of respawn.
4
+ CREATE TABLE `host_agent_sessions` (
5
+ `task_id` text NOT NULL,
6
+ `agent_id` text NOT NULL,
7
+ `session_dir` text NOT NULL,
8
+ `container_name` text NOT NULL,
9
+ `kind` text NOT NULL,
10
+ `outbox_offset` integer DEFAULT 0 NOT NULL,
11
+ `status` text DEFAULT 'running' NOT NULL,
12
+ `created_at` integer NOT NULL,
13
+ `updated_at` integer NOT NULL,
14
+ PRIMARY KEY(`task_id`, `agent_id`)
15
+ );
@@ -64,6 +64,13 @@
64
64
  "when": 1779900009000,
65
65
  "tag": "0008_host_mcp_connections",
66
66
  "breakpoints": true
67
+ },
68
+ {
69
+ "idx": 9,
70
+ "version": "6",
71
+ "when": 1779900010000,
72
+ "tag": "0009_host_agent_sessions",
73
+ "breakpoints": true
67
74
  }
68
75
  ]
69
76
  }
package/db/schema.ts CHANGED
@@ -121,6 +121,39 @@ export type NewHostProjectEnv = typeof hostProjectEnv.$inferInsert;
121
121
  // is sealed with the host master key in single-column `ct.nonce` base64 form
122
122
  // (same packing as host_project_env). The cloud only ever holds the non-secret
123
123
  // metadata; acks never echo secrets back.
124
+ // ---------------------------------------------------------------------------
125
+ // host_agent_sessions — ADR-061 durable agent sessions. One row per
126
+ // (task, agent): where the live in-container runner's session dir is and how
127
+ // far the host has consumed its outbox. The byte offset is what turns a host
128
+ // restart into "resume tailing" instead of "kill and respawn"; attachability
129
+ // itself is judged live off the runner's heartbeat file, not this row.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ export const hostAgentSessions = sqliteTable(
133
+ "host_agent_sessions",
134
+ {
135
+ taskId: text("task_id").notNull(),
136
+ agentId: text("agent_id").notNull(),
137
+ // Host-side absolute path of the session dir (on the workspace mount,
138
+ // identical in-container — task-up bind-mounts the workspace at its own
139
+ // host path).
140
+ sessionDir: text("session_dir").notNull(),
141
+ containerName: text("container_name").notNull(),
142
+ kind: text("kind").notNull(), // "claude" | "codex" | ...
143
+ outboxOffset: integer("outbox_offset", { mode: "number" })
144
+ .notNull()
145
+ .default(0),
146
+ status: text("status").notNull().default("running"), // running|closed
147
+ createdAt: integer("created_at", { mode: "number" }).notNull(),
148
+ updatedAt: integer("updated_at", { mode: "number" }).notNull(),
149
+ },
150
+ (t) => ({
151
+ pk: primaryKey({ columns: [t.taskId, t.agentId] }),
152
+ }),
153
+ );
154
+
155
+ export type HostAgentSession = typeof hostAgentSessions.$inferSelect;
156
+
124
157
  export const mcpConnections = sqliteTable("host_mcp_connections", {
125
158
  id: text("id").primaryKey(), // cloud uai_mcp_connections id
126
159
  userId: text("user_id").notNull(),
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { newId } from "../ulid";
24
- import { dockerExecArgs, LineProcess } from "./proc";
24
+ import { createAgentTransport, type LineTransport } from "./transport";
25
25
  import { register } from "./registry";
26
26
  import type {
27
27
  AgentEvent,
@@ -187,17 +187,28 @@ const CLAUDE_ARGS = [
187
187
  // sandbox. Without this, stream-json has no interactive approver and
188
188
  // every Write/Bash silently self-denies.
189
189
  "--dangerously-skip-permissions",
190
+ // MCP servers come from the uai-managed file, EXPLICITLY (ADR-057/061).
191
+ // Discovered project .mcp.json servers sit behind a per-server approval
192
+ // that headless --print can never answer ("⏸ Pending approval"), which
193
+ // silently loaded a stale subset. --mcp-config bypasses the approval
194
+ // gate; --strict-mcp-config keeps stray host-copied user configs out.
195
+ // setupMcpTaskConfig always writes this file (empty when no connections)
196
+ // before sessions spawn.
197
+ "--mcp-config",
198
+ "/workspace/.mcp.json",
199
+ "--strict-mcp-config",
190
200
  ];
191
201
 
192
202
  export class ClaudeSession implements AgentSession {
193
203
  readonly agentId: string;
194
204
  readonly kind: AgentKind = "claude";
195
205
 
196
- private readonly proc: LineProcess;
206
+ private readonly proc: LineTransport;
197
207
  private readonly handlers = new Set<AgentEventHandler>();
198
208
  private closed = false;
199
209
 
200
210
  constructor(args: {
211
+ taskId: string;
201
212
  agent: RosterAgent;
202
213
  containerName: string;
203
214
  systemPreamble: string;
@@ -231,16 +242,20 @@ export class ClaudeSession implements AgentSession {
231
242
  // it needs CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or an
232
243
  // API key. These live in the host-agent's env (never the cloud, ADR-015);
233
244
  // only ones actually set are forwarded.
234
- const { command, args: argv } = dockerExecArgs(
235
- args.containerName,
236
- "claude",
245
+ // ADR-061: durable by default the CLI is owned by an in-container
246
+ // runner and survives host restarts (attach resumes it); legacy pipes
247
+ // behind UAI_DURABLE_SESSIONS=0. Claude is host-side stateless, so a
248
+ // live runner can be re-attached (allowAttach).
249
+ this.proc = createAgentTransport({
250
+ taskId: args.taskId,
251
+ agentId: this.agentId,
252
+ containerName: args.containerName,
253
+ cli: "claude",
237
254
  cliArgs,
238
- ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
239
- args.agentEnv ?? {},
240
- );
241
- this.proc = new LineProcess({
242
- command,
243
- args: argv,
255
+ passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
256
+ explicitEnv: args.agentEnv ?? {},
257
+ allowAttach: true,
258
+ kind: "claude",
244
259
  debugLabel: `claude:${this.agentId}`,
245
260
  });
246
261
  this.proc.onLine((line) => {
@@ -349,6 +364,6 @@ register({
349
364
  process.env.ANTHROPIC_API_KEY ||
350
365
  process.env.ANTHROPIC_AUTH_TOKEN,
351
366
  ),
352
- create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
353
- new ClaudeSession({ agent, containerName, systemPreamble, agentEnv }),
367
+ create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
368
+ new ClaudeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
354
369
  });
@@ -33,7 +33,7 @@ import { homedir } from "node:os";
33
33
  import { join } from "node:path";
34
34
 
35
35
  import { newId } from "../ulid";
36
- import { dockerExecArgs, LineProcess } from "./proc";
36
+ import { createAgentTransport, type LineTransport } from "./transport";
37
37
  import { register } from "./registry";
38
38
  import type {
39
39
  AgentEvent,
@@ -213,7 +213,7 @@ export class CodexSession implements AgentSession {
213
213
  readonly agentId: string;
214
214
  readonly kind: AgentKind = "codex";
215
215
 
216
- private readonly proc: LineProcess;
216
+ private readonly proc: LineTransport;
217
217
  private readonly handlers = new Set<AgentEventHandler>();
218
218
  private readonly systemPreamble: string;
219
219
  private closed = false;
@@ -231,6 +231,7 @@ export class CodexSession implements AgentSession {
231
231
  private readonly ready: Promise<void>;
232
232
 
233
233
  constructor(args: {
234
+ taskId: string;
234
235
  agent: RosterAgent;
235
236
  containerName: string;
236
237
  systemPreamble: string;
@@ -251,16 +252,22 @@ export class CodexSession implements AgentSession {
251
252
  codexArgs.push("-c", `model_reasoning_effort=${args.agent.effort}`);
252
253
  }
253
254
  codexArgs.push("app-server");
254
- const { command, args: argv } = dockerExecArgs(
255
- args.containerName,
256
- "codex",
257
- codexArgs,
258
- [],
259
- args.agentEnv ?? {},
260
- );
261
- this.proc = new LineProcess({
262
- command,
263
- args: argv,
255
+ // ADR-061: the CLI is owned by an in-container runner (durable across
256
+ // host restarts); legacy pipes behind UAI_DURABLE_SESSIONS=0. Codex
257
+ // re-handshakes per host process (initialize + thread/start live in this
258
+ // object), so a leftover runner is never attached — allowAttach:false
259
+ // stops it and spawns fresh. Its final output before the stop still
260
+ // reaches the cloud via the outbox.
261
+ this.proc = createAgentTransport({
262
+ taskId: args.taskId,
263
+ agentId: this.agentId,
264
+ containerName: args.containerName,
265
+ cli: "codex",
266
+ cliArgs: codexArgs,
267
+ passEnv: [],
268
+ explicitEnv: args.agentEnv ?? {},
269
+ allowAttach: false,
270
+ kind: "codex",
264
271
  debugLabel: `codex:${this.agentId}`,
265
272
  });
266
273
  this.proc.onLine((line) => this.onLine(line));
@@ -538,6 +545,6 @@ register({
538
545
  existsSync(
539
546
  join(process.env.UAI_OWNER_HOME?.trim() || homedir(), ".codex", "auth.json"),
540
547
  ),
541
- create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
542
- new CodexSession({ agent, containerName, systemPreamble, agentEnv }),
548
+ create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
549
+ new CodexSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
543
550
  });
@@ -0,0 +1,306 @@
1
+ /**
2
+ * DurableProcess (ADR-061) — LineProcess's durable twin. Instead of holding
3
+ * the agent CLI's pipes, it talks to the in-container runner
4
+ * (runner/runner.mjs) through two append-only JSONL files on the
5
+ * task-workspace bind mount:
6
+ *
7
+ * writeLine() → append to <sessionDir>/inbox.jsonl
8
+ * onLine() ← poll-tail <sessionDir>/outbox.jsonl from a byte offset
9
+ *
10
+ * The CLI's lifetime is decoupled from this object: dropping it (host
11
+ * restart) leaves the runner and CLI running; `attach()` with the persisted
12
+ * outbox offset resumes exactly where consumption stopped. `close()` is the
13
+ * real teardown — it sends the runner a stop control.
14
+ *
15
+ * Exit is observed via the runner's `{"__uai":"exit"}` meta line, with a
16
+ * stale-heartbeat check as the backstop for a killed container. Meta lines
17
+ * never reach onLine handlers — adapters see the same protocol stream
18
+ * LineProcess gave them.
19
+ */
20
+
21
+ import { spawn } from "node:child_process";
22
+ import {
23
+ closeSync,
24
+ fstatSync,
25
+ mkdirSync,
26
+ openSync,
27
+ promises as fsp,
28
+ readSync,
29
+ statSync,
30
+ } from "node:fs";
31
+ import { join } from "node:path";
32
+ import { fileURLToPath } from "node:url";
33
+
34
+ import type { ExitHandler, LineHandler } from "./proc";
35
+
36
+ const POLL_MS = 75;
37
+ /** Runner beats every 5 s; 25 s of silence without an exit meta = dead. */
38
+ const HEARTBEAT_STALE_MS = 25_000;
39
+ /** After close(), how long to wait for the exit meta before giving up. */
40
+ const CLOSE_GRACE_MS = 8_000;
41
+
42
+ /** Host-side absolute path of the bundled in-container runner script. */
43
+ export function runnerScriptPath(): string {
44
+ return fileURLToPath(new URL("../../runner/runner.mjs", import.meta.url));
45
+ }
46
+
47
+ export interface DurableProcessOptions {
48
+ /** Session dir as seen FROM THE HOST (on the workspace bind mount). */
49
+ hostSessionDir: string;
50
+ /**
51
+ * Command that launches the runner (e.g. `docker exec -d … node
52
+ * /opt/uai/runner.mjs <containerSessionDir> -- claude …`). Omit to ATTACH
53
+ * to a runner that is already alive (boot reconciliation).
54
+ */
55
+ spawnCommand?: { command: string; args: string[] };
56
+ /** Resume consumption from this outbox byte offset (attach path). */
57
+ initialOutboxOffset?: number;
58
+ /** Called after each poll batch whose lines were delivered — persist this
59
+ * offset so a host restart resumes here instead of replaying. */
60
+ onOffsetAdvance?: (offset: number) => void;
61
+ /** Called the moment close() is requested (before the runner has actually
62
+ * died) — lets the owner mark the session unattachable immediately, so a
63
+ * re-create during the teardown grace window spawns fresh instead of
64
+ * attaching to a dying runner. */
65
+ onCloseRequested?: () => void;
66
+ /** Mirrors LineProcess: log raw traffic when UAI_DEBUG_AGENTS is set. */
67
+ debugLabel?: string;
68
+ }
69
+
70
+ interface RunnerMeta {
71
+ __uai: string;
72
+ code?: number | null;
73
+ stderrTail?: string;
74
+ }
75
+
76
+ export class DurableProcess {
77
+ private readonly dir: string;
78
+ private readonly outboxPath: string;
79
+ private readonly inboxPath: string;
80
+ private readonly heartbeatPath: string;
81
+
82
+ private readonly lineHandlers = new Set<LineHandler>();
83
+ private readonly exitHandlers = new Set<ExitHandler>();
84
+
85
+ private offset: number;
86
+ private lineBuf = "";
87
+ private stderrBuf = "";
88
+ private closed = false;
89
+ private detached = false;
90
+ private sawSpawnMeta = false;
91
+ private readonly startedAt = Date.now();
92
+ private poller: ReturnType<typeof setInterval> | null = null;
93
+ private closeTimer: ReturnType<typeof setTimeout> | null = null;
94
+ private inboxChain: Promise<void> = Promise.resolve();
95
+ private readonly onOffsetAdvance: ((offset: number) => void) | null;
96
+ private readonly onCloseRequested: (() => void) | null;
97
+ private readonly debug: string | null;
98
+
99
+ constructor(opts: DurableProcessOptions) {
100
+ this.dir = opts.hostSessionDir;
101
+ this.outboxPath = join(this.dir, "outbox.jsonl");
102
+ this.inboxPath = join(this.dir, "inbox.jsonl");
103
+ this.heartbeatPath = join(this.dir, "heartbeat");
104
+ this.offset = opts.initialOutboxOffset ?? 0;
105
+ this.onOffsetAdvance = opts.onOffsetAdvance ?? null;
106
+ this.onCloseRequested = opts.onCloseRequested ?? null;
107
+ this.debug =
108
+ opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
109
+
110
+ mkdirSync(this.dir, { recursive: true });
111
+
112
+ if (opts.spawnCommand) {
113
+ if (this.debug) {
114
+ this.log(
115
+ `spawn: ${opts.spawnCommand.command} ${opts.spawnCommand.args.join(" ")}`,
116
+ );
117
+ }
118
+ const child = spawn(opts.spawnCommand.command, opts.spawnCommand.args, {
119
+ stdio: ["ignore", "ignore", "pipe"],
120
+ });
121
+ child.stderr?.setEncoding("utf8");
122
+ child.stderr?.on("data", (chunk: string) => {
123
+ this.stderrBuf = (this.stderrBuf + chunk).slice(-8192);
124
+ });
125
+ // `docker exec -d` exits 0 immediately on success; non-zero means the
126
+ // runner never started (bad container, bad mount) — fail loudly now.
127
+ child.on("exit", (code) => {
128
+ if (code !== null && code !== 0) this.finish(null);
129
+ });
130
+ child.on("error", () => this.finish(null));
131
+ child.unref();
132
+ } else {
133
+ this.sawSpawnMeta = true; // attach: the runner pre-exists
134
+ }
135
+
136
+ this.poller = setInterval(() => this.poll(), POLL_MS);
137
+ }
138
+
139
+ private log(msg: string): void {
140
+ console.error(`[uai-agent ${this.debug}] ${msg}`);
141
+ }
142
+
143
+ // ---- outbox tail ---------------------------------------------------------
144
+
145
+ private poll(): void {
146
+ if (this.closed || this.detached) return;
147
+ let fd: number;
148
+ try {
149
+ fd = openSync(this.outboxPath, "r");
150
+ } catch {
151
+ this.checkLiveness();
152
+ return; // outbox not created yet
153
+ }
154
+ const before = this.offset;
155
+ try {
156
+ const size = fstatSync(fd).size;
157
+ while (this.offset < size) {
158
+ const len = Math.min(size - this.offset, 256 * 1024);
159
+ const buf = Buffer.alloc(len);
160
+ const read = readSync(fd, buf, 0, len, this.offset);
161
+ if (read <= 0) break;
162
+ this.offset += read;
163
+ this.lineBuf += buf.toString("utf8", 0, read);
164
+ this.drainLines();
165
+ }
166
+ } finally {
167
+ closeSync(fd);
168
+ }
169
+ if (this.offset !== before && this.onOffsetAdvance && !this.closed) {
170
+ try {
171
+ this.onOffsetAdvance(this.offset);
172
+ } catch {
173
+ // Persistence hiccup — the next batch retries with a larger offset.
174
+ }
175
+ }
176
+ this.checkLiveness();
177
+ }
178
+
179
+ private drainLines(): void {
180
+ let nl: number;
181
+ while ((nl = this.lineBuf.indexOf("\n")) >= 0) {
182
+ const line = this.lineBuf.slice(0, nl).trim();
183
+ this.lineBuf = this.lineBuf.slice(nl + 1);
184
+ if (line.length === 0) continue;
185
+ if (line.startsWith('{"__uai"')) {
186
+ this.handleMeta(line);
187
+ continue;
188
+ }
189
+ if (this.debug) this.log(`<- ${line.slice(0, 1000)}`);
190
+ for (const h of this.lineHandlers) {
191
+ try {
192
+ h(line);
193
+ } catch {
194
+ // A broken handler must not wedge the tail loop.
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ private handleMeta(line: string): void {
201
+ let meta: RunnerMeta;
202
+ try {
203
+ meta = JSON.parse(line) as RunnerMeta;
204
+ } catch {
205
+ return;
206
+ }
207
+ if (meta.__uai === "spawn") {
208
+ this.sawSpawnMeta = true;
209
+ } else if (meta.__uai === "exit") {
210
+ if (typeof meta.stderrTail === "string" && meta.stderrTail) {
211
+ this.stderrBuf = meta.stderrTail.slice(-8192);
212
+ }
213
+ if (this.debug) this.log(`exit meta: code ${meta.code ?? null}`);
214
+ this.finish(typeof meta.code === "number" ? meta.code : null);
215
+ }
216
+ }
217
+
218
+ /** Backstop: no exit meta, but the runner stopped beating → it's gone. */
219
+ private checkLiveness(): void {
220
+ const path = this.sawSpawnMeta ? this.heartbeatPath : null;
221
+ if (!path) {
222
+ // Runner never wrote anything; give the spawn a grace window.
223
+ if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
224
+ return;
225
+ }
226
+ try {
227
+ const age = Date.now() - statSync(path).mtimeMs;
228
+ if (age > HEARTBEAT_STALE_MS) this.finish(null);
229
+ } catch {
230
+ if (Date.now() - this.startedAt > HEARTBEAT_STALE_MS) this.finish(null);
231
+ }
232
+ }
233
+
234
+ private finish(code: number | null): void {
235
+ if (this.closed) return;
236
+ this.closed = true;
237
+ if (this.poller) clearInterval(this.poller);
238
+ if (this.closeTimer) clearTimeout(this.closeTimer);
239
+ for (const h of this.exitHandlers) h(code);
240
+ }
241
+
242
+ // ---- LineProcess-compatible surface ---------------------------------------
243
+
244
+ onLine(handler: LineHandler): void {
245
+ this.lineHandlers.add(handler);
246
+ }
247
+
248
+ onExit(handler: ExitHandler): void {
249
+ this.exitHandlers.add(handler);
250
+ }
251
+
252
+ /** Serialise `value` as one JSONL line appended to the runner's inbox. */
253
+ writeLine(value: unknown): void {
254
+ if (this.closed || this.detached) return;
255
+ const json = JSON.stringify(value);
256
+ if (this.debug) this.log(`-> ${json.slice(0, 1000)}`);
257
+ // Chain appends so concurrent writes can't interleave bytes.
258
+ this.inboxChain = this.inboxChain
259
+ .then(() => fsp.appendFile(this.inboxPath, `${json}\n`))
260
+ .catch(() => {
261
+ // Disk error — liveness checks will surface a dead session.
262
+ });
263
+ }
264
+
265
+ get stderrTail(): string {
266
+ return this.stderrBuf;
267
+ }
268
+
269
+ get isClosed(): boolean {
270
+ return this.closed;
271
+ }
272
+
273
+ /** Consumed-outbox byte offset — persist this to survive host restarts. */
274
+ get outboxOffset(): number {
275
+ return this.offset;
276
+ }
277
+
278
+ /**
279
+ * Stop consuming WITHOUT touching the runner — the host-restart path in
280
+ * miniature (a replaced consumer, a shutting-down host). The CLI lives on;
281
+ * a later attach() resumes from the persisted offset.
282
+ */
283
+ detach(): void {
284
+ if (this.detached || this.closed) return;
285
+ this.detached = true;
286
+ if (this.poller) clearInterval(this.poller);
287
+ if (this.closeTimer) clearTimeout(this.closeTimer);
288
+ }
289
+
290
+ /** Real teardown: ask the runner to stop its CLI, then observe the exit. */
291
+ async close(): Promise<void> {
292
+ if (this.closed || this.detached) return;
293
+ try {
294
+ this.onCloseRequested?.();
295
+ } catch {
296
+ // Bookkeeping only — never blocks the teardown itself.
297
+ }
298
+ try {
299
+ await this.inboxChain;
300
+ await fsp.appendFile(this.inboxPath, '{"__uai":"stop"}\n');
301
+ } catch {
302
+ // Inbox unwritable — fall through to the grace timer.
303
+ }
304
+ this.closeTimer = setTimeout(() => this.finish(null), CLOSE_GRACE_MS);
305
+ }
306
+ }