@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;
|
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
|
},
|
package/lib/agents/codex.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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