@runuai/host 0.8.17 → 0.8.19

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.
@@ -66,6 +66,25 @@ export interface AgentTransportOptions {
66
66
  /** Runner heartbeat is 5 s; older than this = not attachable. */
67
67
  const ATTACH_HEARTBEAT_FRESH_MS = 20_000;
68
68
 
69
+ /**
70
+ * ONE live outbox consumer per (task, agent) in this host process. A session
71
+ * dropped from the orchestrator's map without close()/detach() (e.g. the
72
+ * error-event path — a pipes-era "process is dead" assumption) leaves its
73
+ * DurableProcess polling FOREVER: its liveness check only trips when the
74
+ * RUNNER dies, and the runner is alive. The next channelEnsure then attaches
75
+ * a second tail to the same outbox, and every agent turn is emitted once per
76
+ * leaked tail — live incident 2026-07-20: one agent's messages posted ×5 to
77
+ * the feed, multiplicity growing by one per errored turn. Enforcing the
78
+ * invariant here covers every replacement path, including future ones.
79
+ */
80
+ const liveTails = new Map<string, DurableProcess>();
81
+
82
+ function claimTail(key: string, proc: DurableProcess): DurableProcess {
83
+ liveTails.get(key)?.detach();
84
+ liveTails.set(key, proc);
85
+ return proc;
86
+ }
87
+
69
88
  function durableEnabled(): boolean {
70
89
  return process.env.UAI_DURABLE_SESSIONS !== "0";
71
90
  }
@@ -117,6 +136,8 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
117
136
  .run();
118
137
  };
119
138
 
139
+ const tailKey = `${opts.taskId}:${opts.agentId}`;
140
+
120
141
  // ---- Attach: a previous host process left this agent's runner alive. ----
121
142
  if (opts.allowAttach && row && row.status === "running" && heartbeatFresh(row.sessionDir)) {
122
143
  const proc = new DurableProcess({
@@ -127,7 +148,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
127
148
  debugLabel: opts.debugLabel,
128
149
  });
129
150
  proc.onExit(markClosed);
130
- return proc;
151
+ return claimTail(tailKey, proc);
131
152
  }
132
153
 
133
154
  // ---- Spawn a fresh runner, asking any predecessor to stop. --------------
@@ -231,7 +252,7 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
231
252
  .run();
232
253
 
233
254
  proc.onExit(markClosed);
234
- return proc;
255
+ return claimTail(tailKey, proc);
235
256
  }
236
257
 
