@cubicecho/agent-core 2.0.2 → 2.0.3

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.
Files changed (2) hide show
  1. package/dist/events.js +37 -3
  2. package/package.json +1 -1
package/dist/events.js CHANGED
@@ -22,6 +22,22 @@ const MAX_EVENTS = 1000;
22
22
  const TRIM_SLACK = 256;
23
23
  /** How long a finished run stays readable, for a watcher that arrives just after the end. */
24
24
  const RETAIN_MS = 60_000;
25
+ /**
26
+ * The same for a run that has not said `done`, which is a far more dangerous thing to drop.
27
+ *
28
+ * A finished run has nothing more to say, so forgetting it a minute later costs a late watcher
29
+ * a backlog and nothing else. An unfinished one is still writing: `touched` only moves on
30
+ * `emit`, so a live run that spends a minute inside one slow tool call looked exactly like an
31
+ * abandoned one and was reaped out from under itself. What made that more than a lost backlog
32
+ * is that the next `emit` builds a fresh stream with `seq` back at zero — and `seq` is the
33
+ * field `RunEvent` documents for ordering and de-duplication, so a client that reconnects
34
+ * across the gap discards the new events as ones it has already seen.
35
+ *
36
+ * The sweep still has to reap them, because a run killed by a signal or simply forgotten
37
+ * reaches no `done` either. This is the backstop for a caller that never says so; `endRun` is
38
+ * for one that knows.
39
+ */
40
+ const RETAIN_UNENDED_MS = 30 * 60_000;
25
41
  const streams = new Map();
26
42
  const streamFor = (runId) => {
27
43
  const existing = streams.get(runId);
@@ -52,9 +68,11 @@ const streamFor = (runId) => {
52
68
  let sweeping = null;
53
69
  function sweep() {
54
70
  sweeping = null;
55
- const deadline = Date.now() - RETAIN_MS;
71
+ const now = Date.now();
56
72
  for (const [runId, stream] of streams) {
57
- if (stream.listeners.size === 0 && stream.touched <= deadline)
73
+ if (stream.listeners.size)
74
+ continue;
75
+ if (stream.touched <= now - (stream.ended ? RETAIN_MS : RETAIN_UNENDED_MS))
58
76
  streams.delete(runId);
59
77
  }
60
78
  scheduleSweep();
@@ -73,6 +91,17 @@ function scheduleSweep() {
73
91
  * @param runId The run to forget. An id nothing was emitted under is ignored.
74
92
  */
75
93
  export function endRun(runId) {
94
+ const stream = streams.get(runId);
95
+ if (!stream)
96
+ return;
97
+ // Watchers are parked on a promise that only an `emit` to *this* stream can resolve, and the
98
+ // delete below puts it beyond the reach of every later one — the next `emit` builds a fresh
99
+ // stream and wakes nobody. So they are told the run is over first: a watcher that is handed
100
+ // `done` completes, runs its `finally` and lets its consumer go, where one left parked holds
101
+ // an open subscription that can never say anything again. An SSE client on the other end of
102
+ // that is a connection that never closes.
103
+ if (stream.listeners.size)
104
+ emit(runId, { kind: "done", ok: false, text: "run ended" });
76
105
  streams.delete(runId);
77
106
  }
78
107
  /**
@@ -209,7 +238,12 @@ export async function* watch(runId) {
209
238
  // A watcher can name a run that has not started, or will never start. Nothing was recorded
210
239
  // under it, so nothing is left behind either — and a run that has ended has nothing more to
211
240
  // say to anyone, so the last watcher leaving takes the backlog with it.
212
- if (stream.listeners.size === 0 && (stream.ended || stream.events.length === 0)) {
241
+ // Against `stream` rather than the id: this generator may have outlived its own entry — a
242
+ // sweep or an `endRun` drops it and a later `emit` files the same run under a new one — and
243
+ // the last watcher of the old stream has no business deleting the new one.
244
+ if (streams.get(runId) === stream &&
245
+ stream.listeners.size === 0 &&
246
+ (stream.ended || stream.events.length === 0)) {
213
247
  streams.delete(runId);
214
248
  }
215
249
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",