@runuai/host 0.9.146 → 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.
@@ -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-policy generation. Both absent remains compatible. */
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
- (row.attachCompatibilityKey ?? null) ===
196
- (opts.attachCompatibilityKey ?? null) &&
197
- heartbeatFresh(row.sessionDir)
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
- (row.attachCompatibilityKey ?? null) ===
386
- (opts.attachCompatibilityKey ?? null)
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,
@@ -180,9 +180,9 @@ export interface AgentSessionFactory {
180
180
  /** Host-derived restricted execution policy (ADR-083). Never supplied by
181
181
  * the browser or stored on the roster entry. */
182
182
  executionProfile?: "communicator";
183
- /** Durable runners are keyed by task+agent. A host-owned policy change
184
- * supplies a new key so restart recovery replaces, rather than attaches,
185
- * a process launched with incompatible argv/preamble/tooling. */
183
+ /** Durable runners are keyed by task+agent. Host-owned launch settings
184
+ * supply a compatibility key so restart recovery replaces, rather than
185
+ * attaches, a process launched with incompatible model/effort/policy. */
186
186
  attachCompatibilityKey?: string;
187
187
  /** ADR-048: extra per-agent process environment (e.g. this agent's own
188
188
  * UAI_TASK_TOKEN so its `uai` CLI carries only its own permissions). */