@runuai/host 0.8.39 → 0.8.41

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.
@@ -0,0 +1,5 @@
1
+ -- ADR-078: Codex restart-survival. Persist the app-server thread id so a
2
+ -- respawned Codex (allowAttach:false → kill+respawn on host restart) can
3
+ -- `thread/resume` the SAME durable thread from its on-disk rollout instead of
4
+ -- starting a fresh one and losing the conversation. Null for non-codex kinds.
5
+ ALTER TABLE `host_agent_sessions` ADD `codex_thread_id` text;
@@ -78,6 +78,13 @@
78
78
  "when": 1779900011000,
79
79
  "tag": "0010_host_engine_accounts",
80
80
  "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "6",
85
+ "when": 1779900012000,
86
+ "tag": "0011_codex_thread_id",
87
+ "breakpoints": true
81
88
  }
82
89
  ]
83
90
  }
package/db/schema.ts CHANGED
@@ -144,6 +144,10 @@ export const hostAgentSessions = sqliteTable(
144
144
  .notNull()
145
145
  .default(0),
146
146
  status: text("status").notNull().default("running"), // running|closed
147
+ // ADR-078: Codex thread id (from thread/start). Persisted so a respawned
148
+ // app-server after a host restart resumes the SAME thread (thread/resume)
149
+ // instead of losing the conversation. Null for non-codex kinds / pre-resume.
150
+ codexThreadId: text("codex_thread_id"),
147
151
  createdAt: integer("created_at", { mode: "number" }).notNull(),
148
152
  updatedAt: integer("updated_at", { mode: "number" }).notNull(),
149
153
  },
@@ -32,6 +32,9 @@ 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";
36
+
37
+ import { getDb, schema } from "../db";
35
38
  import { newId } from "../ulid";
36
39
  import { createAgentTransport, type LineTransport } from "./transport";
37
40
  import { isRateLimitMessage } from "./rate-limit";
