@runuai/host 0.9.12 → 0.9.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.12",
3
+ "version": "0.9.13",
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>",
@@ -0,0 +1,104 @@
1
+ /**
2
+ * ADR-103: the host-side event outbox.
3
+ *
4
+ * Every HostEvent gets a monotonic seq and its serialized wire frame is held
5
+ * here until the cloud acks it. The bridge connection drains the outbox in
6
+ * order — live traffic and reconnect replay are the same code path, so a
7
+ * WSS blip (a cloud deploy) can no longer drop events on the floor.
8
+ *
9
+ * Bounded, in-memory. A host PROCESS restart still loses unsent events —
10
+ * that path is ADR-061's (the restarted host reattaches to the container
11
+ * and resumes streaming); this outbox closes the disconnect gap only.
12
+ */
13
+
14
+ import type { HostEvent } from "./protocol";
15
+
16
+ export interface OutboxEntry {
17
+ seq: number;
18
+ /** The full serialized `{kind:"event", seq, event}` frame, ready to send. */
19
+ raw: string;
20
+ }
21
+
22
+ /** Overflow bounds. Generous: a disconnect longer than this many events is a
23
+ * real outage, not a deploy blip, and dropping OLDEST keeps the tail — the
24
+ * most recent turn — intact for replay. */
25
+ const MAX_ENTRIES = 10_000;
26
+ const MAX_BYTES = 32 * 1024 * 1024;
27
+
28
+ export class EventOutbox {
29
+ private entries: OutboxEntry[] = [];
30
+ private totalBytes = 0;
31
+ private nextSeq = 1;
32
+ /** Highest seq ever transmitted on any connection. */
33
+ private sentUpTo = 0;
34
+ /** Entries dropped by overflow since the last drain — for one loud log. */
35
+ private droppedSinceDrain = 0;
36
+
37
+ /** Serialize + append. Returns the entry's seq. */
38
+ enqueue(event: HostEvent): number {
39
+ const seq = this.nextSeq++;
40
+ const raw = JSON.stringify({ kind: "event", seq, event });
41
+ this.entries.push({ seq, raw });
42
+ this.totalBytes += raw.length;
43
+ while (
44
+ this.entries.length > MAX_ENTRIES ||
45
+ (this.totalBytes > MAX_BYTES && this.entries.length > 1)
46
+ ) {
47
+ const dropped = this.entries.shift();
48
+ if (!dropped) break;
49
+ this.totalBytes -= dropped.raw.length;
50
+ // Only a NEVER-SENT entry is a real loss; an unacked-but-sent one most
51
+ // likely landed and just lost its ack.
52
+ if (dropped.seq > this.sentUpTo) this.droppedSinceDrain += 1;
53
+ }
54
+ return seq;
55
+ }
56
+
57
+ /** Cumulative ack: forget everything up to and including `seq`. */
58
+ ack(seq: number): void {
59
+ let i = 0;
60
+ for (const entry of this.entries) {
61
+ if (entry.seq > seq) break;
62
+ this.totalBytes -= entry.raw.length;
63
+ i += 1;
64
+ }
65
+ if (i > 0) this.entries.splice(0, i);
66
+ if (seq > this.sentUpTo) this.sentUpTo = seq;
67
+ }
68
+
69
+ /**
70
+ * Rewind the transmit cursor for a reconnect (ADR-103 resume handshake).
71
+ * `afterSeq` is the cloud's watermark: everything newer re-sends on the
72
+ * next drain. `null` = the cloud has no state for this boot — replay
73
+ * nothing; only never-transmitted entries go out.
74
+ */
75
+ resume(afterSeq: number | null): void {
76
+ if (afterSeq !== null && afterSeq < this.sentUpTo) this.sentUpTo = afterSeq;
77
+ }
78
+
79
+ /**
80
+ * Entries due for transmission, in order. The caller sends them and the
81
+ * cursor advances — drain is idempotent per entry until `resume` rewinds.
82
+ */
83
+ drain(): OutboxEntry[] {
84
+ const due = this.entries.filter((e) => e.seq > this.sentUpTo);
85
+ const last = due[due.length - 1];
86
+ if (last) this.sentUpTo = last.seq;
87
+ return due;
88
+ }
89
+
90
+ /** Never-sent entries lost to overflow since the last call; resets. */
91
+ takeDroppedCount(): number {
92
+ const n = this.droppedSinceDrain;
93
+ this.droppedSinceDrain = 0;
94
+ return n;
95
+ }
96
+
97
+ get size(): number {
98
+ return this.entries.length;
99
+ }
100
+
101
+ get bytes(): number {
102
+ return this.totalBytes;
103
+ }
104
+ }
package/src/main.ts CHANGED
@@ -82,8 +82,10 @@ import { canAdvertiseTypedSecretaryDispatch } from "../lib/agents/mode";
82
82
  import "../lib/agents/factory";