237
258
  function heartbeatFresh(sessionDir: string): boolean {
@@ -688,7 +688,11 @@ class Orchestrator {
688
688
  // becomes a zombie: the map still holds the dead session and every
689
689
  // later deliver "succeeds" into a closed pipe (found live 2026-07-08
690
690
  // after a double SIGKILL). Bounded by the respawn budget, checked in
691
- // reconcileSessions.
691
+ // reconcileSessions. NOTE (durable sessions): an errored TURN does
692
+ // not mean a dead RUNNER — the dropped session's outbox tail would
693
+ // poll forever and duplicate every later turn (×5 live 2026-07-20).
694
+ // createAgentTransport's one-tail-per-agent registry detaches the
695
+ // stale consumer when the respawn attaches.
692
696
  channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
693
697
  channel.respawnLastAt.set(agentId, Date.now());
694
698
  channel.sessions.delete(agentId);
@@ -1459,7 +1463,12 @@ async function dockerListContainersByLabel(
1459
1463
  { timeoutMs: 60_000 },
1460
1464
  );
1461
1465
  if (res.status === null) return null; // docker state UNKNOWN — don't act on it
1462
- if (res.status !== 0) return [];
1466
+ // Non-zero is ALSO unknown, not "no containers": after a machine reboot the
1467
+ // host service races the Docker daemon, and `docker ps` fails fast with
1468
+ // "cannot connect". Treating that as [] downgraded every active task to
1469
+ // stopped on boot (live user report 2026-07-21) — the exited-container
1470
+ // restart branch below never got a chance to run.
1471
+ if (res.status !== 0) return null;
1463
1472
  return res.stdout
1464
1473
  .split("\n")
1465
1474
  .map((l) => l.trim())
@@ -1514,7 +1523,40 @@ async function dockerExec(
1514
1523
  }
1515
1524
 
1516
1525
  /** Exported for tests; production entry is the getOrchestrator() boot guard. */
1517
- export async function recoverRunningTasks(): Promise<void> {
1526
+ export async function recoverRunningTasks(opts?: {
1527
+ /** Delay between passes while docker is unreachable (tests shrink it). */
1528
+ retryMs?: number;
1529
+ /** Bound on passes; ~15 min at the default cadence covers slow boots. */
1530
+ maxPasses?: number;
1531
+ }): Promise<void> {
1532
+ const retryMs = opts?.retryMs ?? 20_000;
1533
+ const maxPasses = opts?.maxPasses ?? 45;
1534
+ // After a machine reboot the host service and the Docker daemon start
1535
+ // concurrently; a pass that finds docker unreachable resolves nothing and
1536
+ // must retry once the daemon is up — the whole point of recovery is to
1537
+ // restart the exited containers that reboot left behind.
1538
+ for (let pass = 1; ; pass++) {
1539
+ const deferred = await recoveryPass();
1540
+ if (deferred === 0) return;
1541
+ if (pass >= maxPasses) {
1542
+ console.warn(
1543
+ `[orchestrator] recovery: docker still unreachable after ${pass} passes — giving up (${deferred} task(s) unresolved)`,
1544
+ );
1545
+ return;
1546
+ }
1547
+ if (pass === 1) {
1548
+ console.log(
1549
+ `[orchestrator] recovery: docker not ready — retrying every ${Math.round(retryMs / 1000)}s for ${deferred} task(s)`,
1550
+ );
1551
+ }
1552
+ await new Promise((resolve) => setTimeout(resolve, retryMs));
1553
+ }
1554
+ }
1555
+
1556
+ /** One recovery sweep. Returns how many tasks were deferred on an
1557
+ * unreachable docker (0 = everything resolved one way or another). */
1558
+ async function recoveryPass(): Promise<number> {
1559
+ let deferred = 0;
1518
1560
  try {
1519
1561
  const db = getDb();
1520
1562
  const rows = db
@@ -1522,13 +1564,13 @@ export async function recoverRunningTasks(): Promise<void> {
1522
1564
  .from(schema.hostTasks)
1523
1565
  .where(inArray(schema.hostTasks.statusMirror, [...ACTIVE_STATUSES]))
1524
1566
  .all();
1525
- if (rows.length === 0) return;
1567
+ if (rows.length === 0) return 0;
1526
1568
  console.log(
1527
1569
  `[orchestrator] recovery: scanning ${rows.length} active task row(s)`,
1528
1570
  );
1529
1571
  for (const task of rows) {
1530
1572
  try {
1531
- await recoverOneTask(task);
1573
+ if (!(await recoverOneTask(task))) deferred += 1;
1532
1574
  } catch (err) {
1533
1575
  console.error(
1534
1576
  `[orchestrator] recovery: ${task.taskId} failed:`,
@@ -1542,17 +1584,19 @@ export async function recoverRunningTasks(): Promise<void> {
1542
1584
  err instanceof Error ? err.message : err,
1543
1585
  );
1544
1586
  }
1587
+ return deferred;
1545
1588
  }
1546
1589
 
1590
+ /** Returns false when docker was unreachable (caller retries the pass). */
1547
1591
  async function recoverOneTask(
1548
1592
  task: typeof schema.hostTasks.$inferSelect,
1549
- ): Promise<void> {
1593
+ ): Promise<boolean> {
1550
1594
  const composeProject = task.composeProject;
1551
1595
  if (!composeProject) {
1552
1596
  // We never wrote a compose project name for this row — must be
1553
1597
  // a row stuck in `queued`/`starting` from before task-up got past
1554
1598
  // step 2. Leave it; the user can recreate.
1555
- return;
1599
+ return true;
1556
1600
  }
1557
1601
  const containerName = `${composeProject}-app-1`;
1558
1602
  const containers = await dockerListContainersByLabel(
@@ -1560,11 +1604,11 @@ async function recoverOneTask(
1560
1604
  );
1561
1605
  if (containers === null) {
1562
1606
  // Docker unreachable / timed out — actual state unknown. Leave the row
1563
- // alone; the next recovery pass (or the next ensure) will see the truth.
1607
+ // alone; the retrying caller re-scans once docker answers.
1564
1608
  console.warn(
1565
- `[orchestrator] recovery: ${task.taskId} skipped — docker did not answer`,
1609
+ `[orchestrator] recovery: ${task.taskId} deferred — docker did not answer`,
1566
1610
  );
1567
- return;
1611
+ return false;
1568
1612
  }
1569
1613
 
1570
1614
  if (containers.length === 0) {
@@ -1581,7 +1625,7 @@ async function recoverOneTask(
1581
1625
  `[orchestrator] recovery: ${task.taskId} -> ${next} (container gone, ` +
1582
1626
  `worktree ${workspaceOnDisk ? "present" : "gone"})`,
1583
1627
  );
1584
- return;
1628
+ return true;
1585
1629
  }
1586
1630
 
1587
1631
  const running = containers.some((c) => c.State === "running");
@@ -1603,7 +1647,7 @@ async function recoverOneTask(
1603
1647
  if (task.ownerUserId) {
1604
1648
  void setupTaskGithub(task.taskId, task.ownerUserId);
1605
1649
  }
1606
- return;
1650
+ return true;
1607
1651
  }
1608
1652
 
1609
1653
  // Container exists but exited. Bring it back up + re-launch the
@@ -1616,7 +1660,7 @@ async function recoverOneTask(
1616
1660
  codeServerPort: null,
1617
1661
  previewPorts: "[]",
1618
1662
  });
1619
- return;
1663
+ return true;
1620
1664
  }
1621
1665
  // Correctly-owned Codex creds BEFORE uai-init / any agent respawn: the boot
1622
1666
  // reinject sweep only targets containers already running, so a container
@@ -1645,6 +1689,7 @@ async function recoverOneTask(
1645
1689
  console.log(
1646
1690
  `[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
1647
1691
  );
1692
+ return true;
1648
1693
  }
1649
1694
 
1650
1695
  function db_setStatus(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
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>",