baychat 0.14.0 → 0.15.0
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/dist/args.js +15 -0
- package/dist/doctor.js +21 -2
- package/dist/index.js +7 -0
- package/dist/relay/adapters.js +21 -3
- package/dist/relay/commands.js +284 -26
- package/dist/relay/daemon.js +317 -43
- package/dist/relay/held.js +184 -0
- package/dist/relay/mailbox.js +1 -0
- package/dist/relay/owner-pid.js +129 -0
- package/dist/relay/registry.js +11 -0
- package/dist/relay/socket.js +48 -0
- package/dist/runtime-binary.js +14 -0
- package/dist/runtimes.js +8 -0
- package/package.json +1 -1
package/dist/relay/daemon.js
CHANGED
|
@@ -40,7 +40,9 @@ const path = __importStar(require("path"));
|
|
|
40
40
|
const config_1 = require("../config");
|
|
41
41
|
const runtime_binary_1 = require("../runtime-binary");
|
|
42
42
|
const adapters_1 = require("./adapters");
|
|
43
|
+
const parent_watch_1 = require("./parent-watch");
|
|
43
44
|
const queue_1 = require("./queue");
|
|
45
|
+
const held_1 = require("./held");
|
|
44
46
|
const registry_1 = require("./registry");
|
|
45
47
|
const mailbox_1 = require("./mailbox");
|
|
46
48
|
const mailbox_watcher_1 = require("./mailbox-watcher");
|
|
@@ -82,6 +84,46 @@ class RelayDaemon {
|
|
|
82
84
|
discoveryReasons = new Map();
|
|
83
85
|
discovery;
|
|
84
86
|
spawnHeadless;
|
|
87
|
+
isAlive;
|
|
88
|
+
/**
|
|
89
|
+
* Sessions with a headless turn running right now — the one-session lease.
|
|
90
|
+
*
|
|
91
|
+
* `SessionQueue` serialises `deliver()` calls, which is NOT the same thing: a
|
|
92
|
+
* headless turn is awaited inside one of those calls, and a second delivery
|
|
93
|
+
* arriving after it resolves would happily start another. Two headless agents
|
|
94
|
+
* under one name is the same fault as the attach/headless clone, reached by a
|
|
95
|
+
* different door.
|
|
96
|
+
*
|
|
97
|
+
* HONEST LIMIT: this can stop a new headless STARTING. It cannot un-run one
|
|
98
|
+
* already in flight, so it does not make a duplicate impossible — it makes the
|
|
99
|
+
* daemon stop creating them.
|
|
100
|
+
*/
|
|
101
|
+
headlessInFlight = new Set();
|
|
102
|
+
/**
|
|
103
|
+
* Batches held for a session that is ALIVE but between wakes.
|
|
104
|
+
*
|
|
105
|
+
* A socket attach exits on every wake, and the turn it triggers runs for
|
|
106
|
+
* MINUTES before the session re-arms. A message arriving in that window is not
|
|
107
|
+
* undeliverable — it is EARLY. Timing out and recording it pending threw away
|
|
108
|
+
* two of the owner's own messages on 2026-08-31 while the agent was busy
|
|
109
|
+
* answering the one before them, which reads to a user as the relay stopping.
|
|
110
|
+
*
|
|
111
|
+
* Held here and flushed the instant the session attaches again.
|
|
112
|
+
*
|
|
113
|
+
* KEYED BY CONVERSATION as well as session. A session is in several rooms, and
|
|
114
|
+
* a wake frame carries ONE conversation id — so merging every held batch under
|
|
115
|
+
* the first one tells the agent that messages from room B came from room A, and
|
|
116
|
+
* sends it to re-read the wrong room. Rooms are held apart and flushed apart.
|
|
117
|
+
*
|
|
118
|
+
* PERSISTED, and that is not a detail. While this was a plain Map a daemon
|
|
119
|
+
* restart lost every held message, and the poll cursor had already moved past
|
|
120
|
+
* them, so the server never sent them again — gone, with `relay status`
|
|
121
|
+
* reporting nothing at all. See `held.ts`.
|
|
122
|
+
*/
|
|
123
|
+
held = new held_1.HeldStore();
|
|
124
|
+
/** Beyond this a session is not "busy", it is broken; stop growing and report. */
|
|
125
|
+
static MAX_HELD = 200;
|
|
126
|
+
reattachGraceMs;
|
|
85
127
|
resolveBinary;
|
|
86
128
|
runTurn;
|
|
87
129
|
/**
|
|
@@ -132,6 +174,8 @@ class RelayDaemon {
|
|
|
132
174
|
}));
|
|
133
175
|
this.discovery = opts.discovery ?? {};
|
|
134
176
|
this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
|
|
177
|
+
this.isAlive = opts.isAlive ?? parent_watch_1.processIsAlive;
|
|
178
|
+
this.reattachGraceMs = opts.reattachGraceMs ?? 4000;
|
|
135
179
|
this.resolveBinary = opts.resolveBinary ?? ((name) => (0, runtime_binary_1.resolveRuntimeBinary)(name, (0, runtime_binary_1.currentBinaryEnv)()));
|
|
136
180
|
this.runTurn = opts.runTurn;
|
|
137
181
|
this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
|
|
@@ -153,6 +197,10 @@ class RelayDaemon {
|
|
|
153
197
|
}
|
|
154
198
|
const auth = { baseUrl: device.baseUrl, token: device.token };
|
|
155
199
|
this.registry.load();
|
|
200
|
+
// Held messages outlive the process that held them. Loaded before the socket
|
|
201
|
+
// opens, so a session that re-arms in the first moments of a new daemon is
|
|
202
|
+
// handed what the previous one was holding rather than a clean slate.
|
|
203
|
+
this.held.load();
|
|
156
204
|
const sockPath = (0, socket_1.socketPath)();
|
|
157
205
|
if (await (0, socket_1.probeSocket)(sockPath)) {
|
|
158
206
|
throw new Error(`a relay is already listening on ${sockPath} — run \`baychat relay status\``);
|
|
@@ -239,6 +287,59 @@ class RelayDaemon {
|
|
|
239
287
|
// could never have matched anything.
|
|
240
288
|
this.queue.push(sessionName, { ...message, conversationId });
|
|
241
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Hand a freshly attached session everything held while it was mid-turn.
|
|
292
|
+
*
|
|
293
|
+
* Sent as ONE wake: the messages arrived while the agent was busy and are read
|
|
294
|
+
* together, in order, the way a person returning to a chat reads them. Waking
|
|
295
|
+
* once per held message would spend a turn on each and let the agent answer the
|
|
296
|
+
* first three not knowing the fourth exists.
|
|
297
|
+
*/
|
|
298
|
+
async flushHeld(session, sock) {
|
|
299
|
+
const rooms = this.held.roomsFor(session);
|
|
300
|
+
if (rooms.length === 0)
|
|
301
|
+
return;
|
|
302
|
+
for (const room of rooms) {
|
|
303
|
+
try {
|
|
304
|
+
// ACKNOWLEDGED, not fired and forgotten. A hand-over into a socket that
|
|
305
|
+
// has already died would otherwise clear the hold AND record `woken` —
|
|
306
|
+
// destroying the only copy of the messages and filing them as
|
|
307
|
+
// delivered, which is worse than never having held them.
|
|
308
|
+
await (0, socket_1.writeFrameAck)(sock, { type: "wake", conversationId: room.conversationId, messages: room.messages });
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
// KEEP IT. This store is the only copy: dropping it because the handoff
|
|
312
|
+
// failed turns "held" into "lost", which is the failure it exists to
|
|
313
|
+
// prevent. The next attach gets it instead.
|
|
314
|
+
this.log(`could not hand ${room.messages.length} held message(s) for ${session} over: ${errText(err)}`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Removed only once it is actually on the wire, and only for THIS room —
|
|
318
|
+
// a failure on the second room must not discard the first's receipt or the
|
|
319
|
+
// third's contents.
|
|
320
|
+
this.held.clearRoom(session, room.conversationId);
|
|
321
|
+
this.record({ kind: "woken", via: "attach", session }, session, room.messages);
|
|
322
|
+
this.log(`delivered ${room.messages.length} held message(s) to ${session} in ${room.conversationId} on re-attach`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Wait for a session to re-arm its socket, up to `graceMs`.
|
|
327
|
+
*
|
|
328
|
+
* Polled rather than event-driven on purpose: the attach path already evicts
|
|
329
|
+
* a previous holder and re-registers, so the socket map is the single source
|
|
330
|
+
* of truth, and subscribing to it would mean a second one to keep in step.
|
|
331
|
+
*/
|
|
332
|
+
async waitForReattach(session, graceMs) {
|
|
333
|
+
const deadline = Date.now() + graceMs;
|
|
334
|
+
for (;;) {
|
|
335
|
+
const sock = this.attached.get(session);
|
|
336
|
+
if (sock && !sock.destroyed)
|
|
337
|
+
return sock;
|
|
338
|
+
if (Date.now() >= deadline)
|
|
339
|
+
return undefined;
|
|
340
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
242
343
|
/**
|
|
243
344
|
* Deliver one coalesced batch to one session. Runs under the queue's
|
|
244
345
|
* per-session lock, so an attach wake and a headless resume can never be in
|
|
@@ -272,12 +373,53 @@ class RelayDaemon {
|
|
|
272
373
|
}
|
|
273
374
|
const sock = this.attached.get(session);
|
|
274
375
|
if (sock && !sock.destroyed) {
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
376
|
+
// AN OPEN SOCKET IS NOT A LIVE SESSION.
|
|
377
|
+
//
|
|
378
|
+
// `watchParent` used to guarantee that by making an orphaned attach exit
|
|
379
|
+
// with its launcher. Without it — and a `Stop` hook has to detach, so it
|
|
380
|
+
// cannot rely on ppid — an orphan keeps its socket open, the daemon reads
|
|
381
|
+
// "socket open" as "session alive", and the wake is written into a corpse
|
|
382
|
+
// and recorded `woken via attach`. A destroyed message, filed as delivered.
|
|
383
|
+
// Two orphans were measured doing exactly that on 2026-08-30.
|
|
384
|
+
//
|
|
385
|
+
// `ownerPid` answers the same question `watchParent` did, from the other
|
|
386
|
+
// side and without depending on the attach's own process tree: rung 3.5
|
|
387
|
+
// uses it to refuse to clone a LIVING session, and this uses it to refuse
|
|
388
|
+
// to deliver into a DEAD one. Absent (an old registration, or a platform
|
|
389
|
+
// with no readable process table) means unknown, and unknown must not cost
|
|
390
|
+
// a live session its delivery — so only a definite "dead" declines.
|
|
391
|
+
if (target.ownerPid !== undefined && !this.isAlive(target.ownerPid)) {
|
|
392
|
+
this.log(`socket for ${session} is orphaned — owner ${target.ownerPid} is gone; not delivering into it`);
|
|
393
|
+
this.attached.delete(session);
|
|
394
|
+
this.registry.setAttached(session, false);
|
|
395
|
+
sock.destroy();
|
|
396
|
+
// Fall through: the session really is gone, which is what the rungs
|
|
397
|
+
// below are for.
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
// Live session: hand it the batch and let the harness re-invoke it. The
|
|
401
|
+
// attach client exits after one wake, which is what makes this a wake
|
|
402
|
+
// rather than a stream — and why the socket is dropped here.
|
|
403
|
+
//
|
|
404
|
+
// AWAITED. `sock.write()` buffers and returns, so an EPIPE from a peer
|
|
405
|
+
// that died between the liveness check and this line used to arrive
|
|
406
|
+
// after `record` had already filed the batch as `woken` — a lost message
|
|
407
|
+
// reported as delivered. A write that does not complete is not a
|
|
408
|
+
// delivery, and the rungs below exist for exactly this case.
|
|
409
|
+
try {
|
|
410
|
+
await (0, socket_1.writeFrameAck)(sock, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
|
|
411
|
+
this.record({ kind: "woken", via: "attach", session }, session, batch);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
this.log(`attach socket for ${session} took no frame: ${errText(err)} — falling through`);
|
|
416
|
+
this.attached.delete(session);
|
|
417
|
+
this.registry.setAttached(session, false);
|
|
418
|
+
sock.destroy();
|
|
419
|
+
// Fall through: nothing was delivered, so this must not shadow a rung
|
|
420
|
+
// that could still reach the session.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
281
423
|
}
|
|
282
424
|
const adapter = (0, adapters_1.adapterFor)(target.runtime);
|
|
283
425
|
// ORDERED ABOVE THE FIFO, deliberately.
|
|
@@ -372,6 +514,58 @@ class RelayDaemon {
|
|
|
372
514
|
this.mailboxes.delete(session);
|
|
373
515
|
this.registry.setAttached(session, false);
|
|
374
516
|
}
|
|
517
|
+
// RUNG 3.5: NEVER CLONE A LIVING SESSION.
|
|
518
|
+
//
|
|
519
|
+
// A socket attach exits by design on every wake, so a healthy session is
|
|
520
|
+
// briefly unreachable between wakes — and to every rung above, that is
|
|
521
|
+
// indistinguishable from a session that has gone. On 2026-08-31 a message
|
|
522
|
+
// landed in that gap and the ladder spawned a headless twin of a session
|
|
523
|
+
// that was alive and re-arming: two agents on one name, both authorised to
|
|
524
|
+
// answer, both able to act. The twin behaved impeccably and still committed
|
|
525
|
+
// and pushed under an identity that already had an occupant.
|
|
526
|
+
//
|
|
527
|
+
// `ownerPid` is the session itself, so it survives the gap that `pid` and
|
|
528
|
+
// the socket do not. Alive means "wait for it", never "replace it".
|
|
529
|
+
//
|
|
530
|
+
// SKIPPED after a mailbox fell through, and that exception is load-bearing:
|
|
531
|
+
// ENXIO has already PROVEN no reader there, and a sandboxed agent records a
|
|
532
|
+
// namespaced pid that reads alive forever (spec §13) — so applying this to
|
|
533
|
+
// fifo sessions would permanently shadow the one rung that can still reach
|
|
534
|
+
// them, which is the 2026-08-31 00:04 regression in a new costume.
|
|
535
|
+
if (!mailbox && target.ownerPid !== undefined && this.isAlive(target.ownerPid)) {
|
|
536
|
+
const rearmed = await this.waitForReattach(session, this.reattachGraceMs);
|
|
537
|
+
if (rearmed) {
|
|
538
|
+
try {
|
|
539
|
+
await (0, socket_1.writeFrameAck)(rearmed, { type: "wake", conversationId: batch[0].conversationId, messages: batch });
|
|
540
|
+
this.record({ kind: "woken", via: "attach", session }, session, batch);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
catch (err) {
|
|
544
|
+
// The socket appeared and then went. Do NOT record a delivery, and do
|
|
545
|
+
// not fall to headless either — the session's process is still alive,
|
|
546
|
+
// so this is the held case arriving by a different door.
|
|
547
|
+
this.log(`re-armed socket for ${session} took no frame: ${errText(err)} — holding instead`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// HELD, not pending. The session's process is still there — it is mid-turn,
|
|
551
|
+
// which is the normal state of a working agent, not a failure. Recording
|
|
552
|
+
// this pending threw the message away and told the user their relay had
|
|
553
|
+
// stopped; spawning a headless one instead is the clone bug. The third
|
|
554
|
+
// option is the correct one: keep it and hand it over when it re-arms.
|
|
555
|
+
const conversationId = batch[0].conversationId;
|
|
556
|
+
const total = this.held.countFor(session);
|
|
557
|
+
if (total + batch.length > RelayDaemon.MAX_HELD) {
|
|
558
|
+
this.record({
|
|
559
|
+
kind: "pending",
|
|
560
|
+
session,
|
|
561
|
+
reason: `session process ${target.ownerPid} is alive but has not re-attached, and ${total} message(s) are already held — not holding more`,
|
|
562
|
+
}, session, batch);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
this.held.add(session, conversationId, batch);
|
|
566
|
+
this.log(`holding ${batch.length} message(s) for ${session} in ${conversationId} until it re-attaches (${this.held.countFor(session)} held)`);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
375
569
|
const resolved = await this.resolveResume(target);
|
|
376
570
|
const check = adapter.canResume(resolved);
|
|
377
571
|
if (!check.ok) {
|
|
@@ -414,48 +608,110 @@ class RelayDaemon {
|
|
|
414
608
|
}
|
|
415
609
|
executable = binary.path;
|
|
416
610
|
}
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
611
|
+
// THE LEASE — around the WHOLE headless phase, rich transport included.
|
|
612
|
+
//
|
|
613
|
+
// `SessionQueue` already serialises `deliver()`, so this is NOT what stops
|
|
614
|
+
// a second headless turn under normal flow; it is the guard for the paths
|
|
615
|
+
// that do not go through the queue, and the place the invariant is stated
|
|
616
|
+
// rather than assumed. It sits ABOVE `richTransport` because Codex's real
|
|
617
|
+
// headless turn runs there — a lease that wrapped only the plain spawn left
|
|
618
|
+
// the primary path uncovered, which is worse than none: it reads as
|
|
619
|
+
// protection while protecting the branch least likely to be taken.
|
|
620
|
+
//
|
|
621
|
+
// WHAT IT DOES NOT DO, deliberately. An attach arriving mid-turn is still
|
|
622
|
+
// accepted and only logged. Declining it would make a session with a HUMAN
|
|
623
|
+
// sitting at it unreachable in order to protect a spawned proxy, which is a
|
|
624
|
+
// worse failure than the overlap: the person gets silence and no way to see
|
|
625
|
+
// why. So this is mutual exclusion between headless turns, and observability
|
|
626
|
+
// for attach-vs-headless. Claiming more than that is what the review caught.
|
|
627
|
+
if (this.headlessInFlight.has(session)) {
|
|
628
|
+
this.record({ kind: "pending", session, reason: "a headless turn is already running for this session — refusing to start a second" }, session, batch);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
this.headlessInFlight.add(session);
|
|
632
|
+
// DELIBERATELY NOT AWAITED.
|
|
633
|
+
//
|
|
634
|
+
// `deliver()` runs under `SessionQueue`'s per-session lock, and a headless
|
|
635
|
+
// turn takes MINUTES. Awaiting it here holds that lock for the whole turn,
|
|
636
|
+
// so every later message for this session parks in the queue: not delivered,
|
|
637
|
+
// not recorded pending, not logged. Measured 2026-08-31 — three messages
|
|
638
|
+
// from the room vanished exactly this way while a twin ran, and the only
|
|
639
|
+
// reason anyone noticed was the human asking whether the agent was alive.
|
|
640
|
+
//
|
|
641
|
+
// Released here, the lease still stops a SECOND headless turn starting, and
|
|
642
|
+
// the message that would have started it is now recorded pending with a
|
|
643
|
+
// reason a person can read. Silence becomes a visible refusal.
|
|
644
|
+
void this.runHeadlessTurn({ session, batch, target, adapter, resolved, executable, prompt, args, file })
|
|
645
|
+
.catch((err) => {
|
|
646
|
+
// Nothing awaits this promise any more, so an escaping rejection would
|
|
647
|
+
// be an unhandled one — which can take the whole daemon down and with it
|
|
648
|
+
// every other session. A turn that threw also did not answer anybody, so
|
|
649
|
+
// the honest record is pending.
|
|
650
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
651
|
+
this.log(`headless turn for ${session} threw: ${reason}`);
|
|
652
|
+
this.record({ kind: "pending", session, reason: `headless turn threw: ${reason}` }, session, batch);
|
|
653
|
+
})
|
|
654
|
+
.finally(() => {
|
|
655
|
+
this.headlessInFlight.delete(session);
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The headless turn itself, outside the delivery lock.
|
|
660
|
+
*
|
|
661
|
+
* Split from `deliver()` for one reason: its duration must not decide whether
|
|
662
|
+
* OTHER messages for this session are handled. Everything it records — woken,
|
|
663
|
+
* or pending with a reason — happens whenever the turn actually ends.
|
|
664
|
+
*/
|
|
665
|
+
async runHeadlessTurn(ctx) {
|
|
666
|
+
const { session, batch, target, adapter, resolved, executable, prompt, args, file } = ctx;
|
|
667
|
+
try {
|
|
668
|
+
// A runtime with a richer transport than "spawn a command" gets to use it.
|
|
669
|
+
// Only a transport that could not be used AT ALL falls through to the spawn:
|
|
670
|
+
// a failure that is a real answer about this session — an id that names no
|
|
671
|
+
// thread, a turn the runtime refused — would say exactly the same thing
|
|
672
|
+
// again down the older path, more slowly and less clearly.
|
|
673
|
+
const richTransport = this.runTurn
|
|
674
|
+
? (input) => this.runTurn({ runtime: target.runtime, ...input })
|
|
675
|
+
: adapter.runTurn?.bind(adapter);
|
|
676
|
+
if (richTransport) {
|
|
677
|
+
const outcome = await richTransport({ binaryPath: executable, target: resolved, prompt });
|
|
678
|
+
if (outcome.kind === "completed") {
|
|
679
|
+
this.record({ kind: "woken", via: "headless", session, exitCode: 0 }, session, batch);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (!outcome.transportUnusable) {
|
|
683
|
+
this.record({ kind: "pending", session, reason: outcome.reason }, session, batch);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
this.log(`${target.runtime}: falling back to a plain spawn — ${outcome.reason}`);
|
|
430
687
|
}
|
|
431
|
-
|
|
432
|
-
|
|
688
|
+
// A Windows .cmd needs its interpreter named explicitly, whatever produced
|
|
689
|
+
// the path — resolution or the session's own runtimeBin — so the decision
|
|
690
|
+
// lives here, after both, rather than inside either. Never a shell: these
|
|
691
|
+
// args carry the wake prompt, and therefore other people's message text.
|
|
692
|
+
const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
|
|
693
|
+
// The session's own directory when we know it: `cwd` is only where the
|
|
694
|
+
// attach process ran, and `codex exec` refuses to start outside a trusted
|
|
695
|
+
// directory at all.
|
|
696
|
+
const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
|
|
697
|
+
cwd: resolved.resumeCwd ?? resolved.cwd,
|
|
698
|
+
});
|
|
699
|
+
if (exitCode !== 0) {
|
|
700
|
+
// The binary ran and the turn still failed, so what we resolved may be
|
|
701
|
+
// stale — a half-finished upgrade, a runtime removed since. Drop it and
|
|
702
|
+
// prove it again next time rather than trusting it for the process's life.
|
|
703
|
+
this.binaries.delete(file);
|
|
704
|
+
// A non-zero headless turn did not necessarily reply. Recording it as
|
|
705
|
+
// delivered would claim an answer we cannot evidence.
|
|
706
|
+
this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
|
|
433
707
|
return;
|
|
434
708
|
}
|
|
435
|
-
this.
|
|
709
|
+
this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
|
|
436
710
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// args carry the wake prompt, and therefore other people's message text.
|
|
441
|
-
const plan = (0, runtime_binary_1.spawnPlanFor)(executable, process.platform);
|
|
442
|
-
// The session's own directory when we know it: `cwd` is only where the
|
|
443
|
-
// attach process ran, and `codex exec` refuses to start outside a trusted
|
|
444
|
-
// directory at all.
|
|
445
|
-
const { exitCode, stderr } = await this.spawnHeadless(plan.file, [...plan.prefixArgs, ...args], {
|
|
446
|
-
cwd: resolved.resumeCwd ?? resolved.cwd,
|
|
447
|
-
});
|
|
448
|
-
if (exitCode !== 0) {
|
|
449
|
-
// The binary ran and the turn still failed, so what we resolved may be
|
|
450
|
-
// stale — a half-finished upgrade, a runtime removed since. Drop it and
|
|
451
|
-
// prove it again next time rather than trusting it for the process's life.
|
|
452
|
-
this.binaries.delete(file);
|
|
453
|
-
// A non-zero headless turn did not necessarily reply. Recording it as
|
|
454
|
-
// delivered would claim an answer we cannot evidence.
|
|
455
|
-
this.record({ kind: "pending", session, reason: `headless ${target.runtime} exited ${exitCode}: ${stderr.slice(0, 200)}` }, session, batch);
|
|
456
|
-
return;
|
|
711
|
+
finally {
|
|
712
|
+
// Nothing to release here — the lease is cleared by the caller's
|
|
713
|
+
// `.finally`, which owns it for exactly as long as this turn runs.
|
|
457
714
|
}
|
|
458
|
-
this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
|
|
459
715
|
}
|
|
460
716
|
/**
|
|
461
717
|
* The proven executable for a runtime, resolved at most once per process
|
|
@@ -512,6 +768,13 @@ class RelayDaemon {
|
|
|
512
768
|
record(outcome, session, batch) {
|
|
513
769
|
if (outcome.kind === "woken") {
|
|
514
770
|
this.delivered += batch.length;
|
|
771
|
+
// The one witness `relay status` may treat as proof that a session is
|
|
772
|
+
// reachable again. A headless turn only counts when it exited cleanly: a
|
|
773
|
+
// spawn that ran and failed delivered nothing, and recording it would
|
|
774
|
+
// clear the very failure it just repeated.
|
|
775
|
+
if (outcome.via !== "headless" || outcome.exitCode === 0) {
|
|
776
|
+
this.registry.markDelivered(session, new Date().toISOString());
|
|
777
|
+
}
|
|
515
778
|
this.log(`woke ${session} via ${outcome.via} with ${batch.length} message(s)`);
|
|
516
779
|
return;
|
|
517
780
|
}
|
|
@@ -547,11 +810,20 @@ class RelayDaemon {
|
|
|
547
810
|
resumeCwd: frame.resumeCwd,
|
|
548
811
|
cwd: frame.cwd,
|
|
549
812
|
runtimeBin: frame.runtimeBin,
|
|
813
|
+
ownerPid: frame.ownerPid,
|
|
550
814
|
});
|
|
551
815
|
// A session that names itself supersedes anything discovery guessed
|
|
552
816
|
// for it, so drop the throttle and let the next wake use the new id.
|
|
553
817
|
if (frame.resumeId)
|
|
554
818
|
this.discoveryReasons.delete(frame.session);
|
|
819
|
+
// OVERLAP, AND WE CANNOT PREVENT IT — only report it. A headless turn
|
|
820
|
+
// already running was spawned because this session looked gone; it is
|
|
821
|
+
// mid-turn now, and the daemon will not kill a model that may be part
|
|
822
|
+
// way through writing something. Two agents briefly hold this name.
|
|
823
|
+
// Said out loud because the alternative is that nobody ever knows.
|
|
824
|
+
if (this.headlessInFlight.has(frame.session)) {
|
|
825
|
+
this.log(`WARNING: ${frame.session} attached while a headless turn for it is still running — two agents hold this session name until that turn ends`);
|
|
826
|
+
}
|
|
555
827
|
// EVICT THE PREVIOUS HOLDER. A session name has exactly one listener,
|
|
556
828
|
// and `set` alone would leave the old socket open and its process
|
|
557
829
|
// running — which is how two orphaned attaches came to be stacked on
|
|
@@ -566,6 +838,7 @@ class RelayDaemon {
|
|
|
566
838
|
this.registry.setAttached(frame.session, true);
|
|
567
839
|
(0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });
|
|
568
840
|
this.log(`attached: ${frame.session} (${frame.runtime})`);
|
|
841
|
+
void this.flushHeld(frame.session, sock);
|
|
569
842
|
return;
|
|
570
843
|
}
|
|
571
844
|
if (frame.type === "status") {
|
|
@@ -607,6 +880,7 @@ class RelayDaemon {
|
|
|
607
880
|
delivered: this.delivered,
|
|
608
881
|
sessions,
|
|
609
882
|
pending: this.pending,
|
|
883
|
+
held: this.held.summary(),
|
|
610
884
|
lastError: this.lastError,
|
|
611
885
|
};
|
|
612
886
|
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.HeldStore = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const config_1 = require("../config");
|
|
40
|
+
const FILE = "relay-held.json";
|
|
41
|
+
function heldPath() {
|
|
42
|
+
return path.join((0, config_1.configDir)(), FILE);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Messages held for a session that is alive but mid-turn, PERSISTED.
|
|
46
|
+
*
|
|
47
|
+
* A socket attach exits on every wake and the turn it triggers runs for minutes
|
|
48
|
+
* before the session re-arms. A message arriving in that window is early, not
|
|
49
|
+
* undeliverable, so the daemon holds it. That much already worked — in memory.
|
|
50
|
+
*
|
|
51
|
+
* In memory was not enough, and the gap was specific: a daemon restart lost
|
|
52
|
+
* every held message, and because the poll cursor had already advanced past
|
|
53
|
+
* them the server never sent them again. They were not pending, not delivered,
|
|
54
|
+
* and not anywhere. `relay status` showed nothing at all, so the only symptom
|
|
55
|
+
* was a person on a phone whose message was never answered — the exact reading
|
|
56
|
+
* of "the relay stopped" that holding was introduced to prevent.
|
|
57
|
+
*
|
|
58
|
+
* So the store is a file, and it is in `relay status`. Held is a THIRD state,
|
|
59
|
+
* neither delivered nor failed, and it has to be visible as itself: printing it
|
|
60
|
+
* as pending would make a working agent look broken, and printing nothing is
|
|
61
|
+
* what we just came from.
|
|
62
|
+
*
|
|
63
|
+
* ⚠️ THIS FILE CONTAINS MESSAGE TEXT IN PLAINTEXT. Everything else the relay
|
|
64
|
+
* persists is metadata — session names, resume ids, positions. This is chat
|
|
65
|
+
* content, sitting on disk on the user's own machine for as long as their agent
|
|
66
|
+
* takes to finish a turn. Mode 0600, in the same private config dir as the
|
|
67
|
+
* device credential, and deliberately emptied the moment a batch is handed
|
|
68
|
+
* over. That is the price of not losing messages, and it is worth naming rather
|
|
69
|
+
* than discovering.
|
|
70
|
+
*/
|
|
71
|
+
class HeldStore {
|
|
72
|
+
filePath;
|
|
73
|
+
/** session → conversationId → room. */
|
|
74
|
+
rooms = new Map();
|
|
75
|
+
constructor(filePath = heldPath()) {
|
|
76
|
+
this.filePath = filePath;
|
|
77
|
+
}
|
|
78
|
+
load() {
|
|
79
|
+
let raw;
|
|
80
|
+
try {
|
|
81
|
+
raw = JSON.parse(fs.readFileSync(this.filePath, "utf8"));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// Missing or corrupt. An empty store is the correct reading: it means the
|
|
85
|
+
// daemon holds nothing, which is true, rather than crashing a relay on
|
|
86
|
+
// startup over messages it cannot recover anyway.
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!raw || typeof raw !== "object")
|
|
90
|
+
return;
|
|
91
|
+
for (const [session, bySession] of Object.entries(raw)) {
|
|
92
|
+
if (!bySession || typeof bySession !== "object")
|
|
93
|
+
continue;
|
|
94
|
+
const rooms = new Map();
|
|
95
|
+
for (const [conversationId, value] of Object.entries(bySession)) {
|
|
96
|
+
const room = value;
|
|
97
|
+
if (!room || !Array.isArray(room.messages) || room.messages.length === 0)
|
|
98
|
+
continue;
|
|
99
|
+
rooms.set(conversationId, {
|
|
100
|
+
conversationId,
|
|
101
|
+
heldAt: typeof room.heldAt === "string" ? room.heldAt : new Date().toISOString(),
|
|
102
|
+
messages: room.messages,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (rooms.size > 0)
|
|
106
|
+
this.rooms.set(session, rooms);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
save() {
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const [session, rooms] of this.rooms) {
|
|
112
|
+
if (rooms.size === 0)
|
|
113
|
+
continue;
|
|
114
|
+
out[session] = Object.fromEntries(rooms);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
118
|
+
if (Object.keys(out).length === 0) {
|
|
119
|
+
// Nothing held: remove the file rather than leaving `{}` behind, so the
|
|
120
|
+
// plaintext does not outlive the hold by a single byte more than it
|
|
121
|
+
// must.
|
|
122
|
+
fs.rmSync(this.filePath, { force: true });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
fs.writeFileSync(this.filePath, JSON.stringify(out, null, 2), { mode: 0o600 });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Losing durability is not losing the hold: the in-memory copy still
|
|
129
|
+
// serves this process, which is exactly the behaviour we had before.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Hold a batch for one room.
|
|
134
|
+
*
|
|
135
|
+
* De-duplicated by message id: a cursor re-baseline (409 recovery re-reads
|
|
136
|
+
* from a watermark) can legitimately replay an event, and a room must not
|
|
137
|
+
* show the agent the same line twice when it finally re-arms.
|
|
138
|
+
*/
|
|
139
|
+
add(session, conversationId, messages) {
|
|
140
|
+
const rooms = this.rooms.get(session) ?? new Map();
|
|
141
|
+
const room = rooms.get(conversationId) ?? { conversationId, heldAt: new Date().toISOString(), messages: [] };
|
|
142
|
+
for (const m of messages) {
|
|
143
|
+
if (!room.messages.some((held) => held.id === m.id))
|
|
144
|
+
room.messages.push(m);
|
|
145
|
+
}
|
|
146
|
+
rooms.set(conversationId, room);
|
|
147
|
+
this.rooms.set(session, rooms);
|
|
148
|
+
this.save();
|
|
149
|
+
}
|
|
150
|
+
/** Every room held for a session, oldest hold first. */
|
|
151
|
+
roomsFor(session) {
|
|
152
|
+
const rooms = this.rooms.get(session);
|
|
153
|
+
if (!rooms)
|
|
154
|
+
return [];
|
|
155
|
+
return [...rooms.values()].sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
|
|
156
|
+
}
|
|
157
|
+
/** Drop one room's hold — called only once its messages are on the wire. */
|
|
158
|
+
clearRoom(session, conversationId) {
|
|
159
|
+
const rooms = this.rooms.get(session);
|
|
160
|
+
if (!rooms || !rooms.delete(conversationId))
|
|
161
|
+
return;
|
|
162
|
+
if (rooms.size === 0)
|
|
163
|
+
this.rooms.delete(session);
|
|
164
|
+
this.save();
|
|
165
|
+
}
|
|
166
|
+
/** How many messages are held for one session, across all its rooms. */
|
|
167
|
+
countFor(session) {
|
|
168
|
+
let n = 0;
|
|
169
|
+
for (const room of this.rooms.get(session)?.values() ?? [])
|
|
170
|
+
n += room.messages.length;
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
/** What `relay status` prints. Content is deliberately NOT included. */
|
|
174
|
+
summary() {
|
|
175
|
+
const out = [];
|
|
176
|
+
for (const [session, rooms] of this.rooms) {
|
|
177
|
+
for (const room of rooms.values()) {
|
|
178
|
+
out.push({ session, conversationId: room.conversationId, count: room.messages.length, heldAt: room.heldAt });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out.sort((a, b) => Date.parse(a.heldAt) - Date.parse(b.heldAt));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
exports.HeldStore = HeldStore;
|
package/dist/relay/mailbox.js
CHANGED
|
@@ -185,6 +185,7 @@ function registrationToTarget(reg) {
|
|
|
185
185
|
resumeCwd: reg.resumeCwd,
|
|
186
186
|
cwd: reg.cwd,
|
|
187
187
|
runtimeBin: reg.runtimeBin,
|
|
188
|
+
ownerPid: reg.ownerPid,
|
|
188
189
|
// Persisted, so a LATER headless resume still knows this session is
|
|
189
190
|
// sandboxed and must be told to re-arm in the foreground. `status()`
|
|
190
191
|
// recomputes the live rung for display; this is the remembered one.
|