83
83
  import { ensureStandardImage, standardRuntimes } from "../lib/standard-image";
84
84
  import { hostCommands, hostEvents } from "./index";
85
+ import { EventOutbox } from "./event-outbox";
85
86
  import {
86
87
  HostErrorCode,
88
+ EVENT_REPLAY_PROTOCOL_FEATURE,
87
89
  GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
88
90
  GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
89
91
  GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
@@ -149,6 +151,33 @@ let shutdownRequested = false;
149
151
  let pendingBinaryTunnelId: string | null = null;
150
152
  const tunnels = new TunnelRegistry();
151
153
 
154
+ // ADR-103: event outbox. BOOT_ID scopes seqs to this process lifetime; the
155
+ // cloud's replay watermark only applies within a matching boot. Events are
156
+ // enqueued by a PROCESS-level subscription (below, before connect()) so a
157
+ // dropped WSS no longer drops events — they drain on reconnect, after the
158
+ // cloud names its watermark via event.resume.
159
+ const BOOT_ID = newId();
160
+ const eventOutbox = new EventOutbox();
161
+ // Transmission is held per-connection until the cloud's event.resume sets the
162
+ // replay cursor (or the fallback timer concedes the cloud predates ADR-103).
163
+ let eventFlushEnabled = false;
164
+ let resumeFallbackTimer: NodeJS.Timeout | null = null;
165
+ const RESUME_FALLBACK_MS = 3_000;
166
+
167
+ function flushEvents(): void {
168
+ const socket = ws;
169
+ if (!eventFlushEnabled || !socket || socket.readyState !== WebSocket.OPEN) {
170
+ return;
171
+ }
172
+ const dropped = eventOutbox.takeDroppedCount();
173
+ if (dropped > 0) {
174
+ console.warn(
175
+ `[host-agent] event outbox overflowed: ${dropped} unsent event(s) lost`,
176
+ );
177
+ }
178
+ for (const entry of eventOutbox.drain()) socket.send(entry.raw);
179
+ }
180
+
152
181
  interface PausableSource {
153
182
  pause(): unknown;
154
183
  resume(): unknown;
@@ -187,6 +216,13 @@ void ensureStandardImage();
187
216
  // self-heals such containers: it re-copies and chowns every running task.
188
217
  void recoveryComplete().then(() => reinjectCodexRunningTasks());
189
218
  watchCodexAuth();
219
+ // ADR-103: subscribe ONCE, for the process — not per connection. Every event
220
+ // lands in the outbox regardless of socket state; flushEvents is a no-op
221
+ // while disconnected and the backlog drains after the resume handshake.
222
+ hostEvents.subscribe((event) => {
223
+ eventOutbox.enqueue(event);
224
+ flushEvents();
225
+ });
190
226
  connect();
191
227
  // Local browser UI (ADR-028) — same single process, alongside the WSS client.
192
228
  // Best-effort: a UI bind failure must not take the host service down.
@@ -234,6 +270,7 @@ function buildCapabilities(): HostCapabilities {
234
270
  version: packageVersion(),
235
271
  protocolFeatures: [
236
272
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
273
+ EVENT_REPLAY_PROTOCOL_FEATURE,
237
274
  GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE,
238
275
  GITHUB_INSTALLATION_VERIFICATION_PROTOCOL_FEATURE,
239
276
  GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE,
@@ -275,19 +312,22 @@ function connect(): void {
275
312
 
276
313
  let lastTraffic = Date.now();
277
314
  let ready = false;
278
- let unsubscribe: (() => void) | null = null;
279
315
  let pingTimer: NodeJS.Timeout | null = null;
280
316
  let deadTimer: NodeJS.Timeout | null = null;
281
317
 
282
318
  const cleanup = (): void => {
283
319
  if (pingTimer) clearInterval(pingTimer);
284
320
  if (deadTimer) clearInterval(deadTimer);
285
- if (unsubscribe) unsubscribe();
321
+ eventFlushEnabled = false;
322
+ if (resumeFallbackTimer) {
323
+ clearTimeout(resumeFallbackTimer);
324
+ resumeFallbackTimer = null;
325
+ }
286
326
  if (ws === socket) ws = null;
287
327
  };
288
328
 
289
329
  socket.on("open", () => {
290
- send(socket, { kind: "auth", token, hostId });
330
+ send(socket, { kind: "auth", token, hostId, bootId: BOOT_ID });
291
331
  // Advertise capabilities immediately after auth (ADR-021). The bridge
292
332
  // rejects with close-code 4001 if auth fails, so sending here is harmless
293
333
  // on a bad token and saves a round-trip on a good one. Re-sent on every
@@ -303,10 +343,14 @@ function connect(): void {
303
343
  setHostObsTag(hostId);
304
344
  addHostBreadcrumb("bridge", "connected");
305
345
 
306
- unsubscribe = hostEvents.subscribe((event) => {
307
- if (socket.readyState !== WebSocket.OPEN) return;
308
- send(socket, { kind: "event", event });
309
- });
346
+ // ADR-103: hold event transmission until the cloud names its replay
347
+ // watermark (event.resume). A pre-103 cloud never will — after the
348
+ // fallback, drain never-transmitted entries only (no replay, no dupes).
349
+ resumeFallbackTimer = setTimeout(() => {
350
+ resumeFallbackTimer = null;
351
+ eventFlushEnabled = true;
352
+ flushEvents();
353
+ }, RESUME_FALLBACK_MS);
310
354
 
311
355
  pingTimer = setInterval(() => {
312
356
  if (socket.readyState === WebSocket.OPEN) {
@@ -339,6 +383,20 @@ function connect(): void {
339
383
  switch (frame.kind) {
340
384
  case "pong":
341
385
  break;
386
+ case "event.resume":
387
+ // ADR-103: the cloud named its watermark — rewind the transmit
388
+ // cursor to it (null = no state, replay nothing) and start draining.
389
+ if (resumeFallbackTimer) {
390
+ clearTimeout(resumeFallbackTimer);
391
+ resumeFallbackTimer = null;
392
+ }
393
+ eventOutbox.resume(frame.afterSeq);
394
+ eventFlushEnabled = true;
395
+ flushEvents();
396
+ break;
397
+ case "event.ack":
398
+ eventOutbox.ack(frame.seq);
399
+ break;
342
400
  case "command":
343
401
  void handleCommand(socket, frame);
344
402
  break;
@@ -1227,6 +1285,15 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
1227
1285
  if (frame.kind === "pong" && typeof frame.ts === "number") {
1228
1286
  return { kind: "pong", ts: frame.ts };
1229
1287
  }
1288
+ if (
1289
+ frame.kind === "event.resume" &&
1290
+ (frame.afterSeq === null || typeof frame.afterSeq === "number")
1291
+ ) {
1292
+ return { kind: "event.resume", afterSeq: frame.afterSeq };
1293
+ }
1294
+ if (frame.kind === "event.ack" && typeof frame.seq === "number") {
1295
+ return { kind: "event.ack", seq: frame.seq };
1296
+ }
1230
1297
  if (
1231
1298
  frame.kind === "command" &&
1232
1299
  typeof frame.commandId === "string" &&
package/src/protocol.ts CHANGED
@@ -41,6 +41,11 @@ export const GITHUB_REPOSITORY_ACCESS_PROTOCOL_FEATURE =
41
41
  "github-repository-access-v1";
42
42
  export const GITHUB_CREDENTIAL_GENERATION_PROTOCOL_FEATURE =
43
43
  "github-credential-generation-v1";
44
+ // ADR-103: the host runs an event outbox — seq'd event frames, resume
45
+ // handshake on reconnect, cumulative acks. A cloud seeing this feature knows
46
+ // a disconnect no longer loses events (they replay), so it keeps its
47
+ // in-flight turn buffers across the gap.
48
+ export const EVENT_REPLAY_PROTOCOL_FEATURE = "event-replay-v1";
44
49
  export const COMMUNICATOR_EXECUTION_PROFILE = "communicator";
45
50
  export const MAX_AGENT_ID_CHARS = 128;
46
51
  export const MAX_SECRETARY_DISPATCH_RECIPIENTS = 16;
@@ -603,7 +608,15 @@ export type CloudToHost =
603
608
  // discovery, DCR, PKCE, token exchange, encrypted storage. Secrets in a
604
609
  // probe (headerValue, clientSecret) are write-only; nothing secret ever
605
610
  // rides an ack. `opId` correlates request↔ack.
606
- | { kind: "mcp.op"; opId: string; op: McpOp };
611
+ | { kind: "mcp.op"; opId: string; op: McpOp }
612
+ // ADR-103: sent once right after auth to a host that supplied a bootId.
613
+ // `afterSeq` is the cloud's replay watermark for this boot: the host
614
+ // replays every outbox entry with seq > afterSeq. `null` means the cloud
615
+ // has no watermark for this boot (fresh boot, or lost KV) — the host sends
616
+ // only entries it never transmitted, replaying nothing.
617
+ | { kind: "event.resume"; afterSeq: number | null }
618
+ // ADR-103: cumulative — the host trims outbox entries with seq <= seq.
619
+ | { kind: "event.ack"; seq: number };
607
620
 
608
621
  /** One MCP-connection operation (ADR-057), executed by the host. */
609
622
  export type McpOp =
@@ -632,14 +645,19 @@ export type McpOp =
632
645
  | { kind: "disconnect"; connectionId: string };
633
646
 
634
647
  export type HostToCloud =
635
- | { kind: "auth"; token: string; hostId: string }
648
+ // ADR-103: `bootId` scopes event seqs to one host process lifetime — the
649
+ // cloud's replay watermark only applies within a matching boot. Optional
650
+ // for pre-103 hosts (which also never send seq'd events).
651
+ | { kind: "auth"; token: string; hostId: string; bootId?: string }
636
652
  | { kind: "host.capabilities"; capabilities: HostCapabilities }
637
653
  | {
638
654
  kind: "result";
639
655
  commandId: string;
640
656
  result: HostCommandResult<unknown>;
641
657
  }
642
- | { kind: "event"; event: HostEvent }
658
+ // ADR-103: `seq` is the outbox sequence number (monotonic within a bootId).
659
+ // Absent from pre-103 hosts; the cloud dedups replay overlap by it.
660
+ | { kind: "event"; event: HostEvent; seq?: number }
643
661
  // Host-pushed task lifecycle (ADR-028 local-UI Stop): the host operator paused
644
662
  // a task's containers, so the cloud mirrors the status (currently "stopped").
645
663
  | { kind: "task.status"; taskId: string; status: string }