@runuai/host 0.9.71 → 0.9.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/agent-cli.ts +36 -0
- package/lib/agents/machine-durable.ts +507 -0
- package/lib/agents/transport.ts +184 -9
- package/lib/machine-exec.ts +14 -6
- package/lib/orchestrator.ts +49 -4
- package/lib/runtime-state.ts +6 -0
- package/lib/task-environment/index.ts +9 -1
- package/lib/task-environment/legacy-adoption.ts +6 -0
- package/lib/task-environment/machine-task-up.ts +229 -0
- package/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/main.ts +20 -0
package/lib/agent-cli.ts
CHANGED
|
@@ -130,6 +130,42 @@ export function writeAgentCli(
|
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* ADR-121: the same CLI materialization, but through the environment's own
|
|
135
|
+
* transport — machine workspaces are not host-reachable via taskWorkspaceDir.
|
|
136
|
+
* Same no-token invariant: uai.json carries the apiUrl only.
|
|
137
|
+
*/
|
|
138
|
+
export async function writeAgentCliViaEnvironment(
|
|
139
|
+
environment: {
|
|
140
|
+
readonly descriptor: { workspacePath: string };
|
|
141
|
+
writeWorkspaceFile(path: string, bytes: Uint8Array): Promise<void>;
|
|
142
|
+
},
|
|
143
|
+
roster: RosterAgent[],
|
|
144
|
+
apiUrl: string | null,
|
|
145
|
+
allowPermissionless = false,
|
|
146
|
+
): Promise<boolean> {
|
|
147
|
+
if (
|
|
148
|
+
!apiUrl ||
|
|
149
|
+
(rosterPermissions(roster).length === 0 && !allowPermissionless)
|
|
150
|
+
) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const root = environment.descriptor.workspacePath;
|
|
155
|
+
await environment.writeWorkspaceFile(
|
|
156
|
+
`${root}/.uai/uai.json`,
|
|
157
|
+
Buffer.from(`${JSON.stringify({ apiUrl }, null, 2)}\n`),
|
|
158
|
+
);
|
|
159
|
+
await environment.writeWorkspaceFile(
|
|
160
|
+
`${root}/.uai/cli.mjs`,
|
|
161
|
+
Buffer.from(CLI_SOURCE),
|
|
162
|
+
);
|
|
163
|
+
return true;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
133
169
|
/**
|
|
134
170
|
* Per-agent env for the `docker exec` that spawns one agent (ADR-048): its OWN
|
|
135
171
|
* task token (carrying only ITS permissions) + the API url. Empty when the agent
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121 + ADR-061: durable agent sessions for MACHINE-backed tasks.
|
|
3
|
+
*
|
|
4
|
+
* The container path's DurableProcess polls the session files on a host
|
|
5
|
+
* bind mount — a machine shares no filesystem with the host, so this
|
|
6
|
+
* variant speaks the same runner protocol over the environment transport:
|
|
7
|
+
*
|
|
8
|
+
* outbox one long-lived `tail -c +<offset+1> -F` streamed over ssh;
|
|
9
|
+
* byte offset = initial + bytes received (append-only file).
|
|
10
|
+
* A dropped ssh stream is respawned from the current offset.
|
|
11
|
+
* inbox chained one-shot appends (`cat >> inbox.jsonl` with stdin).
|
|
12
|
+
* liveness periodic heartbeat mtime probe via exec; the runner beats
|
|
13
|
+
* every 5s, so >25s of silence without an exit meta = dead.
|
|
14
|
+
*
|
|
15
|
+
* The runner itself (runner.mjs) is dependency-free Node and runs on the
|
|
16
|
+
* machine unchanged; it is delivered per-session via writeWorkspaceFile so
|
|
17
|
+
* it can never skew against this host's version.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { posix } from "node:path";
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
TaskEnvironmentHandle,
|
|
25
|
+
TaskEnvironmentProcess,
|
|
26
|
+
} from "../task-environment/types";
|
|
27
|
+
import { runnerScriptPath } from "./durable-proc";
|
|
28
|
+
import type { ExitHandler, LineHandler } from "./proc";
|
|
29
|
+
import type { LineTransport } from "./transport";
|
|
30
|
+
|
|
31
|
+
/** Runner beats every 5 s; 25 s of silence without an exit meta = dead. */
|
|
32
|
+
const HEARTBEAT_STALE_MS = 25_000;
|
|
33
|
+
const HEARTBEAT_PROBE_MS = 10_000;
|
|
34
|
+
const CLOSE_GRACE_MS = 8_000;
|
|
35
|
+
const STEP_TIMEOUT_MS = 30_000;
|
|
36
|
+
const STEP_OUTPUT_BYTES = 64 * 1024;
|
|
37
|
+
const TAIL_RESPAWN_DELAY_MS = 1_000;
|
|
38
|
+
|
|
39
|
+
interface RunnerMeta {
|
|
40
|
+
__uai: string;
|
|
41
|
+
code?: number | null;
|
|
42
|
+
stderrTail?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The handle ops this transport needs beyond the session surface. */
|
|
46
|
+
export type MachineSessionEnvironment = Pick<
|
|
47
|
+
TaskEnvironmentHandle<unknown>,
|
|
48
|
+
| "descriptor"
|
|
49
|
+
| "exec"
|
|
50
|
+
| "spawnSession"
|
|
51
|
+
| "writeWorkspaceFile"
|
|
52
|
+
| "launchDetachedSession"
|
|
53
|
+
>;
|
|
54
|
+
|
|
55
|
+
async function execStep(
|
|
56
|
+
environment: MachineSessionEnvironment,
|
|
57
|
+
argv: readonly [string, ...string[]],
|
|
58
|
+
stdin?: Uint8Array,
|
|
59
|
+
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
|
|
60
|
+
const result = await environment.exec({
|
|
61
|
+
argv,
|
|
62
|
+
...(stdin ? { stdin } : {}),
|
|
63
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
64
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
exitCode: result.exitCode,
|
|
68
|
+
stdout: Buffer.from(result.stdout).toString("utf8"),
|
|
69
|
+
stderr: Buffer.from(result.stderr).toString("utf8"),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Append bytes to an environment file (creating parents), via exec stdin. */
|
|
74
|
+
export async function appendEnvironmentFile(
|
|
75
|
+
environment: MachineSessionEnvironment,
|
|
76
|
+
path: string,
|
|
77
|
+
bytes: string,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
const result = await execStep(
|
|
80
|
+
environment,
|
|
81
|
+
[
|
|
82
|
+
"/bin/sh",
|
|
83
|
+
"-c",
|
|
84
|
+
'mkdir -p "$(dirname "$1")" && cat >> "$1"',
|
|
85
|
+
"append",
|
|
86
|
+
path,
|
|
87
|
+
],
|
|
88
|
+
Buffer.from(bytes),
|
|
89
|
+
);
|
|
90
|
+
if (result.exitCode !== 0) {
|
|
91
|
+
throw new Error(`environment append to ${path} exited ${result.exitCode}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Heartbeat freshness of an environment-side session dir (attach check). */
|
|
96
|
+
export async function machineHeartbeatFresh(
|
|
97
|
+
environment: MachineSessionEnvironment,
|
|
98
|
+
sessionDir: string,
|
|
99
|
+
freshMs: number,
|
|
100
|
+
): Promise<boolean> {
|
|
101
|
+
try {
|
|
102
|
+
const result = await execStep(environment, [
|
|
103
|
+
"/bin/sh",
|
|
104
|
+
"-c",
|
|
105
|
+
'stat -c %Y "$1/heartbeat"',
|
|
106
|
+
"stat",
|
|
107
|
+
sessionDir,
|
|
108
|
+
]);
|
|
109
|
+
if (result.exitCode !== 0) return false;
|
|
110
|
+
const mtimeSeconds = Number(result.stdout.trim());
|
|
111
|
+
if (!Number.isFinite(mtimeSeconds)) return false;
|
|
112
|
+
return Date.now() - mtimeSeconds * 1000 < freshMs;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface MachineDurableOptions {
|
|
119
|
+
environment: MachineSessionEnvironment;
|
|
120
|
+
/** Environment-absolute session dir (inside the workspace). */
|
|
121
|
+
sessionDir: string;
|
|
122
|
+
/** Resume consumption from this outbox byte offset (attach path). */
|
|
123
|
+
initialOutboxOffset?: number;
|
|
124
|
+
onOffsetAdvance?: (offset: number) => void;
|
|
125
|
+
onCloseRequested?: () => void;
|
|
126
|
+
/** Provider-owned detached launch. Omit to attach to a live runner. */
|
|
127
|
+
launch?: () => Promise<{ exitCode: number | null; stderr: Uint8Array }>;
|
|
128
|
+
debugLabel?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export class MachineDurableProcess implements LineTransport {
|
|
132
|
+
readonly #environment: MachineSessionEnvironment;
|
|
133
|
+
readonly #sessionDir: string;
|
|
134
|
+
readonly #outboxPath: string;
|
|
135
|
+
readonly #inboxPath: string;
|
|
136
|
+
|
|
137
|
+
readonly #lineHandlers = new Set<LineHandler>();
|
|
138
|
+
readonly #exitHandlers = new Set<ExitHandler>();
|
|
139
|
+
|
|
140
|
+
#offset: number;
|
|
141
|
+
#lineBuf = "";
|
|
142
|
+
#stderrBuf = "";
|
|
143
|
+
#closed = false;
|
|
144
|
+
#detached = false;
|
|
145
|
+
#sawSpawnMeta = false;
|
|
146
|
+
readonly #startedAt = Date.now();
|
|
147
|
+
#tail: TaskEnvironmentProcess | null = null;
|
|
148
|
+
#tailGeneration = 0;
|
|
149
|
+
#heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
150
|
+
#closeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
151
|
+
#respawnTimer: ReturnType<typeof setTimeout> | null = null;
|
|
152
|
+
#probing = false;
|
|
153
|
+
#inboxChain: Promise<void> = Promise.resolve();
|
|
154
|
+
readonly #onOffsetAdvance: ((offset: number) => void) | null;
|
|
155
|
+
readonly #onCloseRequested: (() => void) | null;
|
|
156
|
+
readonly #debug: string | null;
|
|
157
|
+
|
|
158
|
+
constructor(opts: MachineDurableOptions) {
|
|
159
|
+
this.#environment = opts.environment;
|
|
160
|
+
this.#sessionDir = opts.sessionDir;
|
|
161
|
+
this.#outboxPath = posix.join(opts.sessionDir, "outbox.jsonl");
|
|
162
|
+
this.#inboxPath = posix.join(opts.sessionDir, "inbox.jsonl");
|
|
163
|
+
this.#offset = opts.initialOutboxOffset ?? 0;
|
|
164
|
+
this.#onOffsetAdvance = opts.onOffsetAdvance ?? null;
|
|
165
|
+
this.#onCloseRequested = opts.onCloseRequested ?? null;
|
|
166
|
+
this.#debug =
|
|
167
|
+
opts.debugLabel && process.env.UAI_DEBUG_AGENTS ? opts.debugLabel : null;
|
|
168
|
+
|
|
169
|
+
if (opts.launch) {
|
|
170
|
+
void opts.launch().then(
|
|
171
|
+
(result) => {
|
|
172
|
+
const stderr = Buffer.from(result.stderr).toString("utf8");
|
|
173
|
+
if (stderr) this.#stderrBuf = (this.#stderrBuf + stderr).slice(-8192);
|
|
174
|
+
if (result.exitCode !== 0) {
|
|
175
|
+
this.#finish(null, `launch exited ${result.exitCode}`);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
(error: unknown) => {
|
|
179
|
+
this.#stderrBuf = (
|
|
180
|
+
this.#stderrBuf +
|
|
181
|
+
(error instanceof Error ? error.message : String(error))
|
|
182
|
+
).slice(-8192);
|
|
183
|
+
this.#finish(null, "launch threw");
|
|
184
|
+
},
|
|
185
|
+
);
|
|
186
|
+
} else {
|
|
187
|
+
this.#sawSpawnMeta = true; // attach: the runner pre-exists
|
|
188
|
+
// The container path pre-checks heartbeat freshness with a sync stat;
|
|
189
|
+
// over ssh that check is async, so attach optimistically and probe
|
|
190
|
+
// NOW — a dead runner finishes within a round-trip instead of a
|
|
191
|
+
// full probe interval, and the orchestrator respawns fresh.
|
|
192
|
+
void this.#probeLiveness();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
void this.#spawnTail();
|
|
196
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
197
|
+
void this.#probeLiveness();
|
|
198
|
+
}, HEARTBEAT_PROBE_MS);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#log(message: string): void {
|
|
202
|
+
console.error(`[uai-agent ${this.#debug}] ${message}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---- outbox tail over ssh ------------------------------------------------
|
|
206
|
+
|
|
207
|
+
async #spawnTail(): Promise<void> {
|
|
208
|
+
if (this.#closed || this.#detached) return;
|
|
209
|
+
const generation = ++this.#tailGeneration;
|
|
210
|
+
let tail: TaskEnvironmentProcess;
|
|
211
|
+
try {
|
|
212
|
+
// spawnSession, deliberately: a session request carries NO timeoutMs,
|
|
213
|
+
// and the streaming runner only arms its kill timer when one exists —
|
|
214
|
+
// the tail must live as long as the runner does.
|
|
215
|
+
tail = await this.#environment.spawnSession({
|
|
216
|
+
// -c +N is 1-based (start AT byte N): resume just past the consumed
|
|
217
|
+
// prefix. -F survives the outbox not existing yet; its chatter goes
|
|
218
|
+
// to stderr, never the byte stream.
|
|
219
|
+
argv: [
|
|
220
|
+
"/bin/sh",
|
|
221
|
+
"-c",
|
|
222
|
+
'exec tail -c +"$1" -F "$2" 2>/dev/null',
|
|
223
|
+
"tail",
|
|
224
|
+
String(this.#offset + 1),
|
|
225
|
+
this.#outboxPath,
|
|
226
|
+
],
|
|
227
|
+
maxOutputBytes: 8 * 1024 * 1024,
|
|
228
|
+
});
|
|
229
|
+
} catch {
|
|
230
|
+
this.#scheduleTailRespawn(generation);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (this.#closed || this.#detached || generation !== this.#tailGeneration) {
|
|
234
|
+
await tail.terminate().catch(() => {});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
this.#tail = tail;
|
|
238
|
+
void (async () => {
|
|
239
|
+
try {
|
|
240
|
+
for await (const chunk of tail.stdout) {
|
|
241
|
+
if (this.#closed || this.#detached) break;
|
|
242
|
+
if (generation !== this.#tailGeneration) break;
|
|
243
|
+
const text = Buffer.from(chunk);
|
|
244
|
+
this.#offset += text.byteLength;
|
|
245
|
+
this.#lineBuf += text.toString("utf8");
|
|
246
|
+
this.#drainLines();
|
|
247
|
+
if (this.#onOffsetAdvance && !this.#closed) {
|
|
248
|
+
try {
|
|
249
|
+
this.#onOffsetAdvance(this.#offset);
|
|
250
|
+
} catch {
|
|
251
|
+
// Persistence hiccup — the next batch retries larger.
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
// Stream error — the respawn below re-establishes the tail.
|
|
257
|
+
}
|
|
258
|
+
// The ssh stream ended (drop, machine restart, terminate). If the
|
|
259
|
+
// session is still live, come back from the current offset.
|
|
260
|
+
if (!this.#closed && !this.#detached && generation === this.#tailGeneration) {
|
|
261
|
+
this.#scheduleTailRespawn(generation);
|
|
262
|
+
}
|
|
263
|
+
})();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
#scheduleTailRespawn(generation: number): void {
|
|
267
|
+
if (this.#closed || this.#detached) return;
|
|
268
|
+
if (generation !== this.#tailGeneration) return;
|
|
269
|
+
if (this.#respawnTimer) return;
|
|
270
|
+
this.#respawnTimer = setTimeout(() => {
|
|
271
|
+
this.#respawnTimer = null;
|
|
272
|
+
void this.#spawnTail();
|
|
273
|
+
}, TAIL_RESPAWN_DELAY_MS);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#drainLines(): void {
|
|
277
|
+
let nl: number;
|
|
278
|
+
while ((nl = this.#lineBuf.indexOf("\n")) >= 0) {
|
|
279
|
+
const line = this.#lineBuf.slice(0, nl).trim();
|
|
280
|
+
this.#lineBuf = this.#lineBuf.slice(nl + 1);
|
|
281
|
+
if (line.length === 0) continue;
|
|
282
|
+
if (line.startsWith('{"__uai"')) {
|
|
283
|
+
this.#handleMeta(line);
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (this.#debug) this.#log(`<- ${line.slice(0, 1000)}`);
|
|
287
|
+
for (const handler of this.#lineHandlers) {
|
|
288
|
+
try {
|
|
289
|
+
handler(line);
|
|
290
|
+
} catch {
|
|
291
|
+
// A broken handler must not wedge the tail loop.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#handleMeta(line: string): void {
|
|
298
|
+
let meta: RunnerMeta;
|
|
299
|
+
try {
|
|
300
|
+
meta = JSON.parse(line) as RunnerMeta;
|
|
301
|
+
} catch {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (meta.__uai === "spawn") {
|
|
305
|
+
this.#sawSpawnMeta = true;
|
|
306
|
+
} else if (meta.__uai === "exit") {
|
|
307
|
+
if (typeof meta.stderrTail === "string" && meta.stderrTail) {
|
|
308
|
+
this.#stderrBuf = meta.stderrTail.slice(-8192);
|
|
309
|
+
}
|
|
310
|
+
if (this.#debug) this.#log(`exit meta: code ${meta.code ?? null}`);
|
|
311
|
+
this.#finish(
|
|
312
|
+
typeof meta.code === "number" ? meta.code : null,
|
|
313
|
+
"runner exit meta",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- liveness ------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
async #probeLiveness(): Promise<void> {
|
|
321
|
+
if (this.#closed || this.#detached || this.#probing) return;
|
|
322
|
+
this.#probing = true;
|
|
323
|
+
try {
|
|
324
|
+
if (!this.#sawSpawnMeta) {
|
|
325
|
+
if (Date.now() - this.#startedAt > HEARTBEAT_STALE_MS) {
|
|
326
|
+
this.#finish(null, "no spawn meta within grace");
|
|
327
|
+
}
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const fresh = await machineHeartbeatFresh(
|
|
331
|
+
this.#environment,
|
|
332
|
+
this.#sessionDir,
|
|
333
|
+
HEARTBEAT_STALE_MS,
|
|
334
|
+
);
|
|
335
|
+
if (this.#closed || this.#detached) return;
|
|
336
|
+
if (!fresh) this.#finish(null, "heartbeat stale");
|
|
337
|
+
} finally {
|
|
338
|
+
this.#probing = false;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#finish(code: number | null, reason = "unspecified"): void {
|
|
343
|
+
if (this.#closed) return;
|
|
344
|
+
// Session deaths are load-bearing and were undiagnosable without this
|
|
345
|
+
// (live 2026-08-27: a 65s recycle loop with no trace). Always log.
|
|
346
|
+
console.warn(
|
|
347
|
+
`[machine-durable] session ${this.#sessionDir} finished (code ${code ?? "null"}): ${reason}`,
|
|
348
|
+
);
|
|
349
|
+
this.#closed = true;
|
|
350
|
+
this.#stopTimers();
|
|
351
|
+
const tail = this.#tail;
|
|
352
|
+
this.#tail = null;
|
|
353
|
+
if (tail) void tail.terminate().catch(() => {});
|
|
354
|
+
for (const handler of this.#exitHandlers) handler(code);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#stopTimers(): void {
|
|
358
|
+
if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
|
|
359
|
+
if (this.#closeTimer) clearTimeout(this.#closeTimer);
|
|
360
|
+
if (this.#respawnTimer) clearTimeout(this.#respawnTimer);
|
|
361
|
+
this.#heartbeatTimer = null;
|
|
362
|
+
this.#closeTimer = null;
|
|
363
|
+
this.#respawnTimer = null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ---- LineProcess-compatible surface ---------------------------------------
|
|
367
|
+
|
|
368
|
+
onLine(handler: LineHandler): void {
|
|
369
|
+
this.#lineHandlers.add(handler);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
onExit(handler: ExitHandler): void {
|
|
373
|
+
this.#exitHandlers.add(handler);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** `__uai` MUST stay the first key: both runner and host filter meta lines
|
|
377
|
+
* with a byte-prefix check, not a parse (see DurableProcess.writeLine). */
|
|
378
|
+
writeLine(value: unknown): void {
|
|
379
|
+
if (this.#closed || this.#detached) return;
|
|
380
|
+
const json = JSON.stringify(value);
|
|
381
|
+
const inputMeta = JSON.stringify({ __uai: "input", ts: Date.now() });
|
|
382
|
+
if (this.#debug) this.#log(`-> ${json.slice(0, 1000)}`);
|
|
383
|
+
this.#inboxChain = this.#inboxChain
|
|
384
|
+
.then(() =>
|
|
385
|
+
appendEnvironmentFile(
|
|
386
|
+
this.#environment,
|
|
387
|
+
this.#inboxPath,
|
|
388
|
+
`${inputMeta}\n${json}\n`,
|
|
389
|
+
),
|
|
390
|
+
)
|
|
391
|
+
.catch(() => {
|
|
392
|
+
// Transport error — liveness probes will surface a dead session.
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
get stderrTail(): string {
|
|
397
|
+
return this.#stderrBuf;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
get isClosed(): boolean {
|
|
401
|
+
return this.#closed;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
get outboxOffset(): number {
|
|
405
|
+
return this.#offset;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Stop consuming WITHOUT touching the runner (host restart in miniature). */
|
|
409
|
+
detach(): void {
|
|
410
|
+
if (this.#detached || this.#closed) return;
|
|
411
|
+
this.#detached = true;
|
|
412
|
+
this.#stopTimers();
|
|
413
|
+
const tail = this.#tail;
|
|
414
|
+
this.#tail = null;
|
|
415
|
+
if (tail) void tail.terminate().catch(() => {});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Real teardown: ask the runner to stop its CLI, then observe the exit. */
|
|
419
|
+
async close(): Promise<void> {
|
|
420
|
+
if (this.#closed || this.#detached) return;
|
|
421
|
+
try {
|
|
422
|
+
this.#onCloseRequested?.();
|
|
423
|
+
} catch {
|
|
424
|
+
// Bookkeeping only.
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
await this.#inboxChain;
|
|
428
|
+
await appendEnvironmentFile(
|
|
429
|
+
this.#environment,
|
|
430
|
+
this.#inboxPath,
|
|
431
|
+
'{"__uai":"stop"}\n',
|
|
432
|
+
);
|
|
433
|
+
} catch {
|
|
434
|
+
// Inbox unwritable — fall through to the grace timer.
|
|
435
|
+
}
|
|
436
|
+
this.#closeTimer = setTimeout(
|
|
437
|
+
() => this.#finish(null, "close grace elapsed"),
|
|
438
|
+
CLOSE_GRACE_MS,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
// Session-dir plumbing shared with the transport layer.
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
export function machineSessionDir(
|
|
448
|
+
workspacePath: string,
|
|
449
|
+
agentId: string,
|
|
450
|
+
): string {
|
|
451
|
+
return posix.join(
|
|
452
|
+
workspacePath,
|
|
453
|
+
".uai",
|
|
454
|
+
"sessions",
|
|
455
|
+
agentId,
|
|
456
|
+
`${String(Date.now())}-${process.pid}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Deliver this host's runner into the environment session dir. */
|
|
461
|
+
export async function deployRunner(
|
|
462
|
+
environment: MachineSessionEnvironment,
|
|
463
|
+
sessionDir: string,
|
|
464
|
+
): Promise<string> {
|
|
465
|
+
const runnerPath = posix.join(sessionDir, "runner.mjs");
|
|
466
|
+
await environment.writeWorkspaceFile(
|
|
467
|
+
runnerPath,
|
|
468
|
+
readFileSync(runnerScriptPath()),
|
|
469
|
+
);
|
|
470
|
+
return runnerPath;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Environment-side `current` pointer (agents read the path from their
|
|
474
|
+
* preamble). Best-effort: failure must never kill a session. */
|
|
475
|
+
export async function publishMachineCurrentSession(
|
|
476
|
+
environment: MachineSessionEnvironment,
|
|
477
|
+
sessionDir: string,
|
|
478
|
+
): Promise<void> {
|
|
479
|
+
try {
|
|
480
|
+
await execStep(environment, [
|
|
481
|
+
"/bin/sh",
|
|
482
|
+
"-c",
|
|
483
|
+
'ln -sfn "$(basename "$1")" "$(dirname "$1")/current"',
|
|
484
|
+
"publish",
|
|
485
|
+
sessionDir,
|
|
486
|
+
]);
|
|
487
|
+
} catch {
|
|
488
|
+
// Diagnostic pointer only.
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Ask a (possibly dead) predecessor runner to stop — same gesture as the
|
|
493
|
+
* host-FS append, over the transport, and just as harmless when it's gone. */
|
|
494
|
+
export async function stopMachinePredecessor(
|
|
495
|
+
environment: MachineSessionEnvironment,
|
|
496
|
+
sessionDir: string,
|
|
497
|
+
): Promise<void> {
|
|
498
|
+
try {
|
|
499
|
+
await appendEnvironmentFile(
|
|
500
|
+
environment,
|
|
501
|
+
posix.join(sessionDir, "inbox.jsonl"),
|
|
502
|
+
'{"__uai":"stop"}\n',
|
|
503
|
+
);
|
|
504
|
+
} catch {
|
|
505
|
+
// Dir gone / runner dead — nothing to stop.
|
|
506
|
+
}
|
|
507
|
+
}
|
package/lib/agents/transport.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import {
|
|
25
25
|
copyFileSync,
|
|
26
|
+
readFileSync,
|
|
26
27
|
mkdirSync,
|
|
27
28
|
renameSync,
|
|
28
29
|
rmSync,
|
|
@@ -39,6 +40,14 @@ import { taskWorkspaceDir } from "../env";
|
|
|
39
40
|
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
40
41
|
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
41
42
|
import { DurableProcess, runnerScriptPath } from "./durable-proc";
|
|
43
|
+
import {
|
|
44
|
+
MachineDurableProcess,
|
|
45
|
+
deployRunner,
|
|
46
|
+
machineSessionDir,
|
|
47
|
+
publishMachineCurrentSession,
|
|
48
|
+
stopMachinePredecessor,
|
|
49
|
+
type MachineSessionEnvironment,
|
|
50
|
+
} from "./machine-durable";
|
|
42
51
|
import {
|
|
43
52
|
AGENT_SESSION_OUTPUT_BUFFER_BYTES,
|
|
44
53
|
EnvironmentLineProcess,
|
|
@@ -93,9 +102,13 @@ const ATTACH_HEARTBEAT_FRESH_MS = 20_000;
|
|
|
93
102
|
* the feed, multiplicity growing by one per errored turn. Enforcing the
|
|
94
103
|
* invariant here covers every replacement path, including future ones.
|
|
95
104
|
*/
|
|
96
|
-
|
|
105
|
+
interface DetachableTail extends LineTransport {
|
|
106
|
+
detach(): void;
|
|
107
|
+
}
|
|
97
108
|
|
|
98
|
-
|
|
109
|
+
const liveTails = new Map<string, DetachableTail>();
|
|
110
|
+
|
|
111
|
+
function claimTail<T extends DetachableTail>(key: string, proc: T): T {
|
|
99
112
|
liveTails.get(key)?.detach();
|
|
100
113
|
liveTails.set(key, proc);
|
|
101
114
|
return proc;
|
|
@@ -106,14 +119,16 @@ function durableEnabled(): boolean {
|
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
|
|
109
|
-
// ADR-121: machine-backed sessions ride the environment's own transport
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// machines with an ssh-tail backend.
|
|
122
|
+
// ADR-121: machine-backed sessions ride the environment's own transport —
|
|
123
|
+
// the container-runtime preflight is a docker/apple concern a machine task
|
|
124
|
+
// must not trip over, and the host-FS durable flow below polls files a
|
|
125
|
+
// machine does not share. Machine durability uses the ssh-tail backend.
|
|
114
126
|
if (opts.environment.descriptor.locator.provider === "machine") {
|
|
115
|
-
|
|
116
|
-
|
|
127
|
+
if (!durableEnabled()) {
|
|
128
|
+
clearCurrentSession(opts.taskId, opts.agentId);
|
|
129
|
+
return directEnvironmentTransport(opts);
|
|
130
|
+
}
|
|
131
|
+
return machineDurableTransport(opts);
|
|
117
132
|
}
|
|
118
133
|
// Session creation can happen after channel/task lifecycle queues drain, well
|
|
119
134
|
// after the command-level runtime preflight. Recheck at the actual attach or
|
|
@@ -286,6 +301,166 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
286
301
|
return claimTail(tailKey, proc);
|
|
287
302
|
}
|
|
288
303
|
|
|
304
|
+
/**
|
|
305
|
+
* ADR-121: durable machine sessions. Same DB row + attach/replace semantics
|
|
306
|
+
* as the container flow; the session files live in the MACHINE's workspace
|
|
307
|
+
* and are reached over the environment transport (MachineDurableProcess).
|
|
308
|
+
* Degrades to direct pipes when the handle lacks the wider ops or the runner
|
|
309
|
+
* asset is missing — sessions work, they just don't survive host restarts.
|
|
310
|
+
*/
|
|
311
|
+
function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
|
|
312
|
+
const surface = opts.environment;
|
|
313
|
+
const capable =
|
|
314
|
+
"exec" in surface && "spawnSession" in surface && "writeWorkspaceFile" in surface;
|
|
315
|
+
let runnerSource: Buffer | null = null;
|
|
316
|
+
if (capable) {
|
|
317
|
+
try {
|
|
318
|
+
runnerSource = readFileSync(runnerScriptPath());
|
|
319
|
+
} catch (err) {
|
|
320
|
+
console.warn(
|
|
321
|
+
`[transport] runner unavailable (${err instanceof Error ? err.message : err}) — falling back to direct pipes for ${opts.agentId}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (!capable || runnerSource === null) {
|
|
326
|
+
clearCurrentSession(opts.taskId, opts.agentId);
|
|
327
|
+
return directEnvironmentTransport(opts);
|
|
328
|
+
}
|
|
329
|
+
const environment = surface as unknown as MachineSessionEnvironment &
|
|
330
|
+
TaskEnvironmentAgentSessionSurface;
|
|
331
|
+
|
|
332
|
+
const db = getDb();
|
|
333
|
+
const row = db
|
|
334
|
+
.select()
|
|
335
|
+
.from(schema.hostAgentSessions)
|
|
336
|
+
.where(
|
|
337
|
+
and(
|
|
338
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
339
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
340
|
+
),
|
|
341
|
+
)
|
|
342
|
+
.get();
|
|
343
|
+
const persistOffset = (offset: number): void => {
|
|
344
|
+
db.update(schema.hostAgentSessions)
|
|
345
|
+
.set({ outboxOffset: offset, updatedAt: Date.now() })
|
|
346
|
+
.where(
|
|
347
|
+
and(
|
|
348
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
349
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
.run();
|
|
353
|
+
};
|
|
354
|
+
const markClosed = (): void => {
|
|
355
|
+
db.update(schema.hostAgentSessions)
|
|
356
|
+
.set({ status: "closed", updatedAt: Date.now() })
|
|
357
|
+
.where(
|
|
358
|
+
and(
|
|
359
|
+
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
360
|
+
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
.run();
|
|
364
|
+
};
|
|
365
|
+
const tailKey = `${opts.taskId}:${opts.agentId}`;
|
|
366
|
+
|
|
367
|
+
// ---- Attach: a previous host process left this machine's runner alive. --
|
|
368
|
+
// Freshness cannot be a sync stat over ssh; MachineDurableProcess probes
|
|
369
|
+
// the heartbeat immediately on attach and finishes fast when it is stale.
|
|
370
|
+
if (
|
|
371
|
+
opts.allowAttach &&
|
|
372
|
+
row &&
|
|
373
|
+
row.status === "running" &&
|
|
374
|
+
row.containerName === environment.durableIdentity &&
|
|
375
|
+
(row.attachCompatibilityKey ?? null) ===
|
|
376
|
+
(opts.attachCompatibilityKey ?? null)
|
|
377
|
+
) {
|
|
378
|
+
const proc = new MachineDurableProcess({
|
|
379
|
+
environment,
|
|
380
|
+
sessionDir: row.sessionDir,
|
|
381
|
+
initialOutboxOffset: row.outboxOffset,
|
|
382
|
+
onOffsetAdvance: persistOffset,
|
|
383
|
+
onCloseRequested: markClosed,
|
|
384
|
+
debugLabel: opts.debugLabel,
|
|
385
|
+
});
|
|
386
|
+
proc.onExit(markClosed);
|
|
387
|
+
return claimTail(tailKey, proc);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---- Spawn a fresh runner, asking any predecessor to stop. --------------
|
|
391
|
+
const staleMachineSession =
|
|
392
|
+
row && row.containerName === environment.durableIdentity
|
|
393
|
+
? row.sessionDir
|
|
394
|
+
: null;
|
|
395
|
+
const sessionDir = machineSessionDir(
|
|
396
|
+
environment.descriptor.workspacePath,
|
|
397
|
+
opts.agentId,
|
|
398
|
+
);
|
|
399
|
+
const proc = new MachineDurableProcess({
|
|
400
|
+
environment,
|
|
401
|
+
sessionDir,
|
|
402
|
+
onOffsetAdvance: persistOffset,
|
|
403
|
+
onCloseRequested: markClosed,
|
|
404
|
+
debugLabel: opts.debugLabel,
|
|
405
|
+
launch: async () => {
|
|
406
|
+
// Unconditional stop of a same-machine predecessor (see the container
|
|
407
|
+
// flow): a runner falsely marked closed may still be alive, and a stop
|
|
408
|
+
// appended to a dead session's inbox is harmless.
|
|
409
|
+
if (staleMachineSession) {
|
|
410
|
+
await stopMachinePredecessor(environment, staleMachineSession);
|
|
411
|
+
}
|
|
412
|
+
const runnerPath = await deployRunner(environment, sessionDir);
|
|
413
|
+
await publishMachineCurrentSession(environment, sessionDir);
|
|
414
|
+
const result = await environment.launchDetachedSession({
|
|
415
|
+
argv: [
|
|
416
|
+
"node",
|
|
417
|
+
runnerPath,
|
|
418
|
+
sessionDir,
|
|
419
|
+
"--",
|
|
420
|
+
opts.cli,
|
|
421
|
+
...opts.cliArgs,
|
|
422
|
+
],
|
|
423
|
+
inheritEnv: opts.passEnv ?? [],
|
|
424
|
+
env: opts.explicitEnv ?? {},
|
|
425
|
+
launchTimeoutMs: 30_000,
|
|
426
|
+
maxOutputBytes: 256 * 1024,
|
|
427
|
+
});
|
|
428
|
+
return { exitCode: result.exitCode, stderr: result.stderr };
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const now = Date.now();
|
|
433
|
+
db.insert(schema.hostAgentSessions)
|
|
434
|
+
.values({
|
|
435
|
+
taskId: opts.taskId,
|
|
436
|
+
agentId: opts.agentId,
|
|
437
|
+
sessionDir,
|
|
438
|
+
containerName: environment.durableIdentity,
|
|
439
|
+
kind: opts.kind,
|
|
440
|
+
attachCompatibilityKey: opts.attachCompatibilityKey ?? null,
|
|
441
|
+
outboxOffset: 0,
|
|
442
|
+
status: "running",
|
|
443
|
+
createdAt: now,
|
|
444
|
+
updatedAt: now,
|
|
445
|
+
})
|
|
446
|
+
.onConflictDoUpdate({
|
|
447
|
+
target: [schema.hostAgentSessions.taskId, schema.hostAgentSessions.agentId],
|
|
448
|
+
set: {
|
|
449
|
+
sessionDir,
|
|
450
|
+
containerName: environment.durableIdentity,
|
|
451
|
+
kind: opts.kind,
|
|
452
|
+
attachCompatibilityKey: opts.attachCompatibilityKey ?? null,
|
|
453
|
+
outboxOffset: 0,
|
|
454
|
+
status: "running",
|
|
455
|
+
updatedAt: now,
|
|
456
|
+
},
|
|
457
|
+
})
|
|
458
|
+
.run();
|
|
459
|
+
|
|
460
|
+
proc.onExit(markClosed);
|
|
461
|
+
return claimTail(tailKey, proc);
|
|
462
|
+
}
|
|
463
|
+
|
|
289
464
|
function directEnvironmentTransport(opts: AgentTransportOptions): LineTransport {
|
|
290
465
|
return new EnvironmentLineProcess({
|
|
291
466
|
environment: opts.environment,
|
package/lib/machine-exec.ts
CHANGED
|
@@ -84,12 +84,20 @@ export function buildRemoteCommand(request: {
|
|
|
84
84
|
const command = request.argv.map(shellQuote).join(" ");
|
|
85
85
|
if (request.detached) {
|
|
86
86
|
// Detached durable sessions manage their own transcript IO (runner.mjs);
|
|
87
|
-
// the launch just needs the process to survive this ssh connection
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
// the launch just needs the process to survive this ssh connection AND
|
|
88
|
+
// the connection to close immediately. The redirects must cover the
|
|
89
|
+
// WHOLE background job and the job must `exec` into the payload: with
|
|
90
|
+
// redirects on the inner command only, the waiting shell kept the ssh
|
|
91
|
+
// channel's stdout/stderr open until the runner died, so sshd never saw
|
|
92
|
+
// EOF and every launch rode its timeout into a false spawn failure
|
|
93
|
+
// (live 2026-08-27: a 65s session recycle loop on the second machine
|
|
94
|
+
// task; the first machine's stable session was an accident of its host
|
|
95
|
+
// dying seconds before the timeout could mark the row closed).
|
|
96
|
+
const body = [
|
|
97
|
+
...(request.cwd ? [`cd ${shellQuote(request.cwd)} &&`] : []),
|
|
98
|
+
`exec ${envPrefix}setsid ${command}`,
|
|
99
|
+
].join(" ");
|
|
100
|
+
return `{ ${body}; } </dev/null >/dev/null 2>&1 & exit 0`;
|
|
93
101
|
}
|
|
94
102
|
parts.push(`exec ${envPrefix}${command}`);
|
|
95
103
|
return parts.join(" ");
|
package/lib/orchestrator.ts
CHANGED
|
@@ -96,6 +96,7 @@ import {
|
|
|
96
96
|
apiUrlFromCloudUrl,
|
|
97
97
|
loadTaskCliSecret,
|
|
98
98
|
writeAgentCli,
|
|
99
|
+
writeAgentCliViaEnvironment,
|
|
99
100
|
} from "./agent-cli";
|
|
100
101
|
import {
|
|
101
102
|
DEFAULT_CODEX_HOME,
|
|
@@ -1370,6 +1371,13 @@ export class Orchestrator {
|
|
|
1370
1371
|
|
|
1371
1372
|
for (const agent of missing) channel.spawning.add(agent.id);
|
|
1372
1373
|
try {
|
|
1374
|
+
// ADR-121: machine workspaces are only reachable through the
|
|
1375
|
+
// environment handle — the docker-exec cold-start default would
|
|
1376
|
+
// dial a container that does not exist. Settle the handle first.
|
|
1377
|
+
if (task.environmentProvider === "machine") {
|
|
1378
|
+
await this.agentEnvironment(channel).catch(() => {});
|
|
1379
|
+
if (!this.isActiveChannel(channel)) return;
|
|
1380
|
+
}
|
|
1373
1381
|
// Same per-agent materialisation the initial start does. Browser setup
|
|
1374
1382
|
// is awaited before EVERY missing-session spawn: that reasserts configs
|
|
1375
1383
|
// clobbered by resume/auth injection and covers roster generation.
|
|
@@ -1402,8 +1410,8 @@ export class Orchestrator {
|
|
|
1402
1410
|
|
|
1403
1411
|
const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
|
|
1404
1412
|
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
1405
|
-
const cliWritten =
|
|
1406
|
-
|
|
1413
|
+
const cliWritten = await materializeAgentCli(
|
|
1414
|
+
task,
|
|
1407
1415
|
roster,
|
|
1408
1416
|
apiUrl,
|
|
1409
1417
|
channel.mode === "secretary",
|
|
@@ -1698,6 +1706,14 @@ export class Orchestrator {
|
|
|
1698
1706
|
// browser/MCP configuration, or runner creation can execute in Docker.
|
|
1699
1707
|
await agentClisReady;
|
|
1700
1708
|
if (!this.isActiveChannel(channel)) return false;
|
|
1709
|
+
// ADR-121: machine workspaces are only reachable through the environment
|
|
1710
|
+
// handle. Settle it before any materialization step below, so identity,
|
|
1711
|
+
// skills, browser, and MCP writes all ride the provider surface instead
|
|
1712
|
+
// of dialing a compose container that does not exist.
|
|
1713
|
+
if (task.environmentProvider === "machine") {
|
|
1714
|
+
await this.agentEnvironment(channel).catch(() => {});
|
|
1715
|
+
if (!this.isActiveChannel(channel)) return false;
|
|
1716
|
+
}
|
|
1701
1717
|
// Freeze the generation before any slow docker work. A roster add while
|
|
1702
1718
|
// setup awaits must reconcile under its own engine-aware browser pass,
|
|
1703
1719
|
// not slip into this factory loop under the old generation's config.
|
|
@@ -1777,8 +1793,8 @@ export class Orchestrator {
|
|
|
1777
1793
|
// are actually enforced. Best-effort, host-side.
|
|
1778
1794
|
const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
|
|
1779
1795
|
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
1780
|
-
const cliWritten =
|
|
1781
|
-
|
|
1796
|
+
const cliWritten = await materializeAgentCli(
|
|
1797
|
+
task,
|
|
1782
1798
|
channel.roster,
|
|
1783
1799
|
apiUrl,
|
|
1784
1800
|
channel.mode === "secretary",
|
|
@@ -5619,6 +5635,35 @@ async function quarantineWritableAppleRuntimeContainers(options: {
|
|
|
5619
5635
|
|
|
5620
5636
|
/** Dispatch one durable row through its provider. Generic boot orchestration
|
|
5621
5637
|
* owns DB/lifecycle sequencing but never derives a Compose/container identity. */
|
|
5638
|
+
/**
|
|
5639
|
+
* ADR-121: materialize the in-task uai CLI through whichever surface owns the
|
|
5640
|
+
* workspace — host FS for container tasks, the environment transport for
|
|
5641
|
+
* machine tasks (their workspace is not host-reachable). Best-effort like the
|
|
5642
|
+
* host-side writer.
|
|
5643
|
+
*/
|
|
5644
|
+
async function materializeAgentCli(
|
|
5645
|
+
task: typeof schema.hostTasks.$inferSelect,
|
|
5646
|
+
roster: RosterAgent[],
|
|
5647
|
+
apiUrl: string | null,
|
|
5648
|
+
allowPermissionless: boolean,
|
|
5649
|
+
): Promise<boolean> {
|
|
5650
|
+
if (task.environmentProvider === "machine") {
|
|
5651
|
+
try {
|
|
5652
|
+
const environment = await reconstructPersistedTaskEnvironment(task);
|
|
5653
|
+
if (environment === null) return false;
|
|
5654
|
+
return await writeAgentCliViaEnvironment(
|
|
5655
|
+
environment,
|
|
5656
|
+
roster,
|
|
5657
|
+
apiUrl,
|
|
5658
|
+
allowPermissionless,
|
|
5659
|
+
);
|
|
5660
|
+
} catch {
|
|
5661
|
+
return false;
|
|
5662
|
+
}
|
|
5663
|
+
}
|
|
5664
|
+
return writeAgentCli(task.taskId, roster, apiUrl, allowPermissionless);
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5622
5667
|
async function recoverPersistedTaskEnvironment(
|
|
5623
5668
|
task: typeof schema.hostTasks.$inferSelect,
|
|
5624
5669
|
maintenanceReady: Promise<void>,
|
package/lib/runtime-state.ts
CHANGED
|
@@ -31,6 +31,11 @@ export function getHostTask(taskId: string): HostTask | null {
|
|
|
31
31
|
|
|
32
32
|
/** Remove a provisional first-seen row when admission loses a lifecycle gate. */
|
|
33
33
|
export function deleteHostTask(taskId: string): void {
|
|
34
|
+
// Row deletions are rare and load-bearing; a silent one cost a live
|
|
35
|
+
// machine-task forensic session (2026-08-27). Name the caller.
|
|
36
|
+
console.warn(
|
|
37
|
+
`[runtime-state] deleting host task row ${taskId}\n${(new Error().stack ?? "").split("\n").slice(2, 5).join("\n")}`,
|
|
38
|
+
);
|
|
34
39
|
getDb()
|
|
35
40
|
.delete(schema.hostTasks)
|
|
36
41
|
.where(eq(schema.hostTasks.taskId, taskId))
|
|
@@ -41,6 +46,7 @@ export function deleteHostTask(taskId: string): void {
|
|
|
41
46
|
* happen before this transaction; a SQLite failure leaves every task-owned
|
|
42
47
|
* row intact rather than acknowledging a partial metadata deletion. */
|
|
43
48
|
export function purgeHostTaskState(taskId: string): void {
|
|
49
|
+
console.warn(`[runtime-state] purging all host state for task ${taskId}`);
|
|
44
50
|
getDb().transaction((tx) => {
|
|
45
51
|
tx.delete(schema.hostAgentSessions)
|
|
46
52
|
.where(eq(schema.hostAgentSessions.taskId, taskId))
|
|
@@ -26,7 +26,10 @@ import { createAwsMachineProvider } from "../machine-provider-aws";
|
|
|
26
26
|
import { createLocalMachineProvider } from "../machine-provider-local";
|
|
27
27
|
import { ensureMachineKeyPair } from "../machine-keys";
|
|
28
28
|
import { requestAccessToken } from "../github-tokens";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
createMachineTaskEnvironmentProvider,
|
|
31
|
+
MACHINE_TASK_ENVIRONMENT_PROVIDER,
|
|
32
|
+
} from "./machine";
|
|
30
33
|
import { createMachineHostTaskEnvironmentProvider } from "./machine-task-up";
|
|
31
34
|
import { TaskEnvironmentRegistry } from "./registry";
|
|
32
35
|
import {
|
|
@@ -307,6 +310,9 @@ export function persistedTaskEnvironmentsMatchMachine(
|
|
|
307
310
|
};
|
|
308
311
|
}
|
|
309
312
|
if (locator === null) continue;
|
|
313
|
+
// ADR-121: machine locators name an ephemeral VM, not a container
|
|
314
|
+
// daemon — no container-runtime identity claim to verify.
|
|
315
|
+
if (locator.provider === MACHINE_TASK_ENVIRONMENT_PROVIDER) continue;
|
|
310
316
|
try {
|
|
311
317
|
const persisted = locatorMachineIdentity(locator);
|
|
312
318
|
if (
|
|
@@ -375,6 +381,8 @@ export function hostTaskEnvironmentsAllowMachineSelection(
|
|
|
375
381
|
if (locator.provider !== task.environmentProvider) {
|
|
376
382
|
throw new Error("task environment provider does not match its locator");
|
|
377
383
|
}
|
|
384
|
+
// ADR-121: machine rows make no container-runtime claim.
|
|
385
|
+
if (locator.provider === MACHINE_TASK_ENVIRONMENT_PROVIDER) continue;
|
|
378
386
|
const persisted = locatorMachineIdentity(locator);
|
|
379
387
|
if (
|
|
380
388
|
persisted.backend !== machine.backend ||
|
|
@@ -166,6 +166,12 @@ function legacyCandidate(
|
|
|
166
166
|
// 2026-08-15): a crash-preserved prepared apple locator made adoption —
|
|
167
167
|
// and with it runtime selection — fail closed, so the task could not
|
|
168
168
|
// even be settled.
|
|
169
|
+
// ADR-121: machine-backed rows are not container-runtime residents at
|
|
170
|
+
// all — their locator names an ephemeral VM, not a docker/apple daemon.
|
|
171
|
+
// They must never wedge container-runtime selection (live 2026-08-27:
|
|
172
|
+
// the first machine task quarantined the whole VM runtime on reconnect,
|
|
173
|
+
// collapsing every host command into a retry storm).
|
|
174
|
+
if (locator.provider === "machine") return null;
|
|
169
175
|
let persistedMachine: HostMachineIdentity;
|
|
170
176
|
if (locator.provider === "docker-compose") {
|
|
171
177
|
persistedMachine = parseDockerTaskEnvironmentLocator(locator).machine;
|
|
@@ -15,12 +15,19 @@
|
|
|
15
15
|
* directory that really does hold this task's host-side state — its machine
|
|
16
16
|
* key). Consumers that need the workspace go through the environment handle.
|
|
17
17
|
*/
|
|
18
|
+
import { execFile } from "node:child_process";
|
|
19
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { dirname, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { promisify } from "node:util";
|
|
23
|
+
|
|
18
24
|
import type {
|
|
19
25
|
TaskDownResult,
|
|
20
26
|
TaskLaunchInput,
|
|
21
27
|
TaskUpCredentials,
|
|
22
28
|
TaskUpResult,
|
|
23
29
|
} from "../agent";
|
|
30
|
+
import { sharedRoot } from "../shared-files";
|
|
24
31
|
import {
|
|
25
32
|
MACHINE_TASK_ENVIRONMENT_PROVIDER,
|
|
26
33
|
parseMachineTaskEnvironmentLocator,
|
|
@@ -36,6 +43,9 @@ import type {
|
|
|
36
43
|
|
|
37
44
|
/** In-machine clone budget per project. */
|
|
38
45
|
const CLONE_TIMEOUT_MS = 10 * 60_000;
|
|
46
|
+
/** The machine port code-server listens on (dialed directly by the editor
|
|
47
|
+
* tunnel via inspectRoute; never published). */
|
|
48
|
+
export const MACHINE_CODE_SERVER_PORT = 8080;
|
|
39
49
|
const GIT_STEP_TIMEOUT_MS = 60_000;
|
|
40
50
|
const STEP_OUTPUT_BYTES = 256 * 1024;
|
|
41
51
|
|
|
@@ -223,6 +233,191 @@ async function cloneProject(
|
|
|
223
233
|
return null;
|
|
224
234
|
}
|
|
225
235
|
|
|
236
|
+
/** The curated code-server defaults (slim chrome, telemetry off, trust
|
|
237
|
+
* off) — the SAME seed the standard image bakes; resolved from this package
|
|
238
|
+
* so machine and container editors can never diverge. Machines adjust two
|
|
239
|
+
* keys host-side: the task's ADR-091 theme, and bash for the terminal (the
|
|
240
|
+
* machine payload carries no zsh). */
|
|
241
|
+
function editorSettingsSeed(editorTheme: string | undefined): string {
|
|
242
|
+
const seedPath = resolve(
|
|
243
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
244
|
+
"..",
|
|
245
|
+
"..",
|
|
246
|
+
"images",
|
|
247
|
+
"standard",
|
|
248
|
+
"container",
|
|
249
|
+
"code-server-settings.json",
|
|
250
|
+
);
|
|
251
|
+
const seed = JSON.parse(readFileSync(seedPath, "utf8")) as Record<
|
|
252
|
+
string,
|
|
253
|
+
unknown
|
|
254
|
+
>;
|
|
255
|
+
if (editorTheme) seed["workbench.colorTheme"] = editorTheme;
|
|
256
|
+
seed["terminal.integrated.defaultProfile.linux"] = "bash";
|
|
257
|
+
return `${JSON.stringify(seed, null, 2)}\n`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Seed the editor settings exactly once (an existing settings.json is the
|
|
261
|
+
* user's own state and is never touched — same contract as uai-init). */
|
|
262
|
+
async function seedEditorSettings(
|
|
263
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
264
|
+
editorTheme: string | undefined,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const destination =
|
|
267
|
+
"/home/node/.local/share/code-server/User/settings.json";
|
|
268
|
+
await handle.exec({
|
|
269
|
+
argv: [
|
|
270
|
+
"/bin/sh",
|
|
271
|
+
"-c",
|
|
272
|
+
'[ -e "$1" ] && exit 0; mkdir -p "$(dirname "$1")" && cat > "$1"',
|
|
273
|
+
"seed",
|
|
274
|
+
destination,
|
|
275
|
+
],
|
|
276
|
+
stdin: Buffer.from(editorSettingsSeed(editorTheme)),
|
|
277
|
+
timeoutMs: GIT_STEP_TIMEOUT_MS,
|
|
278
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const execFileAsync = promisify(execFile);
|
|
283
|
+
/** Shared-files snapshots are reference docs, not repos; a tree past this
|
|
284
|
+
* is skipped with a warning rather than ballooning host memory. */
|
|
285
|
+
const SHARED_FILES_TAR_MAX_BYTES = 256 * 1024 * 1024;
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* ADR-062 shared files on machines: containers get live bind mounts at
|
|
289
|
+
* /workspace/files/{org,me}; a machine shares no filesystem, so it gets a
|
|
290
|
+
* POINT-IN-TIME COPY at provision (tar streamed over the transport). The
|
|
291
|
+
* managed-hosting answer for live semantics is EFS (ADR-121); until then a
|
|
292
|
+
* snapshot with an honest note beats silently absent files.
|
|
293
|
+
* Returns a warning string when rw mode was requested (writes cannot sync
|
|
294
|
+
* back) or a scope was skipped; null when clean.
|
|
295
|
+
*/
|
|
296
|
+
async function copySharedFiles(
|
|
297
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
298
|
+
workspacePath: string,
|
|
299
|
+
task: { ownerOrgId?: string; ownerUserId: string; sharedFiles?: string },
|
|
300
|
+
): Promise<string | null> {
|
|
301
|
+
const mode = task.sharedFiles ?? "ro";
|
|
302
|
+
if (mode === "off") return null;
|
|
303
|
+
const scopes: Array<{ host: string; destination: string }> = [];
|
|
304
|
+
if (task.ownerOrgId) {
|
|
305
|
+
scopes.push({
|
|
306
|
+
host: sharedRoot("org", task.ownerOrgId, task.ownerUserId),
|
|
307
|
+
destination: `${workspacePath}/files/org`,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
scopes.push({
|
|
311
|
+
host: sharedRoot("me", task.ownerOrgId ?? "", task.ownerUserId),
|
|
312
|
+
destination: `${workspacePath}/files/me`,
|
|
313
|
+
});
|
|
314
|
+
const warnings: string[] = [];
|
|
315
|
+
let copiedAny = false;
|
|
316
|
+
for (const scope of scopes) {
|
|
317
|
+
if (!existsSync(scope.host) || readdirSync(scope.host).length === 0) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const tarball = await execFileAsync(
|
|
322
|
+
"tar",
|
|
323
|
+
["-C", scope.host, "-cf", "-", "."],
|
|
324
|
+
{ encoding: "buffer", maxBuffer: SHARED_FILES_TAR_MAX_BYTES },
|
|
325
|
+
);
|
|
326
|
+
const result = await handle.exec({
|
|
327
|
+
argv: [
|
|
328
|
+
"/bin/sh",
|
|
329
|
+
"-c",
|
|
330
|
+
'mkdir -p "$1" && tar -C "$1" -xf -',
|
|
331
|
+
"shared",
|
|
332
|
+
scope.destination,
|
|
333
|
+
],
|
|
334
|
+
stdin: tarball.stdout,
|
|
335
|
+
timeoutMs: 5 * 60_000,
|
|
336
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
337
|
+
});
|
|
338
|
+
if (result.exitCode !== 0) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`extract exited ${result.exitCode}: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
copiedAny = true;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
warnings.push(
|
|
346
|
+
`shared files: could not copy ${scope.destination}: ${
|
|
347
|
+
error instanceof Error ? error.message : String(error)
|
|
348
|
+
}`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (copiedAny || warnings.length === 0) {
|
|
353
|
+
// Marker gates the "## Shared files" preamble briefing, mirroring the
|
|
354
|
+
// container path's task-up marker.
|
|
355
|
+
await handle
|
|
356
|
+
.writeWorkspaceFile(`${workspacePath}/.uai/files-mounted`, Buffer.from(""))
|
|
357
|
+
.catch(() => {});
|
|
358
|
+
}
|
|
359
|
+
if (mode === "rw") {
|
|
360
|
+
warnings.push(
|
|
361
|
+
"shared files on this machine task are a point-in-time copy — " +
|
|
362
|
+
"changes under /workspace/files will NOT sync back to the host yet",
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return warnings.length > 0 ? warnings.join("\n") : null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Launch code-server for the Editor pane, exactly once. The pgrep guard
|
|
369
|
+
* makes re-provision and resume idempotent; the exec chain leaves only
|
|
370
|
+
* code-server holding the detached job. */
|
|
371
|
+
async function ensureCodeServer(
|
|
372
|
+
handle: TaskEnvironmentHandle<unknown>,
|
|
373
|
+
workspacePath: string,
|
|
374
|
+
editorTheme: string | undefined,
|
|
375
|
+
): Promise<void> {
|
|
376
|
+
// Settings before process: code-server reads them at startup. A failed
|
|
377
|
+
// seed still launches — a themed editor is worth less than one that starts.
|
|
378
|
+
await seedEditorSettings(handle, editorTheme).catch((error: unknown) => {
|
|
379
|
+
console.warn(
|
|
380
|
+
`[machine] editor settings seed failed: ${
|
|
381
|
+
error instanceof Error ? error.message : String(error)
|
|
382
|
+
}`,
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
// The guard and the launch are SEPARATE execs on purpose: any guard that
|
|
386
|
+
// shares a cmdline with the launch text matches itself — the bracketed
|
|
387
|
+
// pattern alone did not save the first combined script, because the plain
|
|
388
|
+
// launch line later in the same argv still contained code-server +
|
|
389
|
+
// --bind-addr (live 2026-08-27, second silent skip).
|
|
390
|
+
const running = await handle.exec({
|
|
391
|
+
argv: [
|
|
392
|
+
"/bin/sh",
|
|
393
|
+
"-c",
|
|
394
|
+
'pgrep -f "[c]ode-server.*--bind-addr" >/dev/null 2>&1',
|
|
395
|
+
],
|
|
396
|
+
timeoutMs: GIT_STEP_TIMEOUT_MS,
|
|
397
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
398
|
+
});
|
|
399
|
+
if (running.exitCode === 0) return;
|
|
400
|
+
const script =
|
|
401
|
+
"mkdir -p /home/node/.local/share/code-server && " +
|
|
402
|
+
"exec /home/node/.local/bin/code-server " +
|
|
403
|
+
"--user-data-dir /home/node/.local/share/code-server " +
|
|
404
|
+
"--disable-workspace-trust --auth none --disable-telemetry " +
|
|
405
|
+
`--disable-update-check --bind-addr 0.0.0.0:${MACHINE_CODE_SERVER_PORT} ` +
|
|
406
|
+
'"$1" >/tmp/code-server.log 2>&1';
|
|
407
|
+
const result = await handle.launchDetachedSession({
|
|
408
|
+
argv: ["/bin/sh", "-c", script, "code-server", workspacePath],
|
|
409
|
+
inheritEnv: [],
|
|
410
|
+
env: {},
|
|
411
|
+
launchTimeoutMs: 30_000,
|
|
412
|
+
maxOutputBytes: 64 * 1024,
|
|
413
|
+
});
|
|
414
|
+
if (result.exitCode !== 0) {
|
|
415
|
+
throw new Error(
|
|
416
|
+
`code-server launch exited ${result.exitCode}: ${Buffer.from(result.stderr).toString("utf8").trim()}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
226
421
|
/** Wrap the void-teardown machine handle so registry consumers get the
|
|
227
422
|
* TaskDownResult contract the compose providers speak. */
|
|
228
423
|
function withTaskDownResult(
|
|
@@ -249,6 +444,9 @@ function withTaskDownResult(
|
|
|
249
444
|
await handle.teardown();
|
|
250
445
|
return { status: "terminated" };
|
|
251
446
|
},
|
|
447
|
+
...(handle.inspectRoute
|
|
448
|
+
? { inspectRoute: () => handle.inspectRoute!() }
|
|
449
|
+
: {}),
|
|
252
450
|
};
|
|
253
451
|
}
|
|
254
452
|
|
|
@@ -311,6 +509,36 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
311
509
|
throw error;
|
|
312
510
|
}
|
|
313
511
|
|
|
512
|
+
// ADR-062 shared files: point-in-time copy (see copySharedFiles).
|
|
513
|
+
const sharedWarning = await copySharedFiles(handle, workspacePath, {
|
|
514
|
+
...(input.task.ownerOrgId ? { ownerOrgId: input.task.ownerOrgId } : {}),
|
|
515
|
+
ownerUserId: input.task.ownerUserId,
|
|
516
|
+
...(input.task.sharedFiles ? { sharedFiles: input.task.sharedFiles } : {}),
|
|
517
|
+
}).catch((error: unknown) => {
|
|
518
|
+
console.warn(
|
|
519
|
+
`[machine] task ${request.taskId}: shared files copy failed: ${
|
|
520
|
+
error instanceof Error ? error.message : String(error)
|
|
521
|
+
}`,
|
|
522
|
+
);
|
|
523
|
+
return null;
|
|
524
|
+
});
|
|
525
|
+
if (sharedWarning) warnings.push(sharedWarning);
|
|
526
|
+
|
|
527
|
+
// Editor pane: code-server on the machine (same flags as uai-init's
|
|
528
|
+
// container launch), idempotent across provision re-runs and resume.
|
|
529
|
+
// Best-effort — a missing binary degrades the Editor tab, never the task.
|
|
530
|
+
await ensureCodeServer(
|
|
531
|
+
handle,
|
|
532
|
+
workspacePath,
|
|
533
|
+
input.task.previewEnv?.UAI_EDITOR_THEME,
|
|
534
|
+
).catch((error: unknown) => {
|
|
535
|
+
console.warn(
|
|
536
|
+
`[machine] task ${request.taskId}: code-server launch failed: ${
|
|
537
|
+
error instanceof Error ? error.message : String(error)
|
|
538
|
+
}`,
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
314
542
|
const machineId = parseMachineTaskEnvironmentLocator(
|
|
315
543
|
handle.descriptor.locator,
|
|
316
544
|
).machineId;
|
|
@@ -319,6 +547,7 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
319
547
|
result: {
|
|
320
548
|
composeProject: machineId,
|
|
321
549
|
worktreePath: deps.taskControlDir(request.taskId),
|
|
550
|
+
codeServerPort: MACHINE_CODE_SERVER_PORT,
|
|
322
551
|
...(warnings.length > 0
|
|
323
552
|
? { initWarning: warnings.join("\n") }
|
|
324
553
|
: {}),
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -475,6 +475,7 @@ export const hostCommands: HostCommands = {
|
|
|
475
475
|
value: {
|
|
476
476
|
composeProject: existingTask.composeProject ?? "",
|
|
477
477
|
worktreePath: existingTask.worktreePath ?? "",
|
|
478
|
+
codeServerPort: existingTask.codeServerPort ?? undefined,
|
|
478
479
|
},
|
|
479
480
|
};
|
|
480
481
|
}
|
|
@@ -957,6 +958,17 @@ export const hostCommands: HostCommands = {
|
|
|
957
958
|
}
|
|
958
959
|
const result = await wrapAgent(ctx, "taskDown", async () => {
|
|
959
960
|
if (environment === null) return agent.taskDown(input);
|
|
961
|
+
// ADR-121: an ordinary stop of a machine task must keep the machine's
|
|
962
|
+
// disk — the workspace lives THERE, not on a host worktree the way
|
|
963
|
+
// compose teardown preserves it. Stop is the resumable gesture
|
|
964
|
+
// (provision restarts a stopped machine); only orphan GC terminates.
|
|
965
|
+
if (
|
|
966
|
+
!orphanGc &&
|
|
967
|
+
environment.descriptor.locator.provider === "machine"
|
|
968
|
+
) {
|
|
969
|
+
await environment.stop();
|
|
970
|
+
return taskDownResultForInput(input, { status: "stopped" });
|
|
971
|
+
}
|
|
960
972
|
return taskDownResultForInput(input, await environment.teardown());
|
|
961
973
|
});
|
|
962
974
|
if (result.ok) {
|
package/src/main.ts
CHANGED
|
@@ -1966,6 +1966,26 @@ async function resolveTunnelTarget(
|
|
|
1966
1966
|
// Persisted ports are meaningful only while the selected runtime is known
|
|
1967
1967
|
// operational. After a daemon restart an unrelated local process could bind
|
|
1968
1968
|
// a stale port before recovery refreshes the task's mappings.
|
|
1969
|
+
const machineTask = getHostTask(frame.taskId);
|
|
1970
|
+
if (machineTask?.environmentProvider === "machine") {
|
|
1971
|
+
// ADR-121: a machine's address is orchestrator-routable (bridge IP on
|
|
1972
|
+
// the Linux guinea pig, VPC-private on EC2) — editor and previews dial
|
|
1973
|
+
// it straight via the handle's ownership-proven route, independent of
|
|
1974
|
+
// the container runtime's health. macOS-local machines need a published
|
|
1975
|
+
// fallback (bridge IPs are not host-routable there) — recorded ADR-121
|
|
1976
|
+
// follow-up, not silently misrouted: the connect simply fails.
|
|
1977
|
+
const environment = await reconstructPersistedTaskEnvironment(machineTask);
|
|
1978
|
+
const route = environment?.inspectRoute
|
|
1979
|
+
? await environment.inspectRoute()
|
|
1980
|
+
: null;
|
|
1981
|
+
if (!route || route.kind !== "running") return null;
|
|
1982
|
+
const port =
|
|
1983
|
+
frame.target === "editor"
|
|
1984
|
+
? (machineTask.codeServerPort ?? null)
|
|
1985
|
+
: (frame.containerPort ?? null);
|
|
1986
|
+
if (!port) return null;
|
|
1987
|
+
return { host: route.ipv4Address, port };
|
|
1988
|
+
}
|
|
1969
1989
|
if (containerRuntimeProblem()) return null;
|
|
1970
1990
|
const task = getHostTask(frame.taskId);
|
|
1971
1991
|
if (!task) return null;
|