@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.
- package/lib/agents/transport.ts +23 -2
- package/lib/orchestrator.ts +58 -13
- package/package.json +1 -1
package/lib/agents/transport.ts
CHANGED
|
@@ -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 {
|
package/lib/orchestrator.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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<
|
|
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
|
|
1607
|
+
// alone; the retrying caller re-scans once docker answers.
|
|
1564
1608
|
console.warn(
|
|
1565
|
-
`[orchestrator] recovery: ${task.taskId}
|
|
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