@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.
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +33 -0
- package/lib/agents/claude.ts +28 -13
- package/lib/agents/codex.ts +21 -14
- package/lib/agents/durable-proc.ts +306 -0
- package/lib/agents/transport.ts +229 -0
- package/lib/command-db.ts +10 -2
- package/lib/mcp-connections.ts +77 -45
- package/lib/mcp-gateway.ts +42 -12
- package/lib/orchestrator.ts +103 -9
- package/lib/shared-files.ts +156 -0
- package/lib/standard-image.ts +10 -0
- package/package.json +2 -1
- package/runner/runner.mjs +208 -0
- package/scripts/agent/task-up.sh +36 -0
- package/src/index.ts +10 -0
- package/src/main.ts +51 -0
- package/src/protocol.ts +47 -0
|
@@ -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
|
+
);
|
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(),
|
package/lib/agents/claude.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { newId } from "../ulid";
|
|
24
|
-
import {
|
|
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:
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
});
|
package/lib/agents/codex.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
+
}
|