@runuai/host 0.9.147 → 0.9.148
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/agents/attach-compatibility.ts +51 -0
- package/lib/agents/codex.ts +128 -12
- package/lib/agents/durable-proc.ts +2 -0
- package/lib/agents/machine-durable.ts +15 -0
- package/lib/agents/session-state.ts +107 -0
- package/lib/agents/transport.ts +116 -53
- package/lib/agents/types.ts +3 -3
- package/lib/orchestrator.ts +1294 -73
- package/lib/ulid.ts +10 -1
- package/package.json +1 -1
- package/src/main.ts +2 -0
- package/src/protocol.ts +20 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility for the v0.9.147 -> v0.9.148 durable-session key rollout.
|
|
3
|
+
*
|
|
4
|
+
* Older hosts could not receive live roster edits and persisted only the
|
|
5
|
+
* execution-policy key: null for crew, or one of the Secretary policy
|
|
6
|
+
* constants. v0.9.148 adds kind/model/effort to that key. Treat the old row as
|
|
7
|
+
* compatible with the richer desired key only when its policy is unchanged;
|
|
8
|
+
* the transition is deliberately one-way so future/unknown keys remain exact.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const LEGACY_SECRETARY_POLICY_KEYS = new Set([
|
|
12
|
+
"secretary-normal-tools-v1",
|
|
13
|
+
"secretary-communicator-v1",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function rosterLaunchPolicy(key: string | null): string | null | undefined {
|
|
17
|
+
if (key === null) return undefined;
|
|
18
|
+
try {
|
|
19
|
+
const parsed: unknown = JSON.parse(key);
|
|
20
|
+
if (
|
|
21
|
+
!Array.isArray(parsed) ||
|
|
22
|
+
parsed.length !== 5 ||
|
|
23
|
+
parsed[0] !== "roster-launch-v1" ||
|
|
24
|
+
typeof parsed[1] !== "string" ||
|
|
25
|
+
typeof parsed[2] !== "string" ||
|
|
26
|
+
typeof parsed[3] !== "string" ||
|
|
27
|
+
typeof parsed[4] !== "string"
|
|
28
|
+
) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
return parsed[4] === "" ? null : parsed[4];
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function attachCompatibilityKeysMatch(
|
|
38
|
+
stored: string | null | undefined,
|
|
39
|
+
desired: string | null | undefined,
|
|
40
|
+
): boolean {
|
|
41
|
+
const storedKey = stored ?? null;
|
|
42
|
+
const desiredKey = desired ?? null;
|
|
43
|
+
if (storedKey === desiredKey) return true;
|
|
44
|
+
|
|
45
|
+
const desiredPolicy = rosterLaunchPolicy(desiredKey);
|
|
46
|
+
if (storedKey === null) return desiredPolicy === null;
|
|
47
|
+
return (
|
|
48
|
+
LEGACY_SECRETARY_POLICY_KEYS.has(storedKey) &&
|
|
49
|
+
desiredPolicy === storedKey
|
|
50
|
+
);
|
|
51
|
+
}
|
package/lib/agents/codex.ts
CHANGED
|
@@ -32,12 +32,13 @@ import { existsSync } from "node:fs";
|
|
|
32
32
|
import { homedir } from "node:os";
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
|
|
35
|
-
import { and, eq } from "drizzle-orm";
|
|
35
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
36
36
|
|
|
37
37
|
import { getDb, schema } from "../db";
|
|
38
38
|
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
39
39
|
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
40
40
|
import { newId } from "../ulid";
|
|
41
|
+
import { attachCompatibilityKeysMatch } from "./attach-compatibility";
|
|
41
42
|
import { createAgentTransport, type LineTransport } from "./transport";
|
|
42
43
|
import { isRateLimitMessage } from "./rate-limit";
|
|
43
44
|
import { register } from "./registry";
|
|
@@ -233,11 +234,21 @@ const delay = (ms: number): Promise<void> =>
|
|
|
233
234
|
// Thread persistence (ADR-078) — resume the SAME thread across a host restart.
|
|
234
235
|
// ---------------------------------------------------------------------------
|
|
235
236
|
|
|
236
|
-
/** The Codex thread id persisted for (task, agent), or null
|
|
237
|
-
function readCodexThreadId(
|
|
237
|
+
/** The compatible Codex thread id persisted for (task, agent), or null. */
|
|
238
|
+
function readCodexThreadId(
|
|
239
|
+
taskId: string,
|
|
240
|
+
agentId: string,
|
|
241
|
+
attachCompatibilityKey: string | undefined,
|
|
242
|
+
): string | null {
|
|
238
243
|
try {
|
|
239
244
|
const row = getDb()
|
|
240
|
-
.select({
|
|
245
|
+
.select({
|
|
246
|
+
id: schema.hostAgentSessions.codexThreadId,
|
|
247
|
+
kind: schema.hostAgentSessions.kind,
|
|
248
|
+
sessionDir: schema.hostAgentSessions.sessionDir,
|
|
249
|
+
attachCompatibilityKey:
|
|
250
|
+
schema.hostAgentSessions.attachCompatibilityKey,
|
|
251
|
+
})
|
|
241
252
|
.from(schema.hostAgentSessions)
|
|
242
253
|
.where(
|
|
243
254
|
and(
|
|
@@ -246,6 +257,31 @@ function readCodexThreadId(taskId: string, agentId: string): string | null {
|
|
|
246
257
|
),
|
|
247
258
|
)
|
|
248
259
|
.get();
|
|
260
|
+
if (
|
|
261
|
+
row?.kind !== "codex" ||
|
|
262
|
+
!attachCompatibilityKeysMatch(
|
|
263
|
+
row.attachCompatibilityKey,
|
|
264
|
+
attachCompatibilityKey,
|
|
265
|
+
)
|
|
266
|
+
) {
|
|
267
|
+
// Transport will promote the row to the new launch key while preserving
|
|
268
|
+
// codexThreadId. Clear the incompatible id first so a crash between that
|
|
269
|
+
// upsert and thread/started cannot make the next host resume it.
|
|
270
|
+
if (row?.id && row.sessionDir) {
|
|
271
|
+
getDb()
|
|
272
|
+
.update(schema.hostAgentSessions)
|
|
273
|
+
.set({ codexThreadId: null, updatedAt: Date.now() })
|
|
274
|
+
.where(
|
|
275
|
+
and(
|
|
276
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
277
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
278
|
+
eq(schema.hostAgentSessions.sessionDir, row.sessionDir),
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
.run();
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
249
285
|
return row?.id ?? null;
|
|
250
286
|
} catch {
|
|
251
287
|
return null;
|
|
@@ -258,15 +294,57 @@ function readCodexThreadId(taskId: string, agentId: string): string | null {
|
|
|
258
294
|
* thread (today's behavior). The transport's session-row upsert never clears
|
|
259
295
|
* this column, so it survives the kill+respawn of a host restart.
|
|
260
296
|
*/
|
|
261
|
-
function saveCodexThreadId(
|
|
297
|
+
function saveCodexThreadId(
|
|
298
|
+
taskId: string,
|
|
299
|
+
agentId: string,
|
|
300
|
+
threadId: string,
|
|
301
|
+
sessionDir: string | undefined,
|
|
302
|
+
attachCompatibilityKey: string | undefined,
|
|
303
|
+
): void {
|
|
304
|
+
if (!sessionDir) return;
|
|
262
305
|
try {
|
|
263
|
-
getDb()
|
|
306
|
+
const db = getDb();
|
|
307
|
+
const row = db
|
|
308
|
+
.select({
|
|
309
|
+
kind: schema.hostAgentSessions.kind,
|
|
310
|
+
sessionDir: schema.hostAgentSessions.sessionDir,
|
|
311
|
+
attachCompatibilityKey:
|
|
312
|
+
schema.hostAgentSessions.attachCompatibilityKey,
|
|
313
|
+
})
|
|
314
|
+
.from(schema.hostAgentSessions)
|
|
315
|
+
.where(
|
|
316
|
+
and(
|
|
317
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
318
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
.get();
|
|
322
|
+
const persistedKey = row?.attachCompatibilityKey ?? null;
|
|
323
|
+
if (
|
|
324
|
+
row?.kind !== "codex" ||
|
|
325
|
+
row.sessionDir !== sessionDir ||
|
|
326
|
+
!attachCompatibilityKeysMatch(
|
|
327
|
+
row.attachCompatibilityKey,
|
|
328
|
+
attachCompatibilityKey,
|
|
329
|
+
)
|
|
330
|
+
) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
db
|
|
264
334
|
.update(schema.hostAgentSessions)
|
|
265
335
|
.set({ codexThreadId: threadId, updatedAt: Date.now() })
|
|
266
336
|
.where(
|
|
267
337
|
and(
|
|
268
338
|
eq(schema.hostAgentSessions.taskId, taskId),
|
|
269
339
|
eq(schema.hostAgentSessions.agentId, agentId),
|
|
340
|
+
eq(schema.hostAgentSessions.kind, "codex"),
|
|
341
|
+
eq(schema.hostAgentSessions.sessionDir, sessionDir),
|
|
342
|
+
persistedKey === null
|
|
343
|
+
? isNull(schema.hostAgentSessions.attachCompatibilityKey)
|
|
344
|
+
: eq(
|
|
345
|
+
schema.hostAgentSessions.attachCompatibilityKey,
|
|
346
|
+
persistedKey,
|
|
347
|
+
),
|
|
270
348
|
),
|
|
271
349
|
)
|
|
272
350
|
.run();
|
|
@@ -302,6 +380,7 @@ export class CodexSession implements AgentSession {
|
|
|
302
380
|
|
|
303
381
|
private threadId: string | null = null;
|
|
304
382
|
private readonly ready: Promise<void>;
|
|
383
|
+
private readonly attachCompatibilityKey: string | undefined;
|
|
305
384
|
|
|
306
385
|
constructor(args: {
|
|
307
386
|
taskId: string;
|
|
@@ -309,15 +388,21 @@ export class CodexSession implements AgentSession {
|
|
|
309
388
|
environment: TaskEnvironmentAgentSessionSurface;
|
|
310
389
|
systemPreamble: string;
|
|
311
390
|
agentEnv?: Record<string, string>;
|
|
391
|
+
attachCompatibilityKey?: string;
|
|
312
392
|
/** ADR-128: `-c key=value` overrides a delegating adapter prepends. */
|
|
313
393
|
cliConfigOverrides?: string[];
|
|
314
394
|
}) {
|
|
315
395
|
this.agentId = args.agent.id;
|
|
316
396
|
this.systemPreamble = args.systemPreamble;
|
|
317
397
|
this.taskId = args.taskId;
|
|
398
|
+
this.attachCompatibilityKey = args.attachCompatibilityKey;
|
|
318
399
|
// ADR-078: read BEFORE createAgentTransport (whose session-row upsert
|
|
319
400
|
// preserves this column). Non-null → a host restart; resume the thread.
|
|
320
|
-
this.resumeThreadId = readCodexThreadId(
|
|
401
|
+
this.resumeThreadId = readCodexThreadId(
|
|
402
|
+
args.taskId,
|
|
403
|
+
args.agent.id,
|
|
404
|
+
args.attachCompatibilityKey,
|
|
405
|
+
);
|
|
321
406
|
|
|
322
407
|
// The agent's model / effort (when set) are selected via config overrides
|
|
323
408
|
// (`-c model=<model>`, `-c model_reasoning_effort=<effort>`) on the
|
|
@@ -349,6 +434,7 @@ export class CodexSession implements AgentSession {
|
|
|
349
434
|
passEnv: [],
|
|
350
435
|
explicitEnv: args.agentEnv ?? {},
|
|
351
436
|
allowAttach: false,
|
|
437
|
+
attachCompatibilityKey: args.attachCompatibilityKey,
|
|
352
438
|
kind: "codex",
|
|
353
439
|
debugLabel: `codex:${this.agentId}`,
|
|
354
440
|
});
|
|
@@ -387,6 +473,10 @@ export class CodexSession implements AgentSession {
|
|
|
387
473
|
// -- JSON-RPC plumbing ----------------------------------------------------
|
|
388
474
|
|
|
389
475
|
private onLine(line: string): void {
|
|
476
|
+
// close() severs this generation before the durable runner necessarily
|
|
477
|
+
// exits. Ignore buffered notifications so a late thread/started cannot
|
|
478
|
+
// restore the resume id that a live roster edit just cleared.
|
|
479
|
+
if (this.closed) return;
|
|
390
480
|
let msg: unknown;
|
|
391
481
|
try {
|
|
392
482
|
msg = JSON.parse(line);
|
|
@@ -439,7 +529,7 @@ export class CodexSession implements AgentSession {
|
|
|
439
529
|
const thread = msg.params.thread;
|
|
440
530
|
if (isObj(thread) && typeof thread.id === "string") {
|
|
441
531
|
this.threadId = thread.id;
|
|
442
|
-
|
|
532
|
+
this.persistThreadId(thread.id);
|
|
443
533
|
}
|
|
444
534
|
}
|
|
445
535
|
for (const ev of mapCodexNotification(method, msg.params)) {
|
|
@@ -568,7 +658,7 @@ export class CodexSession implements AgentSession {
|
|
|
568
658
|
if (isObj(result) && isObj(result.thread)) {
|
|
569
659
|
const id = str(result.thread.id) || this.threadId;
|
|
570
660
|
this.threadId = id;
|
|
571
|
-
if (id)
|
|
661
|
+
if (id) this.persistThreadId(id);
|
|
572
662
|
}
|
|
573
663
|
}
|
|
574
664
|
|
|
@@ -594,7 +684,7 @@ export class CodexSession implements AgentSession {
|
|
|
594
684
|
? str(result.thread.id)
|
|
595
685
|
: threadId;
|
|
596
686
|
this.threadId = id;
|
|
597
|
-
|
|
687
|
+
this.persistThreadId(id);
|
|
598
688
|
return true;
|
|
599
689
|
} catch (err) {
|
|
600
690
|
const code = (err as { rpcCode?: number }).rpcCode;
|
|
@@ -615,6 +705,16 @@ export class CodexSession implements AgentSession {
|
|
|
615
705
|
|
|
616
706
|
// -- AgentSession ---------------------------------------------------------
|
|
617
707
|
|
|
708
|
+
private persistThreadId(threadId: string): void {
|
|
709
|
+
saveCodexThreadId(
|
|
710
|
+
this.taskId,
|
|
711
|
+
this.agentId,
|
|
712
|
+
threadId,
|
|
713
|
+
this.proc.durableSessionDir,
|
|
714
|
+
this.attachCompatibilityKey,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
|
|
618
718
|
onEvent(handler: AgentEventHandler): () => void {
|
|
619
719
|
this.handlers.add(handler);
|
|
620
720
|
return () => this.handlers.delete(handler);
|
|
@@ -704,6 +804,22 @@ register({
|
|
|
704
804
|
// provider block rides it, and this destructuring silently dropped it on
|
|
705
805
|
// the first live gpt-5-mini smoke (2026-09-12, host 0.9.136 — codex still
|
|
706
806
|
// dialed api.openai.com).
|
|
707
|
-
create: async ({
|
|
708
|
-
|
|
807
|
+
create: async ({
|
|
808
|
+
taskId,
|
|
809
|
+
agent,
|
|
810
|
+
environment,
|
|
811
|
+
systemPreamble,
|
|
812
|
+
agentEnv,
|
|
813
|
+
attachCompatibilityKey,
|
|
814
|
+
cliConfigOverrides,
|
|
815
|
+
}) =>
|
|
816
|
+
new CodexSession({
|
|
817
|
+
taskId,
|
|
818
|
+
agent,
|
|
819
|
+
environment,
|
|
820
|
+
systemPreamble,
|
|
821
|
+
agentEnv,
|
|
822
|
+
attachCompatibilityKey,
|
|
823
|
+
cliConfigOverrides,
|
|
824
|
+
}),
|
|
709
825
|
});
|
|
@@ -72,6 +72,7 @@ interface RunnerMeta {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export class DurableProcess {
|
|
75
|
+
readonly durableSessionDir: string;
|
|
75
76
|
private readonly dir: string;
|
|
76
77
|
private readonly outboxPath: string;
|
|
77
78
|
private readonly inboxPath: string;
|
|
@@ -95,6 +96,7 @@ export class DurableProcess {
|
|
|
95
96
|
private readonly debug: string | null;
|
|
96
97
|
|
|
97
98
|
constructor(opts: DurableProcessOptions) {
|
|
99
|
+
this.durableSessionDir = opts.hostSessionDir;
|
|
98
100
|
this.dir = opts.hostSessionDir;
|
|
99
101
|
this.outboxPath = join(this.dir, "outbox.jsonl");
|
|
100
102
|
this.inboxPath = join(this.dir, "inbox.jsonl");
|
|
@@ -129,6 +129,7 @@ export interface MachineDurableOptions {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
export class MachineDurableProcess implements LineTransport {
|
|
132
|
+
readonly durableSessionDir: string;
|
|
132
133
|
readonly #environment: MachineSessionEnvironment;
|
|
133
134
|
readonly #sessionDir: string;
|
|
134
135
|
readonly #outboxPath: string;
|
|
@@ -158,6 +159,7 @@ export class MachineDurableProcess implements LineTransport {
|
|
|
158
159
|
readonly #debug: string | null;
|
|
159
160
|
|
|
160
161
|
constructor(opts: MachineDurableOptions) {
|
|
162
|
+
this.durableSessionDir = opts.sessionDir;
|
|
161
163
|
this.#environment = opts.environment;
|
|
162
164
|
this.#sessionDir = opts.sessionDir;
|
|
163
165
|
this.#outboxPath = posix.join(opts.sessionDir, "outbox.jsonl");
|
|
@@ -563,3 +565,16 @@ export async function stopMachinePredecessor(
|
|
|
563
565
|
// Dir gone / runner dead — nothing to stop.
|
|
564
566
|
}
|
|
565
567
|
}
|
|
568
|
+
|
|
569
|
+
/** Removal/recovery variant: failure is not success because no later attach
|
|
570
|
+
* will provide the transport layer's usual predecessor-stop backstop. */
|
|
571
|
+
export async function stopMachinePredecessorStrict(
|
|
572
|
+
environment: MachineSessionEnvironment,
|
|
573
|
+
sessionDir: string,
|
|
574
|
+
): Promise<void> {
|
|
575
|
+
await appendEnvironmentFile(
|
|
576
|
+
environment,
|
|
577
|
+
posix.join(sessionDir, "inbox.jsonl"),
|
|
578
|
+
'{"__uai":"stop"}\n',
|
|
579
|
+
);
|
|
580
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable agent-session state used by live roster reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* The orchestrator normally owns a live AgentSession and closes it through the
|
|
5
|
+
* adapter. After a host restart, however, a removed agent can exist only as a
|
|
6
|
+
* host_agent_sessions row. These helpers stop that persisted runner
|
|
7
|
+
* without deleting its row or generation directory, and clear Codex's durable
|
|
8
|
+
* thread id when launch settings change.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { promises as fsp } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { and, eq } from "drizzle-orm";
|
|
15
|
+
|
|
16
|
+
import { getDb, schema } from "../db";
|
|
17
|
+
import type { TaskEnvironmentHandle } from "../task-environment/types";
|
|
18
|
+
import { isEnvironmentTransportProvider } from "../task-environment/types";
|
|
19
|
+
import {
|
|
20
|
+
stopMachinePredecessorStrict,
|
|
21
|
+
type MachineSessionEnvironment,
|
|
22
|
+
} from "./machine-durable";
|
|
23
|
+
|
|
24
|
+
/** Durable-session ids persisted for one task, including rows marked closed.
|
|
25
|
+
* A crash can land after markClosed but before its stop reaches the runner. */
|
|
26
|
+
export function persistedAgentIds(taskId: string): string[] {
|
|
27
|
+
return getDb()
|
|
28
|
+
.select({ agentId: schema.hostAgentSessions.agentId })
|
|
29
|
+
.from(schema.hostAgentSessions)
|
|
30
|
+
.where(eq(schema.hostAgentSessions.taskId, taskId))
|
|
31
|
+
.all()
|
|
32
|
+
.map((row) => row.agentId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A changed model/engine/effort starts a fresh Codex thread. The channel
|
|
37
|
+
* transcript is the durable memory; resuming a thread created under the old
|
|
38
|
+
* launch settings can make the replacement keep using the old model.
|
|
39
|
+
*/
|
|
40
|
+
export function clearPersistedCodexThread(
|
|
41
|
+
taskId: string,
|
|
42
|
+
agentId: string,
|
|
43
|
+
): void {
|
|
44
|
+
getDb()
|
|
45
|
+
.update(schema.hostAgentSessions)
|
|
46
|
+
.set({ codexThreadId: null, updatedAt: Date.now() })
|
|
47
|
+
.where(
|
|
48
|
+
and(
|
|
49
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
50
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
.run();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop a persisted runner that has no in-memory AgentSession (boot recovery).
|
|
58
|
+
* The session directory and DB row deliberately remain for diagnostics.
|
|
59
|
+
*/
|
|
60
|
+
export async function stopPersistedAgentSession(
|
|
61
|
+
taskId: string,
|
|
62
|
+
agentId: string,
|
|
63
|
+
environment: TaskEnvironmentHandle<unknown>,
|
|
64
|
+
): Promise<string | null> {
|
|
65
|
+
const db = getDb();
|
|
66
|
+
const row = db
|
|
67
|
+
.select()
|
|
68
|
+
.from(schema.hostAgentSessions)
|
|
69
|
+
.where(
|
|
70
|
+
and(
|
|
71
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
72
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
.get();
|
|
76
|
+
if (!row) return null;
|
|
77
|
+
|
|
78
|
+
if (isEnvironmentTransportProvider(environment.descriptor.locator.provider)) {
|
|
79
|
+
await stopMachinePredecessorStrict(
|
|
80
|
+
environment as MachineSessionEnvironment,
|
|
81
|
+
row.sessionDir,
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
try {
|
|
85
|
+
await fsp.appendFile(
|
|
86
|
+
join(row.sessionDir, "inbox.jsonl"),
|
|
87
|
+
'{"__uai":"stop"}\n',
|
|
88
|
+
);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// A removed generation directory proves there is no runner left to read
|
|
91
|
+
// it. Permission, I/O, and other failures are not proof: retry them.
|
|
92
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
db.update(schema.hostAgentSessions)
|
|
97
|
+
.set({ status: "closed", updatedAt: Date.now() })
|
|
98
|
+
.where(
|
|
99
|
+
and(
|
|
100
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
101
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
102
|
+
eq(schema.hostAgentSessions.sessionDir, row.sessionDir),
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
.run();
|
|
106
|
+
return row.sessionDir;
|
|
107
|
+
}
|
package/lib/agents/transport.ts
CHANGED
|
@@ -33,9 +33,9 @@ import {
|
|
|
33
33
|
} from "node:fs";
|
|
34
34
|
import { basename, dirname, join } from "node:path";
|
|
35
35
|
|
|
36
|
-
import { and, eq } from "drizzle-orm";
|
|
36
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
37
37
|
|
|
38
|
-
import { getDb, schema } from "../db";
|
|
38
|
+
import { getDb, schema, type Db } from "../db";
|
|
39
39
|
import { taskWorkspaceDir } from "../env";
|
|
40
40
|
import { machineSessionAuthorityEnv } from "../runtime-authority";
|
|
41
41
|
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
@@ -56,9 +56,12 @@ import {
|
|
|
56
56
|
type LineHandler,
|
|
57
57
|
} from "./proc";
|
|
58
58
|
import { isEnvironmentTransportProvider } from "../task-environment/types";
|
|
59
|
+
import { attachCompatibilityKeysMatch } from "./attach-compatibility";
|
|
59
60
|
|
|
60
61
|
/** The shared surface adapters program against (LineProcess's shape). */
|
|
61
62
|
export interface LineTransport {
|
|
63
|
+
/** Exact durable row generation owned by this transport, when durable. */
|
|
64
|
+
readonly durableSessionDir?: string;
|
|
62
65
|
onLine(handler: LineHandler): void;
|
|
63
66
|
onExit(handler: ExitHandler): void;
|
|
64
67
|
writeLine(value: unknown): void;
|
|
@@ -84,7 +87,7 @@ export interface AgentTransportOptions {
|
|
|
84
87
|
* protocol state can't outlive the host process (codex handshake).
|
|
85
88
|
*/
|
|
86
89
|
allowAttach: boolean;
|
|
87
|
-
/** Host-owned launch-
|
|
90
|
+
/** Host-owned launch-settings generation. Both absent remains compatible. */
|
|
88
91
|
attachCompatibilityKey?: string;
|
|
89
92
|
kind: string;
|
|
90
93
|
debugLabel?: string;
|
|
@@ -120,6 +123,73 @@ function durableEnabled(): boolean {
|
|
|
120
123
|
return process.env.UAI_DURABLE_SESSIONS !== "0";
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
/** Durable rows are keyed by task + agent for lookup, but callbacks belong to
|
|
127
|
+
* one exact generation. A delayed offset/exit from the predecessor must never
|
|
128
|
+
* mutate the replacement row after its upsert. */
|
|
129
|
+
function durableRowCallbacks(
|
|
130
|
+
db: Db,
|
|
131
|
+
taskId: string,
|
|
132
|
+
agentId: string,
|
|
133
|
+
sessionDir: string,
|
|
134
|
+
): {
|
|
135
|
+
persistOffset(offset: number): void;
|
|
136
|
+
markClosed(): void;
|
|
137
|
+
} {
|
|
138
|
+
const ownedRow = () =>
|
|
139
|
+
and(
|
|
140
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
141
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
142
|
+
eq(schema.hostAgentSessions.sessionDir, sessionDir),
|
|
143
|
+
);
|
|
144
|
+
return {
|
|
145
|
+
persistOffset(offset: number): void {
|
|
146
|
+
db.update(schema.hostAgentSessions)
|
|
147
|
+
.set({ outboxOffset: offset, updatedAt: Date.now() })
|
|
148
|
+
.where(ownedRow())
|
|
149
|
+
.run();
|
|
150
|
+
},
|
|
151
|
+
markClosed(): void {
|
|
152
|
+
db.update(schema.hostAgentSessions)
|
|
153
|
+
.set({ status: "closed", updatedAt: Date.now() })
|
|
154
|
+
.where(ownedRow())
|
|
155
|
+
.run();
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Claim a compatible durable row and promote a one-release legacy key to the
|
|
161
|
+
* rich launch fingerprint. The exact generation/key fence prevents a stale
|
|
162
|
+
* host process from rewriting a replacement row after reading its snapshot. */
|
|
163
|
+
function claimCompatibleAttachRow(
|
|
164
|
+
db: Db,
|
|
165
|
+
taskId: string,
|
|
166
|
+
agentId: string,
|
|
167
|
+
sessionDir: string,
|
|
168
|
+
stored: string | null | undefined,
|
|
169
|
+
desired: string | null | undefined,
|
|
170
|
+
): boolean {
|
|
171
|
+
if (!attachCompatibilityKeysMatch(stored, desired)) return false;
|
|
172
|
+
const storedKey = stored ?? null;
|
|
173
|
+
const desiredKey = desired ?? null;
|
|
174
|
+
if (storedKey === desiredKey) return true;
|
|
175
|
+
if (desiredKey === null) return false;
|
|
176
|
+
const result = db
|
|
177
|
+
.update(schema.hostAgentSessions)
|
|
178
|
+
.set({ attachCompatibilityKey: desiredKey, updatedAt: Date.now() })
|
|
179
|
+
.where(
|
|
180
|
+
and(
|
|
181
|
+
eq(schema.hostAgentSessions.taskId, taskId),
|
|
182
|
+
eq(schema.hostAgentSessions.agentId, agentId),
|
|
183
|
+
eq(schema.hostAgentSessions.sessionDir, sessionDir),
|
|
184
|
+
storedKey === null
|
|
185
|
+
? isNull(schema.hostAgentSessions.attachCompatibilityKey)
|
|
186
|
+
: eq(schema.hostAgentSessions.attachCompatibilityKey, storedKey),
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
.run();
|
|
190
|
+
return result.changes === 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
123
193
|
export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
|
|
124
194
|
// ADR-121: machine-backed sessions ride the environment's own transport —
|
|
125
195
|
// the container-runtime preflight is a docker/apple concern a machine task
|
|
@@ -161,29 +231,6 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
161
231
|
)
|
|
162
232
|
.get();
|
|
163
233
|
|
|
164
|
-
const persistOffset = (offset: number): void => {
|
|
165
|
-
db.update(schema.hostAgentSessions)
|
|
166
|
-
.set({ outboxOffset: offset, updatedAt: Date.now() })
|
|
167
|
-
.where(
|
|
168
|
-
and(
|
|
169
|
-
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
170
|
-
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
171
|
-
),
|
|
172
|
-
)
|
|
173
|
-
.run();
|
|
174
|
-
};
|
|
175
|
-
const markClosed = (): void => {
|
|
176
|
-
db.update(schema.hostAgentSessions)
|
|
177
|
-
.set({ status: "closed", updatedAt: Date.now() })
|
|
178
|
-
.where(
|
|
179
|
-
and(
|
|
180
|
-
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
181
|
-
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
182
|
-
),
|
|
183
|
-
)
|
|
184
|
-
.run();
|
|
185
|
-
};
|
|
186
|
-
|
|
187
234
|
const tailKey = `${opts.taskId}:${opts.agentId}`;
|
|
188
235
|
|
|
189
236
|
// ---- Attach: a previous host process left this agent's runner alive. ----
|
|
@@ -192,10 +239,23 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
192
239
|
row &&
|
|
193
240
|
row.status === "running" &&
|
|
194
241
|
row.containerName === opts.environment.durableIdentity &&
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
242
|
+
row.kind === opts.kind &&
|
|
243
|
+
heartbeatFresh(row.sessionDir) &&
|
|
244
|
+
claimCompatibleAttachRow(
|
|
245
|
+
db,
|
|
246
|
+
opts.taskId,
|
|
247
|
+
opts.agentId,
|
|
248
|
+
row.sessionDir,
|
|
249
|
+
row.attachCompatibilityKey,
|
|
250
|
+
opts.attachCompatibilityKey,
|
|
251
|
+
)
|
|
198
252
|
) {
|
|
253
|
+
const { persistOffset, markClosed } = durableRowCallbacks(
|
|
254
|
+
db,
|
|
255
|
+
opts.taskId,
|
|
256
|
+
opts.agentId,
|
|
257
|
+
row.sessionDir,
|
|
258
|
+
);
|
|
199
259
|
const proc = new DurableProcess({
|
|
200
260
|
hostSessionDir: row.sessionDir,
|
|
201
261
|
initialOutboxOffset: row.outboxOffset,
|
|
@@ -240,6 +300,12 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
240
300
|
opts.agentId,
|
|
241
301
|
basename(sessionDir),
|
|
242
302
|
);
|
|
303
|
+
const { persistOffset, markClosed } = durableRowCallbacks(
|
|
304
|
+
db,
|
|
305
|
+
opts.taskId,
|
|
306
|
+
opts.agentId,
|
|
307
|
+
sessionDir,
|
|
308
|
+
);
|
|
243
309
|
const runnerInEnvironment = join(environmentSessionDir, "runner.mjs");
|
|
244
310
|
try {
|
|
245
311
|
copyFileSync(runnerScriptPath(), runnerInSession);
|
|
@@ -350,28 +416,6 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
|
|
|
350
416
|
),
|
|
351
417
|
)
|
|
352
418
|
.get();
|
|
353
|
-
const persistOffset = (offset: number): void => {
|
|
354
|
-
db.update(schema.hostAgentSessions)
|
|
355
|
-
.set({ outboxOffset: offset, updatedAt: Date.now() })
|
|
356
|
-
.where(
|
|
357
|
-
and(
|
|
358
|
-
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
359
|
-
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
360
|
-
),
|
|
361
|
-
)
|
|
362
|
-
.run();
|
|
363
|
-
};
|
|
364
|
-
const markClosed = (): void => {
|
|
365
|
-
db.update(schema.hostAgentSessions)
|
|
366
|
-
.set({ status: "closed", updatedAt: Date.now() })
|
|
367
|
-
.where(
|
|
368
|
-
and(
|
|
369
|
-
eq(schema.hostAgentSessions.taskId, opts.taskId),
|
|
370
|
-
eq(schema.hostAgentSessions.agentId, opts.agentId),
|
|
371
|
-
),
|
|
372
|
-
)
|
|
373
|
-
.run();
|
|
374
|
-
};
|
|
375
419
|
const tailKey = `${opts.taskId}:${opts.agentId}`;
|
|
376
420
|
|
|
377
421
|
// ---- Attach: a previous host process left this machine's runner alive. --
|
|
@@ -382,9 +426,22 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
|
|
|
382
426
|
row &&
|
|
383
427
|
row.status === "running" &&
|
|
384
428
|
row.containerName === environment.durableIdentity &&
|
|
385
|
-
|
|
386
|
-
|
|
429
|
+
row.kind === opts.kind &&
|
|
430
|
+
claimCompatibleAttachRow(
|
|
431
|
+
db,
|
|
432
|
+
opts.taskId,
|
|
433
|
+
opts.agentId,
|
|
434
|
+
row.sessionDir,
|
|
435
|
+
row.attachCompatibilityKey,
|
|
436
|
+
opts.attachCompatibilityKey,
|
|
437
|
+
)
|
|
387
438
|
) {
|
|
439
|
+
const { persistOffset, markClosed } = durableRowCallbacks(
|
|
440
|
+
db,
|
|
441
|
+
opts.taskId,
|
|
442
|
+
opts.agentId,
|
|
443
|
+
row.sessionDir,
|
|
444
|
+
);
|
|
388
445
|
const proc = new MachineDurableProcess({
|
|
389
446
|
environment,
|
|
390
447
|
sessionDir: row.sessionDir,
|
|
@@ -406,6 +463,12 @@ function machineDurableTransport(opts: AgentTransportOptions): LineTransport {
|
|
|
406
463
|
environment.descriptor.workspacePath,
|
|
407
464
|
opts.agentId,
|
|
408
465
|
);
|
|
466
|
+
const { persistOffset, markClosed } = durableRowCallbacks(
|
|
467
|
+
db,
|
|
468
|
+
opts.taskId,
|
|
469
|
+
opts.agentId,
|
|
470
|
+
sessionDir,
|
|
471
|
+
);
|
|
409
472
|
const proc = new MachineDurableProcess({
|
|
410
473
|
environment,
|
|
411
474
|
sessionDir,
|