@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 +1 -1
- package/src/event-outbox.ts +104 -0
- package/src/main.ts +74 -7
- package/src/protocol.ts +21 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|