baychat 0.11.2 → 0.12.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.
@@ -42,14 +42,23 @@ const queue_1 = require("./queue");
42
42
  const registry_1 = require("./registry");
43
43
  const socket_1 = require("./socket");
44
44
  const transport_1 = require("./transport");
45
+ const watermarks_1 = require("./watermarks");
46
+ /**
47
+ * How long a failed resume-id discovery is believed before it is retried.
48
+ *
49
+ * A session whose runtime left no identifying trace stays undiscoverable, so
50
+ * re-scanning every transcript on the box per message would make an
51
+ * unanswerable delivery expensive as well as pending.
52
+ */
53
+ const DISCOVERY_INTERVAL_MS = 10 * 60_000;
45
54
  class RelayDaemon {
46
55
  registry = new registry_1.SessionRegistry();
47
56
  queue;
48
57
  abort = new AbortController();
49
58
  /** Live attach sockets by session name. */
50
59
  attached = new Map();
51
- /** Per-conversation delivery watermark — the 409 catch-up baseline. */
52
- watermarks = new Map();
60
+ /** Per (session, conversation) delivery positions — the 409 catch-up baseline. */
61
+ watermarks = new watermarks_1.Watermarks();
53
62
  pending = [];
54
63
  server;
55
64
  startedAt = new Date().toISOString();
@@ -62,9 +71,15 @@ class RelayDaemon {
62
71
  transportDetail = "starting";
63
72
  /** Live session names from the last poll, for pruning and for `relay status`. */
64
73
  liveSessions = [];
74
+ /** Why discovery last refused, per session, and when — throttles the rescan. */
75
+ discoveryReasons = new Map();
76
+ discovery;
77
+ spawnHeadless;
65
78
  log;
66
79
  constructor(opts = {}) {
67
80
  this.log = opts.log ?? ((line) => console.log(`[relay] ${line}`));
81
+ this.discovery = opts.discovery ?? {};
82
+ this.spawnHeadless = opts.spawnHeadless ?? adapters_1.runHeadless;
68
83
  this.queue = new queue_1.SessionQueue((session, batch) => this.deliver(session, batch), (session, err) => {
69
84
  this.lastError = `delivery failed for ${session}: ${errText(err)}`;
70
85
  this.log(this.lastError);
@@ -119,6 +134,9 @@ class RelayDaemon {
119
134
  },
120
135
  onSessions: (names) => {
121
136
  this.liveSessions = names;
137
+ // A recovery must cover every live session, including one this relay has
138
+ // never had to route an event to — skipping it re-baselines it at "now".
139
+ this.watermarks.noteLiveSessions(names);
122
140
  const dropped = this.registry.pruneToLive(names);
123
141
  for (const name of dropped)
124
142
  this.log(`session ended server-side, dropped: ${name}`);
@@ -140,7 +158,7 @@ class RelayDaemon {
140
158
  });
141
159
  }
142
160
  /**
143
- * Route one event and record the conversation watermark.
161
+ * Route one event and record this session's position in that conversation.
144
162
  *
145
163
  * Routing is the SERVER's answer (`sessionName`), not a local guess: the
146
164
  * device poll already knows which session agent each event was queued for.
@@ -150,9 +168,10 @@ class RelayDaemon {
150
168
  */
151
169
  onEvent(event) {
152
170
  const { conversationId, sessionName, message } = event;
153
- const prior = this.watermarks.get(conversationId);
154
- if (!prior || message.createdAt > prior)
155
- this.watermarks.set(conversationId, message.createdAt);
171
+ // Per SESSION, not per conversation: a sibling in the same room that was
172
+ // behind must not have its recovery window truncated by a session that was
173
+ // ahead. An event with no session still registers the room — see Watermarks.
174
+ this.watermarks.record(sessionName, conversationId, message.createdAt);
156
175
  if (!sessionName) {
157
176
  this.log(`event ${message.id} carries no session (renamed or ended mid-poll) — ignoring`);
158
177
  return;
@@ -183,15 +202,25 @@ class RelayDaemon {
183
202
  return;
184
203
  }
185
204
  const adapter = (0, adapters_1.adapterFor)(target.runtime);
186
- const check = adapter.canResume(target);
205
+ // A target with no resume id is the unbounded failure this whole path
206
+ // exists to close: without one, `canResume` says no and the message waits
207
+ // for a human. Ask the runtime's own on-disk state who this session is
208
+ // before accepting that answer.
209
+ const resolved = await this.resolveResume(target);
210
+ const check = adapter.canResume(resolved);
187
211
  if (!check.ok) {
188
212
  // The honest outcome: it reached this box, and nothing answered it.
189
- this.record({ kind: "pending", session, reason: check.reason ?? "cannot resume" }, session, batch);
213
+ const why = this.discoveryReasons.get(session)?.reason;
214
+ const reason = why ? `${check.reason ?? "cannot resume"}; discovery: ${why}` : (check.reason ?? "cannot resume");
215
+ this.record({ kind: "pending", session, reason }, session, batch);
190
216
  return;
191
217
  }
192
218
  const prompt = (0, adapters_1.buildWakePrompt)(session, batch[0].conversationId, batch);
193
- const { file, args } = adapter.headlessCommand(target, prompt);
194
- const { exitCode, stderr } = await (0, adapters_1.runHeadless)(file, args, { cwd: target.cwd });
219
+ const { file, args } = adapter.headlessCommand(resolved, prompt);
220
+ // The session's own directory when we know it: `cwd` is only where the
221
+ // attach process ran, and `codex exec` refuses to start outside a trusted
222
+ // directory at all.
223
+ const { exitCode, stderr } = await this.spawnHeadless(file, args, { cwd: resolved.resumeCwd ?? resolved.cwd });
195
224
  if (exitCode !== 0) {
196
225
  // A non-zero headless turn did not necessarily reply. Recording it as
197
226
  // delivered would claim an answer we cannot evidence.
@@ -200,6 +229,43 @@ class RelayDaemon {
200
229
  }
201
230
  this.record({ kind: "woken", via: "headless", session, exitCode }, session, batch);
202
231
  }
232
+ /**
233
+ * Fill in a missing resume id from the runtime's on-disk state, at most once
234
+ * every `DISCOVERY_INTERVAL_MS` per session.
235
+ *
236
+ * Throttled because a refusal is the common steady state — a session whose
237
+ * runtime left no trace will never become discoverable — and re-reading every
238
+ * transcript on the box for each arriving message would make an unanswerable
239
+ * delivery expensive as well as pending.
240
+ *
241
+ * A discovered id is persisted with its provenance, so the search happens
242
+ * once per session rather than once per wake, and `relay status` can show a
243
+ * human where the id came from.
244
+ */
245
+ async resolveResume(target) {
246
+ if (target.resumeId)
247
+ return target;
248
+ const last = this.discoveryReasons.get(target.name);
249
+ if (last && Date.now() - last.at < DISCOVERY_INTERVAL_MS)
250
+ return target;
251
+ const result = await (0, adapters_1.adapterFor)(target.runtime).discoverResume(target, this.discovery);
252
+ if (!result.ok) {
253
+ this.discoveryReasons.set(target.name, { at: Date.now(), reason: result.reason });
254
+ this.log(`no resume id for ${target.name}: ${result.reason}`);
255
+ return target;
256
+ }
257
+ this.discoveryReasons.delete(target.name);
258
+ const updated = this.registry.upsert({
259
+ name: target.name,
260
+ runtime: target.runtime,
261
+ resumeId: result.resumeId,
262
+ resumeSource: result.source,
263
+ resumeEvidence: result.evidence,
264
+ resumeCwd: result.cwd,
265
+ });
266
+ this.log(`resume id for ${target.name}: ${result.resumeId} (${result.evidence})`);
267
+ return updated;
268
+ }
203
269
  record(outcome, session, batch) {
204
270
  if (outcome.kind === "woken") {
205
271
  this.delivered += batch.length;
@@ -233,8 +299,15 @@ class RelayDaemon {
233
299
  name: frame.session,
234
300
  runtime: frame.runtime,
235
301
  resumeId: frame.resumeId,
302
+ resumeSource: frame.resumeSource,
303
+ resumeEvidence: frame.resumeEvidence,
304
+ resumeCwd: frame.resumeCwd,
236
305
  cwd: frame.cwd,
237
306
  });
307
+ // A session that names itself supersedes anything discovery guessed
308
+ // for it, so drop the throttle and let the next wake use the new id.
309
+ if (frame.resumeId)
310
+ this.discoveryReasons.delete(frame.session);
238
311
  this.attached.set(frame.session, sock);
239
312
  this.registry.setAttached(frame.session, true);
240
313
  (0, socket_1.writeFrame)(sock, { type: "attached", session: frame.session });