@runuai/host 0.8.18 → 0.8.20

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.
@@ -1463,7 +1463,12 @@ async function dockerListContainersByLabel(
1463
1463
  { timeoutMs: 60_000 },
1464
1464
  );
1465
1465
  if (res.status === null) return null; // docker state UNKNOWN — don't act on it
1466
- 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;
1467
1472
  return res.stdout
1468
1473
  .split("\n")
1469
1474
  .map((l) => l.trim())
@@ -1518,7 +1523,40 @@ async function dockerExec(
1518
1523
  }
1519
1524
 
1520
1525
  /** Exported for tests; production entry is the getOrchestrator() boot guard. */
1521
- 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;
1522
1560
  try {
1523
1561
  const db = getDb();
1524
1562
  const rows = db
@@ -1526,13 +1564,13 @@ export async function recoverRunningTasks(): Promise<void> {
1526
1564
  .from(schema.hostTasks)
1527
1565
  .where(inArray(schema.hostTasks.statusMirror, [...ACTIVE_STATUSES]))
1528
1566
  .all();
1529
- if (rows.length === 0) return;
1567
+ if (rows.length === 0) return 0;
1530
1568
  console.log(
1531
1569
  `[orchestrator] recovery: scanning ${rows.length} active task row(s)`,
1532
1570
  );
1533
1571
  for (const task of rows) {
1534
1572
  try {
1535
- await recoverOneTask(task);
1573
+ if (!(await recoverOneTask(task))) deferred += 1;
1536
1574
  } catch (err) {
1537
1575
  console.error(
1538
1576
  `[orchestrator] recovery: ${task.taskId} failed:`,
@@ -1546,17 +1584,19 @@ export async function recoverRunningTasks(): Promise<void> {
1546
1584
  err instanceof Error ? err.message : err,
1547
1585
  );
1548
1586
  }
1587
+ return deferred;
1549
1588
  }
1550
1589
 
1590
+ /** Returns false when docker was unreachable (caller retries the pass). */
1551
1591
  async function recoverOneTask(
1552
1592
  task: typeof schema.hostTasks.$inferSelect,
1553
- ): Promise<void> {
1593
+ ): Promise<boolean> {
1554
1594
  const composeProject = task.composeProject;
1555
1595
  if (!composeProject) {
1556
1596
  // We never wrote a compose project name for this row — must be
1557
1597
  // a row stuck in `queued`/`starting` from before task-up got past
1558
1598
  // step 2. Leave it; the user can recreate.
1559
- return;
1599
+ return true;
1560
1600
  }
1561
1601
  const containerName = `${composeProject}-app-1`;
1562
1602
  const containers = await dockerListContainersByLabel(
@@ -1564,11 +1604,11 @@ async function recoverOneTask(
1564
1604
  );
1565
1605
  if (containers === null) {
1566
1606
  // Docker unreachable / timed out — actual state unknown. Leave the row
1567
- // alone; the next recovery pass (or the next ensure) will see the truth.
1607
+ // alone; the retrying caller re-scans once docker answers.
1568
1608
  console.warn(
1569
- `[orchestrator] recovery: ${task.taskId} skipped — docker did not answer`,
1609
+ `[orchestrator] recovery: ${task.taskId} deferred — docker did not answer`,
1570
1610
  );
1571
- return;
1611
+ return false;
1572
1612
  }
1573
1613
 
1574
1614
  if (containers.length === 0) {
@@ -1585,7 +1625,7 @@ async function recoverOneTask(
1585
1625
  `[orchestrator] recovery: ${task.taskId} -> ${next} (container gone, ` +
1586
1626
  `worktree ${workspaceOnDisk ? "present" : "gone"})`,
1587
1627
  );
1588
- return;
1628
+ return true;
1589
1629
  }
1590
1630
 
1591
1631
  const running = containers.some((c) => c.State === "running");
@@ -1607,7 +1647,7 @@ async function recoverOneTask(
1607
1647
  if (task.ownerUserId) {
1608
1648
  void setupTaskGithub(task.taskId, task.ownerUserId);
1609
1649
  }
1610
- return;
1650
+ return true;
1611
1651
  }
1612
1652
 
1613
1653
  // Container exists but exited. Bring it back up + re-launch the
@@ -1620,7 +1660,7 @@ async function recoverOneTask(
1620
1660
  codeServerPort: null,
1621
1661
  previewPorts: "[]",
1622
1662
  });
1623
- return;
1663
+ return true;
1624
1664
  }
1625
1665
  // Correctly-owned Codex creds BEFORE uai-init / any agent respawn: the boot
1626
1666
  // reinject sweep only targets containers already running, so a container
@@ -1649,6 +1689,7 @@ async function recoverOneTask(
1649
1689
  console.log(
1650
1690
  `[orchestrator] recovery: ${task.taskId} resumed (port ${port ?? "?"})`,
1651
1691
  );
1692
+ return true;
1652
1693
  }
1653
1694
 
1654
1695
  function db_setStatus(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.18",
3
+ "version": "0.8.20",
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>",
@@ -198,7 +198,15 @@ while IFS= read -r project_obj; do
198
198
  [ -n "$project_default" ] || project_default="main"
199
199
 
200
200
  mkdir -p "$(dirname "$worktree_target")"
201
- if git -C "$mirror_dir" rev-parse --verify --quiet \
201
+ if [ -e "$worktree_target/.git" ]; then
202
+ # Resume (ADR-028): the task worktree survived the stop — reuse it as-is
203
+ # (a linked worktree's .git is a FILE, hence -e). Re-running `git
204
+ # worktree add` would refuse ("already exists" / branch exists) and
205
+ # errored EVERY resume of a worktree-preserving stop within seconds
206
+ # (found live 2026-07-21). The task branch and any uncommitted work are
207
+ # exactly what the user expects back.
208
+ log "worktree for $project_slug already present — resuming with it"
209
+ elif git -C "$mirror_dir" rev-parse --verify --quiet \
202
210
  "refs/remotes/origin/$project_default" >/dev/null 2>&1; then
203
211
  # Normal case: branch the task worktree off origin/<defaultBranch>.
204
212
  step "WORKTREE_FAILED" "git worktree add ($project_id)"