@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.
package/lib/agent-cli.ts CHANGED
@@ -305,7 +305,22 @@ async function main() {
305
305
  case "memory search": {
306
306
  const q = pos.join(" ") || flags.q || "";
307
307
  const author = flags.as ? "&author=" + encodeURIComponent(flags.as) : "";
308
- out((await api("GET", "/api/agent/memory?q=" + encodeURIComponent(q) + author)).memories);
308
+ const limit = flags.limit ? "&limit=" + encodeURIComponent(String(flags.limit)) : "";
309
+ const res = await api(
310
+ "GET",
311
+ "/api/agent/memory?q=" + encodeURIComponent(q) + author + limit,
312
+ );
313
+ out(res.memories);
314
+ // Truncation goes to stderr so it cannot corrupt piped JSON, and it is
315
+ // stated rather than implied: recalling across tasks means reading the
316
+ // WHOLE store, and a short page that says nothing reads as all there is.
317
+ // NOTE: this file is a template literal. No backticks, no \${} in here.
318
+ if (res.truncated) {
319
+ console.error(
320
+ "uai: showing " + res.memories.length + " of " + res.total +
321
+ " — re-run with --limit all for every row.",
322
+ );
323
+ }
309
324
  break;
310
325
  }
311
326
  case "memory save": {
@@ -453,7 +468,7 @@ async function main() {
453
468
  " uai task list",
454
469
  " uai task create --name <n> --prompt <p> [--projects id,id] [--team id] [--agents handle,handle]",
455
470
  ' uai task close --status finished|canceled [--reason "final report"]',
456
- " uai memory search <query>",
471
+ " uai memory search <query> [--as handle] [--limit N|all]",
457
472
  " uai memory save <text> [--project id] [--tags a,b]",
458
473
  " uai memory delete <id>",
459
474
  " uai todo list",
@@ -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
+ }
@@ -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 if none yet. */
237
- function readCodexThreadId(taskId: string, agentId: string): string | null {
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({ id: schema.hostAgentSessions.codexThreadId })
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(taskId: string, agentId: string, threadId: string): void {
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(args.taskId, args.agent.id);
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
- saveCodexThreadId(this.taskId, this.agentId, thread.id);
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) saveCodexThreadId(this.taskId, this.agentId, 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
- saveCodexThreadId(this.taskId, this.agentId, id);
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 ({ taskId, agent, environment, systemPreamble, agentEnv, cliConfigOverrides }) =>
708
- new CodexSession({ taskId, agent, environment, systemPreamble, agentEnv, cliConfigOverrides }),
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
+ }