@runuai/host 0.8.17 → 0.8.18

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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.8.17",
3
+ "version": "0.8.18",
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>",