@@ -211,6 +214,64 @@ const APPROVAL_METHODS = new Set([
211
214
  * can legitimately run for minutes. */
212
215
  const RPC_TIMEOUT_MS = 30_000;
213
216
 
217
+ /**
218
+ * ADR-078 thread/resume lock retry. Right after a host restart the previous
219
+ * (durable, in-container) app-server may still hold the thread's rollout lock;
220
+ * `thread/resume` then fails with JSON-RPC -32600 until the stop we sent the old
221
+ * runner lands. Retry that specific error a bounded number of times.
222
+ */
223
+ const RESUME_LOCK_ATTEMPTS = 10;
224
+ const RESUME_LOCK_BACKOFF_MS = 1_000;
225
+
226
+ const delay = (ms: number): Promise<void> =>
227
+ new Promise((resolve) => setTimeout(resolve, ms));
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Thread persistence (ADR-078) — resume the SAME thread across a host restart.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ /** The Codex thread id persisted for (task, agent), or null if none yet. */
234
+ function readCodexThreadId(taskId: string, agentId: string): string | null {
235
+ try {
236
+ const row = getDb()
237
+ .select({ id: schema.hostAgentSessions.codexThreadId })
238
+ .from(schema.hostAgentSessions)
239
+ .where(
240
+ and(
241
+ eq(schema.hostAgentSessions.taskId, taskId),
242
+ eq(schema.hostAgentSessions.agentId, agentId),
243
+ ),
244
+ )
245
+ .get();
246
+ return row?.id ?? null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Persist the Codex thread id for (task, agent) so a later host process resumes
254
+ * it. Best-effort — a write failure just means the next restart starts a fresh
255
+ * thread (today's behavior). The transport's session-row upsert never clears
256
+ * this column, so it survives the kill+respawn of a host restart.
257
+ */
258
+ function saveCodexThreadId(taskId: string, agentId: string, threadId: string): void {
259
+ try {
260
+ getDb()
261
+ .update(schema.hostAgentSessions)
262
+ .set({ codexThreadId: threadId, updatedAt: Date.now() })
263
+ .where(
264
+ and(
265
+ eq(schema.hostAgentSessions.taskId, taskId),
266
+ eq(schema.hostAgentSessions.agentId, agentId),
267
+ ),
268
+ )
269
+ .run();
270
+ } catch {
271
+ /* best effort — see doc comment */
272
+ }
273
+ }
274
+
214
275
  // ---------------------------------------------------------------------------
215
276
  // The session.
216
277
  // ---------------------------------------------------------------------------
@@ -222,6 +283,9 @@ export class CodexSession implements AgentSession {
222
283
  private readonly proc: LineTransport;
223
284
  private readonly handlers = new Set<AgentEventHandler>();
224
285
  private readonly systemPreamble: string;
286
+ private readonly taskId: string;
287
+ /** ADR-078: a thread id persisted by a PRIOR host process, to resume. */
288
+ private readonly resumeThreadId: string | null;
225
289
  private closed = false;
226
290
  private handshakeOk = false;
227
291
 
@@ -245,6 +309,10 @@ export class CodexSession implements AgentSession {
245
309
  }) {
246
310
  this.agentId = args.agent.id;
247
311
  this.systemPreamble = args.systemPreamble;
312
+ this.taskId = args.taskId;
313
+ // ADR-078: read BEFORE createAgentTransport (whose session-row upsert
314
+ // preserves this column). Non-null → a host restart; resume the thread.
315
+ this.resumeThreadId = readCodexThreadId(args.taskId, args.agent.id);
248
316
 
249
317
  // The agent's model / effort (when set) are selected via config overrides
250
318
  // (`-c model=<model>`, `-c model_reasoning_effort=<effort>`) on the
@@ -328,7 +396,15 @@ export class CodexSession implements AgentSession {
328
396
  if (waiter) {
329
397
  this.pending.delete(id);
330
398
  if (isObj(msg.error)) {
331
- waiter.reject(new Error(str(msg.error.message, "rpc error")));
399
+ // Preserve the JSON-RPC code so thread/resume can retry -32600
400
+ // (the thread's single-writer lock still held by a dying app-server).
401
+ const code =
402
+ typeof msg.error.code === "number" ? msg.error.code : undefined;
403
+ waiter.reject(
404
+ Object.assign(new Error(str(msg.error.message, "rpc error")), {
405
+ rpcCode: code,
406
+ }),
407
+ );
332
408
  } else {
333
409
  waiter.resolve(msg.result);
334
410
  }
@@ -355,6 +431,7 @@ export class CodexSession implements AgentSession {
355
431
  const thread = msg.params.thread;
356
432
  if (isObj(thread) && typeof thread.id === "string") {
357
433
  this.threadId = thread.id;
434
+ saveCodexThreadId(this.taskId, this.agentId, thread.id);
358
435
  }
359
436
  }
360
437
  for (const ev of mapCodexNotification(method, msg.params)) {
@@ -448,11 +525,30 @@ export class CodexSession implements AgentSession {
448
525
  private async handshake(): Promise<void> {
449
526
  await this.request("initialize", {
450
527
  clientInfo: { name: "uai", version: "0.2" },
528
+ // ADR-078: unlock experimental fields. thread/resume's `excludeTurns`
529
+ // (resume without replaying history — see tryResumeThread) is gated on
530
+ // this; without it the app-server rejects the field with
531
+ // "thread/resume.excludeTurns requires experimentalApi capability" and
532
+ // we fall back to a fresh thread, losing the whole conversation.
533
+ capabilities: { experimentalApi: true },
451
534
  });
452
535
  this.notify("initialized", {});
453
- // The uai channel briefing (how to @-mention, the roster, the
454
- // project's defaultPrompt) goes in as developer instructions so it
455
- // applies to every turn on the thread.
536
+ // ADR-078: a thread id from a prior host process means this is a restart —
537
+ // resume the SAME durable thread so the conversation survives, instead of
538
+ // starting fresh. Fall back to a new thread if resume isn't possible
539
+ // (thread gone/corrupt, or the old writer never released the lock).
540
+ if (this.resumeThreadId && (await this.tryResumeThread(this.resumeThreadId))) {
541
+ this.handshakeOk = true;
542
+ return;
543
+ }
544
+ await this.startThread();
545
+ this.handshakeOk = true;
546
+ }
547
+
548
+ private async startThread(): Promise<void> {
549
+ // The uai channel briefing (how to @-mention, the roster, the project's
550
+ // defaultPrompt) goes in as developer instructions so it applies to every
551
+ // turn on the thread (persisted with the thread, so resume needn't resend).
456
552
  const threadParams: Record<string, unknown> = {
457
553
  approvalPolicy: "never",
458
554
  sandbox: "danger-full-access",
@@ -462,9 +558,51 @@ export class CodexSession implements AgentSession {
462
558
  }
463
559
  const result = await this.request("thread/start", threadParams);
464
560
  if (isObj(result) && isObj(result.thread)) {
465
- this.threadId = str(result.thread.id) || this.threadId;
561
+ const id = str(result.thread.id) || this.threadId;
562
+ this.threadId = id;
563
+ if (id) saveCodexThreadId(this.taskId, this.agentId, id);
466
564
  }
467
- this.handshakeOk = true;
565
+ }
566
+
567
+ /**
568
+ * Reopen the persisted thread by id (ADR-078). `excludeTurns:true` returns
569
+ * only metadata — no history replay, which would double-post the transcript
570
+ * the cloud already holds. The app-server allows one writer per thread, so a
571
+ * -32600 (lock still held by the dying predecessor app-server) is retried with
572
+ * backoff; any other failure returns false and the caller starts a fresh
573
+ * thread. Returns true iff the thread is now open for turns.
574
+ */
575
+ private async tryResumeThread(threadId: string): Promise<boolean> {
576
+ for (let attempt = 0; attempt < RESUME_LOCK_ATTEMPTS; attempt++) {
577
+ try {
578
+ const result = await this.request("thread/resume", {
579
+ threadId,
580
+ excludeTurns: true,
581
+ approvalPolicy: "never",
582
+ sandbox: "danger-full-access",
583
+ });
584
+ const id =
585
+ isObj(result) && isObj(result.thread) && str(result.thread.id)
586
+ ? str(result.thread.id)
587
+ : threadId;
588
+ this.threadId = id;
589
+ saveCodexThreadId(this.taskId, this.agentId, id);
590
+ return true;
591
+ } catch (err) {
592
+ const code = (err as { rpcCode?: number }).rpcCode;
593
+ if (code === -32600 && attempt < RESUME_LOCK_ATTEMPTS - 1) {
594
+ await delay(RESUME_LOCK_BACKOFF_MS);
595
+ continue;
596
+ }
597
+ console.warn(
598
+ `[codex] thread/resume ${threadId} failed (${
599
+ err instanceof Error ? err.message : String(err)
600
+ }) — starting a fresh thread`,
601
+ );
602
+ return false;
603
+ }
604
+ }
605
+ return false;
468
606
  }
469
607
 
470
608
  // -- AgentSession ---------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.39",
3
+ "version": "0.8.41",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",