@runuai/host 0.9.94 → 0.9.95

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.
@@ -345,6 +345,46 @@ export function pickEngineAccount(
345
345
  return selectAccount(accounts, { exclude: opts.exclude, now: opts.now });
346
346
  }
347
347
 
348
+ /**
349
+ * ADR-076 + codex-cli 0.152.0: two codex agents sharing one account share one
350
+ * CODEX_HOME, and codex's sqlite state runtime cannot be opened by two
351
+ * concurrent processes. These pure helpers give each codex agent its own
352
+ * home (a copy of the account's) — the orchestrator wraps them with the
353
+ * container exec. Only codex config-dir homes under /home/node/.codex are
354
+ * isolated; ambient/env accounts and other kinds are left untouched.
355
+ */
356
+ export function codexHomeIsolatable(
357
+ kind: string,
358
+ codexHome: string | undefined,
359
+ ): codexHome is string {
360
+ return (
361
+ kind === "codex" &&
362
+ typeof codexHome === "string" &&
363
+ codexHome.startsWith("/home/node/.codex")
364
+ );
365
+ }
366
+
367
+ /** The per-agent home for a shared account home. Agent id is sanitized so it
368
+ * is always a safe path segment. */
369
+ export function codexPerAgentHome(codexHome: string, agentId: string): string {
370
+ const safe = agentId.replace(/[^A-Za-z0-9_-]/g, "_");
371
+ return `${codexHome}-agent-${safe}`;
372
+ }
373
+
374
+ /** The idempotent seed command: copy the account home into the per-agent home
375
+ * ONCE (only when the per-agent home is still empty), node-owned. */
376
+ export function codexHomeSeedCommand(
377
+ codexHome: string,
378
+ perAgentHome: string,
379
+ ): string {
380
+ return (
381
+ `set -e; mkdir -p "${perAgentHome}"; ` +
382
+ `if [ -z "$(ls -A "${perAgentHome}" 2>/dev/null)" ]; then ` +
383
+ `cp -a "${codexHome}/." "${perAgentHome}/" 2>/dev/null || true; fi; ` +
384
+ `chown -R node:node "${perAgentHome}"`
385
+ );
386
+ }
387
+
348
388
  /** Test hook: clear in-memory rotation state. */
349
389
  export function resetRotationState(): void {
350
390
  rotationState.clear();
@@ -111,6 +111,9 @@ import {
111
111
  pickEngineAccount,
112
112
  provisionEngineAccounts,
113
113
  resolveEngineAccounts,
114
+ codexHomeIsolatable,
115
+ codexPerAgentHome,
116
+ codexHomeSeedCommand,
114
117
  type ResolvedEngineAccount,
115
118
  } from "./engine-accounts";
116
119
  import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
@@ -1612,15 +1615,74 @@ export class Orchestrator {
1612
1615
  return operation;
1613
1616
  }
1614
1617
 
1618
+ /**
1619
+ * Per-agent CODEX_HOME (see the call site). Returns `args` unchanged unless
1620
+ * this is a codex agent with a config-dir CODEX_HOME; then it seeds
1621
+ * `<home>-agent-<agentId>` from `<home>` once (in-container cp as root,
1622
+ * node-owned) and points this agent's env there. Best-effort: a seed
1623
+ * failure logs and falls back to the shared home rather than blocking the
1624
+ * session — one shared home is the pre-fix behavior, not a regression.
1625
+ */
1626
+ private async isolateCodexHome(
1627
+ environment: TaskEnvironmentHandle<unknown>,
1628
+ agentId: string,
1629
+ args: Omit<Parameters<AgentSessionFactory["create"]>[0], "environment">,
1630
+ ): Promise<Omit<Parameters<AgentSessionFactory["create"]>[0], "environment">> {
1631
+ const home = args.agentEnv?.CODEX_HOME;
1632
+ if (!codexHomeIsolatable(args.agent?.kind ?? "", home)) return args;
1633
+ const perAgent = codexPerAgentHome(home, agentId);
1634
+ try {
1635
+ const res = await environment.exec({
1636
+ argv: ["/bin/sh", "-c", codexHomeSeedCommand(home, perAgent)],
1637
+ user: "root",
1638
+ env: RUNTIME_AUTHORITY_ENV,
1639
+ timeoutMs: 30_000,
1640
+ maxOutputBytes: 8 * 1024,
1641
+ });
1642
+ if (res.exitCode !== 0) {
1643
+ console.warn(
1644
+ `[codex] ${agentId}: could not isolate CODEX_HOME (exit ${res.exitCode}); using shared home`,
1645
+ );
1646
+ return args;
1647
+ }
1648
+ } catch (err) {
1649
+ console.warn(
1650
+ `[codex] ${agentId}: CODEX_HOME isolation errored (${
1651
+ err instanceof Error ? err.message : String(err)
1652
+ }); using shared home`,
1653
+ );
1654
+ return args;
1655
+ }
1656
+ return {
1657
+ ...args,
1658
+ agentEnv: { ...(args.agentEnv ?? {}), CODEX_HOME: perAgent },
1659
+ };
1660
+ }
1661
+
1615
1662
  private async createAndBindStableSessionCore(
1616
1663
  channel: Channel,
1617
1664
  agentId: string,
1618
1665
  args: Omit<Parameters<AgentSessionFactory["create"]>[0], "environment">,
1619
1666
  ): Promise<AgentSession | null> {
1620
1667
  const environment = await this.agentEnvironment(channel);
1668
+ // ADR-076 + codex-cli 0.152.0: two codex agents that resolve to the SAME
1669
+ // account share one CODEX_HOME, and codex's new sqlite state runtime
1670
+ // cannot be opened by two concurrent processes — the second agent dies
1671
+ // with "failed to initialize sqlite state runtime under <CODEX_HOME>"
1672
+ // (live 2026-09-04, a task with two codex agents on one account). Give
1673
+ // each codex agent its OWN CODEX_HOME, seeded once from the resolved
1674
+ // account's dir, so their state runtimes never collide. Single-codex
1675
+ // tasks are unaffected beyond a one-time in-container copy.
1676
+ // Only codex agents take the isolation path; everything else uses args
1677
+ // directly, WITHOUT an extra await — a stray microtask here reordered the
1678
+ // Claude re-spawn's prompt re-delivery.
1679
+ const boundArgs =
1680
+ args.agent?.kind === "codex"
1681
+ ? await this.isolateCodexHome(environment, agentId, args)
1682
+ : args;
1621
1683
  while (this.isActiveChannel(channel)) {
1622
1684
  const generation = channel.configGeneration;
1623
- const session = await this.factory.create({ ...args, environment });
1685
+ const session = await this.factory.create({ ...boundArgs, environment });
1624
1686
  if (!this.isActiveChannel(channel)) {
1625
1687
  await session.close().catch(() => {});
1626
1688
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.94",
3
+ "version": "0.9.95",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",