@runuai/host 0.4.3 → 0.5.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 +18 -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/mcp-connections.ts +77 -45
- package/lib/mcp-gateway.ts +36 -9
- package/lib/orchestrator.ts +35 -9
- package/lib/standard-image.ts +10 -0
- package/package.json +2 -1
- package/runner/runner.mjs +208 -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,
|
|
@@ -193,11 +193,12 @@ export class ClaudeSession implements AgentSession {
|
|
|
193
193
|
readonly agentId: string;
|
|
194
194
|
readonly kind: AgentKind = "claude";
|
|
195
195
|
|
|
196
|
-
private readonly proc:
|
|
196
|
+
private readonly proc: LineTransport;
|
|
197
197
|
private readonly handlers = new Set<AgentEventHandler>();
|
|
198
198
|
private closed = false;
|
|
199
199
|
|
|
200
200
|
constructor(args: {
|
|
201
|
+
taskId: string;
|
|
201
202
|
agent: RosterAgent;
|
|
202
203
|
containerName: string;
|
|
203
204
|
systemPreamble: string;
|
|
@@ -231,16 +232,20 @@ export class ClaudeSession implements AgentSession {
|
|
|
231
232
|
// it needs CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or an
|
|
232
233
|
// API key. These live in the host-agent's env (never the cloud, ADR-015);
|
|
233
234
|
// only ones actually set are forwarded.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
235
|
+
// ADR-061: durable by default — the CLI is owned by an in-container
|
|
236
|
+
// runner and survives host restarts (attach resumes it); legacy pipes
|
|
237
|
+
// behind UAI_DURABLE_SESSIONS=0. Claude is host-side stateless, so a
|
|
238
|
+
// live runner can be re-attached (allowAttach).
|
|
239
|
+
this.proc = createAgentTransport({
|
|
240
|
+
taskId: args.taskId,
|
|
241
|
+
agentId: this.agentId,
|
|
242
|
+
containerName: args.containerName,
|
|
243
|
+
cli: "claude",
|
|
237
244
|
cliArgs,
|
|
238
|
-
["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
239
|
-
args.agentEnv ?? {},
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
command,
|
|
243
|
-
args: argv,
|
|
245
|
+
passEnv: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
|
|
246
|
+
explicitEnv: args.agentEnv ?? {},
|
|
247
|
+
allowAttach: true,
|
|
248
|
+
kind: "claude",
|
|
244
249
|
debugLabel: `claude:${this.agentId}`,
|
|
245
250
|
});
|
|
246
251
|
this.proc.onLine((line) => {
|
|
@@ -349,6 +354,6 @@ register({
|
|
|
349
354
|
process.env.ANTHROPIC_API_KEY ||
|
|
350
355
|
process.env.ANTHROPIC_AUTH_TOKEN,
|
|
351
356
|
),
|
|
352
|
-
create: async ({ agent, containerName, systemPreamble, agentEnv }) =>
|
|
353
|
-
new ClaudeSession({ agent, containerName, systemPreamble, agentEnv }),
|
|
357
|
+
create: async ({ taskId, agent, containerName, systemPreamble, agentEnv }) =>
|
|
358
|
+
new ClaudeSession({ taskId, agent, containerName, systemPreamble, agentEnv }),
|
|
354
359
|
});
|
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
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent transport selection (ADR-061). Adapters call createAgentTransport()
|
|
3
|
+
* where they used to build a LineProcess directly; it returns either:
|
|
4
|
+
*
|
|
5
|
+
* - legacy pipes (`docker exec -i <cli>`, LineProcess) when
|
|
6
|
+
* UAI_DURABLE_SESSIONS=0, or
|
|
7
|
+
* - the durable path: an in-container runner owning the CLI, driven through
|
|
8
|
+
* inbox/outbox files on the workspace mount (DurableProcess).
|
|
9
|
+
*
|
|
10
|
+
* Durable mode makes host restarts transparent WITHOUT orchestrator
|
|
11
|
+
* changes: the orchestrator's lazy respawn calls factory.create as always,
|
|
12
|
+
* and this helper quietly ATTACHES to a still-live runner (fresh heartbeat +
|
|
13
|
+
* a `running` host_agent_sessions row) at the persisted outbox offset
|
|
14
|
+
* instead of spawning a new CLI — the agent's in-memory context survives.
|
|
15
|
+
* Adapters that re-handshake per process (codex) pass allowAttach:false and
|
|
16
|
+
* get a fresh runner each create — the stale predecessor is asked to stop
|
|
17
|
+
* via its inbox first.
|
|
18
|
+
*
|
|
19
|
+
* The runner script is COPIED into each session dir (not bind-mounted):
|
|
20
|
+
* every existing container already sees the workspace at its identical host
|
|
21
|
+
* path, so durable sessions work for containers created before this
|
|
22
|
+
* feature, and each spawn ships the runner version matching this host.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { copyFileSync, mkdirSync, statSync, promises as fsp } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
|
|
28
|
+
import { and, eq } from "drizzle-orm";
|
|
29
|
+
|
|
30
|
+
import { getDb, schema } from "../db";
|
|
31
|
+
import { taskWorkspaceDir } from "../env";
|
|
32
|
+
import { DurableProcess, runnerScriptPath } from "./durable-proc";
|
|
33
|
+
import { LineProcess, dockerExecArgs, type ExitHandler, type LineHandler } from "./proc";
|
|
34
|
+
|
|
35
|
+
/** The shared surface adapters program against (LineProcess's shape). */
|
|
36
|
+
export interface LineTransport {
|
|
37
|
+
onLine(handler: LineHandler): void;
|
|
38
|
+
onExit(handler: ExitHandler): void;
|
|
39
|
+
writeLine(value: unknown): void;
|
|
40
|
+
readonly stderrTail: string;
|
|
41
|
+
readonly isClosed: boolean;
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface AgentTransportOptions {
|
|
46
|
+
taskId: string;
|
|
47
|
+
agentId: string;
|
|
48
|
+
containerName: string;
|
|
49
|
+
/** CLI + args exactly as they'd follow `docker exec -i <container>`. */
|
|
50
|
+
cli: string;
|
|
51
|
+
cliArgs: string[];
|
|
52
|
+
/** Env names forwarded from the host's own env (`-e NAME`). */
|
|
53
|
+
passEnv?: string[];
|
|
54
|
+
/** Explicit per-exec env values (`-e NAME=value`). */
|
|
55
|
+
explicitEnv?: Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Whether a live runner from a previous host process may be attached to.
|
|
58
|
+
* True for stateless adapters (claude); false for ones whose host-side
|
|
59
|
+
* protocol state can't outlive the host process (codex handshake).
|
|
60
|
+
*/
|
|
61
|
+
allowAttach: boolean;
|
|
62
|
+
kind: string;
|
|
63
|
+
debugLabel?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Runner heartbeat is 5 s; older than this = not attachable. */
|
|
67
|
+
const ATTACH_HEARTBEAT_FRESH_MS = 20_000;
|
|
68
|
+
|
|
69
|
+
function durableEnabled(): boolean {
|
|
70
|
+
return process.env.UAI_DURABLE_SESSIONS !== "0";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
|
|
74
|
+
if (!durableEnabled()) {
|
|
75
|
+
const { command, args } = dockerExecArgs(
|
|
76
|
+
opts.containerName,
|
|
77
|
+
opts.cli,
|
|
78
|
+
opts.cliArgs,
|
|
79
|
+
opts.passEnv ?? [],
|
|
80
|
+
opts.explicitEnv ?? {},
|
|
81
|
+
);
|
|
82
|
+
return new LineProcess({ command, args, debugLabel: opts.debugLabel });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const db = getDb();
|
|
86
|
+
const row = db
|
|
87
|
+
.select()
|
|
88
|
+
.from(schema.hostAgentSessions)
|
|
89
|
+
.where(
|
|
90
|
+
and(
|
|
91
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
92
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
.get();
|
|
96
|
+
|
|
97
|
+
const persistOffset = (offset: number): void => {
|
|
98
|
+
db.update(schema.hostAgentSessions)
|
|
99
|
+
.set({ outboxOffset: offset, updatedAt: Date.now() })
|
|
100
|
+
.where(
|
|
101
|
+
and(
|
|
102
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
103
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
.run();
|
|
107
|
+
};
|
|
108
|
+
const markClosed = (): void => {
|
|
109
|
+
db.update(schema.hostAgentSessions)
|
|
110
|
+
.set({ status: "closed", updatedAt: Date.now() })
|
|
111
|
+
.where(
|
|
112
|
+
and(
|
|
113
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
114
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
.run();
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// ---- Attach: a previous host process left this agent's runner alive. ----
|
|
121
|
+
if (opts.allowAttach && row && row.status === "running" && heartbeatFresh(row.sessionDir)) {
|
|
122
|
+
const proc = new DurableProcess({
|
|
123
|
+
hostSessionDir: row.sessionDir,
|
|
124
|
+
initialOutboxOffset: row.outboxOffset,
|
|
125
|
+
onOffsetAdvance: persistOffset,
|
|
126
|
+
onCloseRequested: markClosed,
|
|
127
|
+
debugLabel: opts.debugLabel,
|
|
128
|
+
});
|
|
129
|
+
proc.onExit(markClosed);
|
|
130
|
+
return proc;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---- Spawn a fresh runner, asking any predecessor to stop. --------------
|
|
134
|
+
// Unconditional (not gated on row.status): a runner falsely marked closed
|
|
135
|
+
// by a stale-heartbeat verdict may still be alive, and a stop appended to
|
|
136
|
+
// a dead session's inbox is harmless — this is what guarantees one live
|
|
137
|
+
// CLI per (task, agent).
|
|
138
|
+
if (row) {
|
|
139
|
+
void fsp
|
|
140
|
+
.appendFile(join(row.sessionDir, "inbox.jsonl"), '{"__uai":"stop"}\n')
|
|
141
|
+
.catch(() => {
|
|
142
|
+
// Dir already gone / runner already dead — nothing to stop.
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const sessionDir = join(
|
|
147
|
+
taskWorkspaceDir(opts.taskId),
|
|
148
|
+
".uai",
|
|
149
|
+
"sessions",
|
|
150
|
+
opts.agentId,
|
|
151
|
+
String(Date.now()),
|
|
152
|
+
);
|
|
153
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
154
|
+
// Ship this host's runner with the session: works in containers created
|
|
155
|
+
// before this feature (the workspace is mounted at its host path) and
|
|
156
|
+
// never skews against the host version.
|
|
157
|
+
const runnerInSession = join(sessionDir, "runner.mjs");
|
|
158
|
+
copyFileSync(runnerScriptPath(), runnerInSession);
|
|
159
|
+
|
|
160
|
+
const envArgs: string[] = [];
|
|
161
|
+
for (const name of opts.passEnv ?? []) {
|
|
162
|
+
if (process.env[name]) envArgs.push("-e", name);
|
|
163
|
+
}
|
|
164
|
+
for (const [name, value] of Object.entries(opts.explicitEnv ?? {})) {
|
|
165
|
+
envArgs.push("-e", `${name}=${value}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const proc = new DurableProcess({
|
|
169
|
+
hostSessionDir: sessionDir,
|
|
170
|
+
onOffsetAdvance: persistOffset,
|
|
171
|
+
onCloseRequested: markClosed,
|
|
172
|
+
debugLabel: opts.debugLabel,
|
|
173
|
+
spawnCommand: {
|
|
174
|
+
command: "docker",
|
|
175
|
+
args: [
|
|
176
|
+
"exec",
|
|
177
|
+
"-d",
|
|
178
|
+
...envArgs,
|
|
179
|
+
opts.containerName,
|
|
180
|
+
"node",
|
|
181
|
+
runnerInSession, // identical path in-container (workspace self-mount)
|
|
182
|
+
sessionDir,
|
|
183
|
+
"--",
|
|
184
|
+
opts.cli,
|
|
185
|
+
...opts.cliArgs,
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
db.insert(schema.hostAgentSessions)
|
|
192
|
+
.values({
|
|
193
|
+
taskId: opts.taskId,
|
|
194
|
+
agentId: opts.agentId,
|
|
195
|
+
sessionDir,
|
|
196
|
+
containerName: opts.containerName,
|
|
197
|
+
kind: opts.kind,
|
|
198
|
+
outboxOffset: 0,
|
|
199
|
+
status: "running",
|
|
200
|
+
createdAt: now,
|
|
201
|
+
updatedAt: now,
|
|
202
|
+
})
|
|
203
|
+
.onConflictDoUpdate({
|
|
204
|
+
target: [schema.hostAgentSessions.taskId, schema.hostAgentSessions.agentId],
|
|
205
|
+
set: {
|
|
206
|
+
sessionDir,
|
|
207
|
+
containerName: opts.containerName,
|
|
208
|
+
kind: opts.kind,
|
|
209
|
+
outboxOffset: 0,
|
|
210
|
+
status: "running",
|
|
211
|
+
updatedAt: now,
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
.run();
|
|
215
|
+
|
|
216
|
+
proc.onExit(markClosed);
|
|
217
|
+
return proc;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function heartbeatFresh(sessionDir: string): boolean {
|
|
221
|
+
try {
|
|
222
|
+
return (
|
|
223
|
+
Date.now() - statSync(join(sessionDir, "heartbeat")).mtimeMs <
|
|
224
|
+
ATTACH_HEARTBEAT_FRESH_MS
|
|
225
|
+
);
|
|
226
|
+
} catch {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
}
|
package/lib/mcp-connections.ts
CHANGED
|
@@ -368,31 +368,23 @@ async function oauthComplete(connectionId: string, code: string): Promise<McpAck
|
|
|
368
368
|
throw new Error("connection has no pending OAuth flow");
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
"Basic " +
|
|
387
|
-
Buffer.from(`${row.clientId}:${unpack(row.clientSecretEnc)}`).toString(
|
|
388
|
-
"base64",
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
const res = await timedFetch(row.tokenEndpoint, { method: "POST", headers, body });
|
|
392
|
-
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
393
|
-
if (!res.ok || typeof json.access_token !== "string") {
|
|
371
|
+
const { ok, status, json } = await postTokenEndpoint(
|
|
372
|
+
row.tokenEndpoint,
|
|
373
|
+
{
|
|
374
|
+
grant_type: "authorization_code",
|
|
375
|
+
code,
|
|
376
|
+
redirect_uri: row.redirectUri,
|
|
377
|
+
client_id: row.clientId,
|
|
378
|
+
code_verifier: unpack(row.pkceVerifierEnc),
|
|
379
|
+
// RFC 8707, mirrored from the authorize request.
|
|
380
|
+
resource: row.url,
|
|
381
|
+
},
|
|
382
|
+
row.clientId,
|
|
383
|
+
row.clientSecretEnc ? unpack(row.clientSecretEnc) : undefined,
|
|
384
|
+
);
|
|
385
|
+
if (!ok || typeof json.access_token !== "string") {
|
|
394
386
|
const detail =
|
|
395
|
-
typeof json.error === "string" ? json.error : `status ${
|
|
387
|
+
typeof json.error === "string" ? json.error : `status ${status}`;
|
|
396
388
|
throw new Error(`token exchange failed (${detail})`);
|
|
397
389
|
}
|
|
398
390
|
|
|
@@ -473,6 +465,54 @@ export async function authHeaderFor(
|
|
|
473
465
|
return { name: "authorization", value: `Bearer ${secret.accessToken}` };
|
|
474
466
|
}
|
|
475
467
|
|
|
468
|
+
/**
|
|
469
|
+
* POST a token-endpoint request. A client secret rides HTTP Basic first
|
|
470
|
+
* (RFC 6749's MUST-support method, what DCR vendors expect); on failure it
|
|
471
|
+
* retries once with `client_secret` in the body (client_secret_post) —
|
|
472
|
+
* some manual-creds vendors (Slack's user-token endpoint) only read body
|
|
473
|
+
* credentials and answer Basic with bad_client_secret.
|
|
474
|
+
*/
|
|
475
|
+
async function postTokenEndpoint(
|
|
476
|
+
tokenEndpoint: string,
|
|
477
|
+
params: Record<string, string>,
|
|
478
|
+
clientId: string,
|
|
479
|
+
clientSecret: string | undefined,
|
|
480
|
+
): Promise<{ ok: boolean; status: number; json: Record<string, unknown> }> {
|
|
481
|
+
const attempt = async (mode: "basic" | "post") => {
|
|
482
|
+
const body = new URLSearchParams(params);
|
|
483
|
+
const headers: Record<string, string> = {
|
|
484
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
485
|
+
accept: "application/json",
|
|
486
|
+
};
|
|
487
|
+
if (clientSecret) {
|
|
488
|
+
if (mode === "basic") {
|
|
489
|
+
headers.authorization =
|
|
490
|
+
"Basic " +
|
|
491
|
+
Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
|
|
492
|
+
} else {
|
|
493
|
+
body.set("client_secret", clientSecret);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const res = await timedFetch(tokenEndpoint, {
|
|
497
|
+
method: "POST",
|
|
498
|
+
headers,
|
|
499
|
+
body,
|
|
500
|
+
});
|
|
501
|
+
const json = (await res.json().catch(() => ({}))) as Record<
|
|
502
|
+
string,
|
|
503
|
+
unknown
|
|
504
|
+
>;
|
|
505
|
+
return {
|
|
506
|
+
ok: res.ok && typeof json.access_token === "string",
|
|
507
|
+
status: res.status,
|
|
508
|
+
json,
|
|
509
|
+
};
|
|
510
|
+
};
|
|
511
|
+
const basic = await attempt("basic");
|
|
512
|
+
if (basic.ok || !clientSecret) return basic;
|
|
513
|
+
return attempt("post");
|
|
514
|
+
}
|
|
515
|
+
|
|
476
516
|
async function refreshTokens(
|
|
477
517
|
row: schema.McpConnection,
|
|
478
518
|
secret: OauthSecret,
|
|
@@ -480,27 +520,19 @@ async function refreshTokens(
|
|
|
480
520
|
if (!secret.refreshToken || !row.tokenEndpoint || !row.clientId) {
|
|
481
521
|
throw new Error("access token expired and no refresh token is stored");
|
|
482
522
|
}
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
Buffer.from(`${row.clientId}:${unpack(row.clientSecretEnc)}`).toString(
|
|
497
|
-
"base64",
|
|
498
|
-
);
|
|
499
|
-
}
|
|
500
|
-
const res = await timedFetch(row.tokenEndpoint, { method: "POST", headers, body });
|
|
501
|
-
const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
502
|
-
if (!res.ok || typeof json.access_token !== "string") {
|
|
503
|
-
throw new Error(`token refresh failed (status ${res.status})`);
|
|
523
|
+
const { ok, status, json } = await postTokenEndpoint(
|
|
524
|
+
row.tokenEndpoint,
|
|
525
|
+
{
|
|
526
|
+
grant_type: "refresh_token",
|
|
527
|
+
refresh_token: secret.refreshToken,
|
|
528
|
+
client_id: row.clientId,
|
|
529
|
+
resource: row.url,
|
|
530
|
+
},
|
|
531
|
+
row.clientId,
|
|
532
|
+
row.clientSecretEnc ? unpack(row.clientSecretEnc) : undefined,
|
|
533
|
+
);
|
|
534
|
+
if (!ok || typeof json.access_token !== "string") {
|
|
535
|
+
throw new Error(`token refresh failed (status ${status})`);
|
|
504
536
|
}
|
|
505
537
|
const next: OauthSecret = {
|
|
506
538
|
accessToken: json.access_token,
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
21
21
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
22
|
-
import { Readable } from "node:stream";
|
|
22
|
+
import { Readable, pipeline } from "node:stream";
|
|
23
23
|
import { resolve } from "node:path";
|
|
24
24
|
|
|
25
25
|
import { env } from "./env";
|
|
@@ -31,7 +31,11 @@ const BIND = process.env.UAI_MCP_GATEWAY_BIND ?? "127.0.0.1";
|
|
|
31
31
|
/** MCP request bodies are small JSON-RPC frames; cap so audit parsing (and a
|
|
32
32
|
* hostile container) can't balloon host memory. Responses stream freely. */
|
|
33
33
|
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
34
|
-
|
|
34
|
+
/** Bounds the upstream CONNECT (until response headers) only. The response
|
|
35
|
+
* BODY must never be time-bounded: MCP streamable-HTTP clients hold SSE
|
|
36
|
+
* streams open indefinitely, and an abort mid-pipe once took the whole host
|
|
37
|
+
* process down as an unhandled Readable 'error' (2026-07-13). */
|
|
38
|
+
const UPSTREAM_CONNECT_TIMEOUT_MS = 60_000;
|
|
35
39
|
|
|
36
40
|
export interface TaskMcpConnection {
|
|
37
41
|
id: string;
|
|
@@ -181,15 +185,29 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|
|
181
185
|
if (typeof v === "string") headers[name] = v;
|
|
182
186
|
}
|
|
183
187
|
|
|
188
|
+
// The controller lives for the whole exchange: the timer only guards the
|
|
189
|
+
// connect phase (cleared once headers land), and a client hang-up reaps the
|
|
190
|
+
// upstream so dead streams don't accumulate.
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
res.on("close", () => controller.abort());
|
|
193
|
+
|
|
184
194
|
const attempt = async (forceRefresh: boolean): Promise<Response> => {
|
|
185
195
|
const auth = await authHeaderFor(conn, forceRefresh);
|
|
186
196
|
if (auth) headers[auth.name] = auth.value;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
197
|
+
const connectTimer = setTimeout(
|
|
198
|
+
() => controller.abort(),
|
|
199
|
+
UPSTREAM_CONNECT_TIMEOUT_MS,
|
|
200
|
+
);
|
|
201
|
+
try {
|
|
202
|
+
return await fetch(conn.url, {
|
|
203
|
+
method: req.method,
|
|
204
|
+
headers,
|
|
205
|
+
body: body ?? undefined,
|
|
206
|
+
signal: controller.signal,
|
|
207
|
+
});
|
|
208
|
+
} finally {
|
|
209
|
+
clearTimeout(connectTimer);
|
|
210
|
+
}
|
|
193
211
|
};
|
|
194
212
|
|
|
195
213
|
let upstream: Response;
|
|
@@ -220,8 +238,17 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
|
|
|
220
238
|
}
|
|
221
239
|
res.writeHead(upstream.status, resHeaders);
|
|
222
240
|
if (upstream.body) {
|
|
223
|
-
|
|
241
|
+
// pipeline (NOT .pipe) so a mid-stream error — client gone, upstream
|
|
242
|
+
// reset, abort — tears both ends down instead of crashing the process
|
|
243
|
+
// as an unhandled Readable 'error'.
|
|
244
|
+
pipeline(
|
|
245
|
+
Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]),
|
|
224
246
|
res,
|
|
247
|
+
(err) => {
|
|
248
|
+
if (err && err.name !== "AbortError") {
|
|
249
|
+
audit({ taskId, slug, stream: "ended", error: err.name });
|
|
250
|
+
}
|
|
251
|
+
},
|
|
225
252
|
);
|
|
226
253
|
} else {
|
|
227
254
|
res.end();
|
package/lib/orchestrator.ts
CHANGED
|
@@ -92,6 +92,10 @@ interface Channel {
|
|
|
92
92
|
/** Per-agent respawn counter — bounded so a broken agent can't
|
|
93
93
|
* loop forever rewriting its config. */
|
|
94
94
|
respawns: Map<string, number>;
|
|
95
|
+
/** When each agent last burned a respawn — a budget older than the
|
|
96
|
+
* cooldown resets, so a task self-heals once the underlying failure
|
|
97
|
+
* (missing CLI, broken config) clears instead of needing an operator. */
|
|
98
|
+
respawnLastAt: Map<string, number>;
|
|
95
99
|
/** ADR-049: humans in the chat (from the latest channel spec). */
|
|
96
100
|
humans: ChannelHuman[];
|
|
97
101
|
/** ADR-053: wire the Playwright MCP browser at session start. */
|
|
@@ -109,6 +113,8 @@ interface Channel {
|
|
|
109
113
|
|
|
110
114
|
/** Hard cap on automatic respawns per agent per channel lifetime. */
|
|
111
115
|
const MAX_RESPAWNS_PER_AGENT = 5;
|
|
116
|
+
/** A burned respawn budget resets after this quiet period (see reconcile). */
|
|
117
|
+
const RESPAWN_COOLDOWN_MS = 10 * 60_000;
|
|
112
118
|
|
|
113
119
|
/** Substrings in an agent's error output that mean "config was
|
|
114
120
|
* unlinked between runs" — repair-and-respawn covers the common
|
|
@@ -240,6 +246,7 @@ class Orchestrator {
|
|
|
240
246
|
openTurns: new Set(),
|
|
241
247
|
interrupted: new Set(),
|
|
242
248
|
respawns: new Map(),
|
|
249
|
+
respawnLastAt: new Map(),
|
|
243
250
|
humans: spec.humans ?? [],
|
|
244
251
|
browserTesting: spec.browserTesting === true,
|
|
245
252
|
mcpConnections: spec.mcpConnections ?? [],
|
|
@@ -273,7 +280,14 @@ class Orchestrator {
|
|
|
273
280
|
(ok) => {
|
|
274
281
|
if (!ok) channel.sessionsReady = null;
|
|
275
282
|
},
|
|
276
|
-
() => {
|
|
283
|
+
(err: unknown) => {
|
|
284
|
+
// A start failure retries on the next ensure — but it must be
|
|
285
|
+
// VISIBLE: an unlogged throw here once looped silently every ~2s
|
|
286
|
+
// while a task sat dead with no sessions and no trace.
|
|
287
|
+
console.warn(
|
|
288
|
+
`[orchestrator] ${channel.taskId}: session start failed: ` +
|
|
289
|
+
`${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
|
290
|
+
);
|
|
277
291
|
channel.sessionsReady = null;
|
|
278
292
|
},
|
|
279
293
|
);
|
|
@@ -306,14 +320,23 @@ class Orchestrator {
|
|
|
306
320
|
|
|
307
321
|
/** Spawn sessions for roster agents added after the initial start. */
|
|
308
322
|
private async reconcileSessions(channel: Channel): Promise<void> {
|
|
309
|
-
const missing = channel.roster.filter(
|
|
310
|
-
(agent)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
323
|
+
const missing = channel.roster.filter((agent) => {
|
|
324
|
+
if (channel.sessions.has(agent.id) || channel.spawning.has(agent.id)) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
// Crash-loop budget: an agent whose session keeps dying stops being
|
|
328
|
+
// respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment) —
|
|
329
|
+
// but a budget that has been cold for RESPAWN_COOLDOWN_MS resets, so
|
|
330
|
+
// the task heals itself once the cause (a missing CLI on the shared
|
|
331
|
+
// volume, a broken config) is fixed, instead of staying dead until a
|
|
332
|
+
// host restart.
|
|
333
|
+
if ((channel.respawns.get(agent.id) ?? 0) > MAX_RESPAWNS_PER_AGENT) {
|
|
334
|
+
const lastAt = channel.respawnLastAt.get(agent.id) ?? 0;
|
|
335
|
+
if (Date.now() - lastAt < RESPAWN_COOLDOWN_MS) return false;
|
|
336
|
+
channel.respawns.set(agent.id, 0);
|
|
337
|
+
}
|
|
338
|
+
return true;
|
|
339
|
+
});
|
|
317
340
|
if (missing.length === 0) return;
|
|
318
341
|
|
|
319
342
|
const task = getHostTask(channel.taskId);
|
|
@@ -613,6 +636,7 @@ class Orchestrator {
|
|
|
613
636
|
// after a double SIGKILL). Bounded by the respawn budget, checked in
|
|
614
637
|
// reconcileSessions.
|
|
615
638
|
channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
|
|
639
|
+
channel.respawnLastAt.set(agentId, Date.now());
|
|
616
640
|
channel.sessions.delete(agentId);
|
|
617
641
|
break;
|
|
618
642
|
}
|
|
@@ -635,6 +659,7 @@ class Orchestrator {
|
|
|
635
659
|
// Same zombie hazard as the error path — a session whose process
|
|
636
660
|
// ended (even cleanly) can never carry another turn.
|
|
637
661
|
channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
|
|
662
|
+
channel.respawnLastAt.set(agentId, Date.now());
|
|
638
663
|
channel.sessions.delete(agentId);
|
|
639
664
|
break;
|
|
640
665
|
}
|
|
@@ -653,6 +678,7 @@ class Orchestrator {
|
|
|
653
678
|
): Promise<void> {
|
|
654
679
|
const tries = (channel.respawns.get(agentId) ?? 0) + 1;
|
|
655
680
|
channel.respawns.set(agentId, tries);
|
|
681
|
+
channel.respawnLastAt.set(agentId, Date.now());
|
|
656
682
|
|
|
657
683
|
if (tries > MAX_RESPAWNS_PER_AGENT) {
|
|
658
684
|
this.emitHost({
|
package/lib/standard-image.ts
CHANGED
|
@@ -238,6 +238,12 @@ async function upgradeVolumeAgentClis(): Promise<void> {
|
|
|
238
238
|
const upgrade = await run("docker", [
|
|
239
239
|
"run",
|
|
240
240
|
"--rm",
|
|
241
|
+
// The container NAME is the mutex: overlapping boots (or a crash-looping
|
|
242
|
+
// service) must never race two npm installs on the shared volume — that
|
|
243
|
+
// once left it with no `claude` at all (2026-07-13). Docker rejects the
|
|
244
|
+
// duplicate name; we treat that as "already upgrading, skip".
|
|
245
|
+
"--name",
|
|
246
|
+
"uai-cli-upgrade",
|
|
241
247
|
"-u",
|
|
242
248
|
"root",
|
|
243
249
|
"-w",
|
|
@@ -250,6 +256,10 @@ async function upgradeVolumeAgentClis(): Promise<void> {
|
|
|
250
256
|
`npm install -g ${pkgs} >/dev/null 2>&1 || { rm -rf ${scopeDirs}; npm install -g ${pkgs} >/dev/null 2>&1; } && asdf reshim nodejs >/dev/null 2>&1; ` +
|
|
251
257
|
bins.map((b) => `printf '%s ' "$(${b} --version 2>/dev/null | head -1)"`).join("; "),
|
|
252
258
|
]);
|
|
259
|
+
if (upgrade.code !== 0 && /already in use/i.test(upgrade.stderr)) {
|
|
260
|
+
console.log("[host-agent] agent CLI upgrade already running — skipped");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
253
263
|
if (upgrade.code === 0) {
|
|
254
264
|
console.log(
|
|
255
265
|
`[host-agent] agent CLIs current on ${ASDF_DATA_VOLUME}: ${upgrade.stdout.trim()}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@runuai/host",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Diogo Perillo <diogo.perillo@gmail.com>",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"db",
|
|
41
41
|
"scripts/agent",
|
|
42
42
|
"scripts/install",
|
|
43
|
+
"runner",
|
|
43
44
|
"images/standard",
|
|
44
45
|
"ui",
|
|
45
46
|
"README.md",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* uai session runner (ADR-061) — runs INSIDE the task container and owns an
|
|
4
|
+
* agent CLI process, so the CLI's lifetime is decoupled from the host
|
|
5
|
+
* process. The host talks to it only through files on the task-workspace
|
|
6
|
+
* bind mount (no sockets/FIFOs — they don't cross the macOS↔VM boundary):
|
|
7
|
+
*
|
|
8
|
+
* <sessionDir>/inbox.jsonl host appends; runner tails → CLI stdin
|
|
9
|
+
* <sessionDir>/outbox.jsonl CLI stdout lines verbatim + __uai meta lines
|
|
10
|
+
* <sessionDir>/heartbeat rewritten every HEARTBEAT_MS (host checks mtime)
|
|
11
|
+
* <sessionDir>/runner.json pid, protocol, argv, startedAt
|
|
12
|
+
*
|
|
13
|
+
* Meta lines are `{"__uai":"spawn"|"exit", ...}`; the host filters them out
|
|
14
|
+
* before handing lines to the protocol adapters. Control lines the host
|
|
15
|
+
* appends to the inbox use the same shape (`{"__uai":"stop"}`); everything
|
|
16
|
+
* else in the inbox goes to the CLI's stdin untouched.
|
|
17
|
+
*
|
|
18
|
+
* Plain Node ≥18, dependency-free, ESM. Testable outside docker: point it
|
|
19
|
+
* at a tmp dir and any line-oriented fake CLI.
|
|
20
|
+
*
|
|
21
|
+
* Usage: node runner.mjs <sessionDir> -- <command> [args...]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { appendFileSync, mkdirSync, openSync, readSync, fstatSync, closeSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import process from "node:process";
|
|
28
|
+
|
|
29
|
+
const PROTOCOL = 1;
|
|
30
|
+
const POLL_MS = 50;
|
|
31
|
+
const HEARTBEAT_MS = 5_000;
|
|
32
|
+
const STDERR_CAP = 8 * 1024;
|
|
33
|
+
const STOP_GRACE_MS = 5_000;
|
|
34
|
+
|
|
35
|
+
// ---- argv ------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const sep = process.argv.indexOf("--");
|
|
38
|
+
const sessionDir = process.argv[2];
|
|
39
|
+
if (!sessionDir || sep < 0 || sep + 1 >= process.argv.length) {
|
|
40
|
+
process.stderr.write("usage: runner.mjs <sessionDir> -- <command> [args...]\n");
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
const command = process.argv[sep + 1];
|
|
44
|
+
const args = process.argv.slice(sep + 2);
|
|
45
|
+
|
|
46
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
47
|
+
const inboxPath = join(sessionDir, "inbox.jsonl");
|
|
48
|
+
const outboxPath = join(sessionDir, "outbox.jsonl");
|
|
49
|
+
const heartbeatPath = join(sessionDir, "heartbeat");
|
|
50
|
+
const runnerJsonPath = join(sessionDir, "runner.json");
|
|
51
|
+
|
|
52
|
+
// Single append point — appendFileSync with O_APPEND keeps lines atomic for
|
|
53
|
+
// the sizes we write; the runner is the outbox's only writer.
|
|
54
|
+
function outbox(line) {
|
|
55
|
+
appendFileSync(outboxPath, line.endsWith("\n") ? line : `${line}\n`);
|
|
56
|
+
}
|
|
57
|
+
function meta(kind, extra = {}) {
|
|
58
|
+
outbox(JSON.stringify({ __uai: kind, ts: Date.now(), ...extra }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- the CLI ---------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// The runner is launched through the container's asdf `node` shim, which
|
|
64
|
+
// exports its resolved version (ASDF_NODEJS_VERSION et al.) into our env.
|
|
65
|
+
// Passing that through would pin the CLI's own asdf shim to the WORKSPACE's
|
|
66
|
+
// node version — "No claude executable found for nodejs X" when the agent
|
|
67
|
+
// CLIs are installed under a different one. Strip ASDF_* so the CLI shim
|
|
68
|
+
// resolves exactly as a direct `docker exec <cli>` would.
|
|
69
|
+
const cliEnv = { ...process.env };
|
|
70
|
+
for (const key of Object.keys(cliEnv)) {
|
|
71
|
+
if (key.startsWith("ASDF_")) delete cliEnv[key];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: cliEnv });
|
|
75
|
+
|
|
76
|
+
writeFileSync(
|
|
77
|
+
runnerJsonPath,
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
protocol: PROTOCOL,
|
|
80
|
+
runnerPid: process.pid,
|
|
81
|
+
cliPid: child.pid ?? null,
|
|
82
|
+
command,
|
|
83
|
+
args,
|
|
84
|
+
startedAt: Date.now(),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
meta("spawn", { protocol: PROTOCOL, runnerPid: process.pid, cliPid: child.pid ?? null });
|
|
88
|
+
|
|
89
|
+
let stderrTail = "";
|
|
90
|
+
child.stderr?.setEncoding("utf8");
|
|
91
|
+
child.stderr?.on("data", (chunk) => {
|
|
92
|
+
stderrTail = (stderrTail + chunk).slice(-STDERR_CAP);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// CLI stdout → outbox, complete lines only (partial line buffered).
|
|
96
|
+
let stdoutBuf = "";
|
|
97
|
+
child.stdout?.setEncoding("utf8");
|
|
98
|
+
child.stdout?.on("data", (chunk) => {
|
|
99
|
+
stdoutBuf += chunk;
|
|
100
|
+
for (;;) {
|
|
101
|
+
const nl = stdoutBuf.indexOf("\n");
|
|
102
|
+
if (nl < 0) break;
|
|
103
|
+
const line = stdoutBuf.slice(0, nl);
|
|
104
|
+
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
105
|
+
if (line.trim().length > 0) outbox(line);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
let exiting = false;
|
|
110
|
+
child.on("exit", (code, signal) => {
|
|
111
|
+
if (exiting) return;
|
|
112
|
+
exiting = true;
|
|
113
|
+
if (stdoutBuf.trim().length > 0) outbox(stdoutBuf); // flush the partial tail
|
|
114
|
+
meta("exit", { code, signal: signal ?? null, stderrTail });
|
|
115
|
+
process.exit(0);
|
|
116
|
+
});
|
|
117
|
+
child.on("error", (err) => {
|
|
118
|
+
if (exiting) return;
|
|
119
|
+
exiting = true;
|
|
120
|
+
meta("exit", { code: null, signal: null, stderrTail: String(err?.message ?? err) });
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---- inbox tail → CLI stdin --------------------------------------------------
|
|
125
|
+
|
|
126
|
+
let inboxOffset = 0; // fresh session dir per spawn — always start at 0
|
|
127
|
+
let inboxBuf = "";
|
|
128
|
+
|
|
129
|
+
function stopCli() {
|
|
130
|
+
if (exiting) return;
|
|
131
|
+
try {
|
|
132
|
+
child.kill("SIGTERM");
|
|
133
|
+
} catch {
|
|
134
|
+
/* already gone */
|
|
135
|
+
}
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
try {
|
|
138
|
+
child.kill("SIGKILL");
|
|
139
|
+
} catch {
|
|
140
|
+
/* already gone */
|
|
141
|
+
}
|
|
142
|
+
}, STOP_GRACE_MS).unref();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleInboxLine(line) {
|
|
146
|
+
if (line.trim().length === 0) return;
|
|
147
|
+
if (line.startsWith('{"__uai"')) {
|
|
148
|
+
try {
|
|
149
|
+
const ctl = JSON.parse(line);
|
|
150
|
+
if (ctl.__uai === "stop") stopCli();
|
|
151
|
+
} catch {
|
|
152
|
+
/* malformed control — ignore */
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
child.stdin?.write(`${line}\n`);
|
|
158
|
+
} catch {
|
|
159
|
+
/* EPIPE after CLI death — the exit meta already tells the host */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pollInbox() {
|
|
164
|
+
let fd;
|
|
165
|
+
try {
|
|
166
|
+
fd = openSync(inboxPath, "r");
|
|
167
|
+
} catch {
|
|
168
|
+
return; // inbox not created yet
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const size = fstatSync(fd).size;
|
|
172
|
+
if (size > inboxOffset) {
|
|
173
|
+
const len = size - inboxOffset;
|
|
174
|
+
const buf = Buffer.alloc(len);
|
|
175
|
+
const read = readSync(fd, buf, 0, len, inboxOffset);
|
|
176
|
+
inboxOffset += read;
|
|
177
|
+
inboxBuf += buf.toString("utf8", 0, read);
|
|
178
|
+
for (;;) {
|
|
179
|
+
const nl = inboxBuf.indexOf("\n");
|
|
180
|
+
if (nl < 0) break;
|
|
181
|
+
const line = inboxBuf.slice(0, nl);
|
|
182
|
+
inboxBuf = inboxBuf.slice(nl + 1);
|
|
183
|
+
handleInboxLine(line);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
closeSync(fd);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
setInterval(pollInbox, POLL_MS);
|
|
192
|
+
|
|
193
|
+
// ---- heartbeat ---------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
function beat() {
|
|
196
|
+
try {
|
|
197
|
+
writeFileSync(heartbeatPath, `${process.pid} ${Date.now()}\n`);
|
|
198
|
+
} catch {
|
|
199
|
+
/* disk hiccup — next beat retries */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
beat();
|
|
203
|
+
setInterval(beat, HEARTBEAT_MS);
|
|
204
|
+
|
|
205
|
+
// The runner dies only with its CLI (or on stop/SIGTERM) — never because a
|
|
206
|
+
// host-side consumer went away. That is the entire point (ADR-061).
|
|
207
|
+
process.on("SIGTERM", stopCli);
|
|
208
|
+
process.on("SIGINT", stopCli);
|