@lionhummer/constellation 0.10.0 → 0.11.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/README.md CHANGED
@@ -152,7 +152,7 @@ const receiver = new EventReceiver(client, hooks, {
152
152
  // One process: leave this out (an in-memory lock is used).
153
153
  // Several processes or pods: share the lock so only one works on an event at a time.
154
154
  lock: redisEventLock((args) => redis.call(...args)), // ioredis; node-redis: (args) => redis.sendCommand(args)
155
- onFailure: (eventId, error) => logger.warn({ eventId, error }, "event will be re-sent"),
155
+ onFailure: (eventId, error) => logger.warn({ eventId, error }, error.willRetry ? "retrying event" : "event will be re-sent"),
156
156
  });
157
157
 
158
158
  export async function webhook(request: Request): Promise<Response> {
@@ -177,6 +177,8 @@ Don't run tools inside the webhook call. Constellation waits 5 seconds for the a
177
177
 
178
178
  ### Recovery
179
179
 
180
+ A failure that may pass, such as a database lock timeout or a network error, is retried in the same process after 1, 3 and 9 seconds while it still holds the event (`retryDelaysMs`; `[]` turns this off). A re-send arriving meanwhile is answered `202`. Your own code can throw `PermanentEventError` to skip these retries.
181
+
180
182
  If your process dies while handling an event, you don't need a poller or a store to recover it. Constellation sends an unanswered `model.completed` event again after the response window (10 minutes by default; operators can lower it, for example to 2 minutes), up to 3 times. A re-sent event has the same `id`:
181
183
 
182
184
  - while another process still holds the event's lock, `claim` returns `null` and you answer `202`. Constellation then checks again after another window without counting a re-send;
@@ -201,7 +203,8 @@ If you answer an event twice, for example because the reply to your first answer
201
203
  | `200`, `outcome: "already_accepted"` | This event was already answered by you. `bodyMatched: false` means the earlier answer differed and stands. | `answered` |
202
204
  | `200`, `outcome: "event_closed"` | The request was cancelled or expired before your continuation arrived. Your telemetry is still recorded (`observationsRecorded`). | `answered`, `ended: "closed"` |
203
205
  | A permanent rejection (`PERMANENT_RESPONSE_CODES`, for example `400 bad_request`) of a continuation | The continuation can never be accepted. | The SDK sends an `observe` response with the error instead, which ends the request as failed: `ended: "failed"` |
204
- | Anything else: network error, timeout, `5xx`, `429`, `409 conflict`, `401`, `403` | Temporary. | `onFailure` runs and the call throws. The event stays unanswered and is re-sent. |
206
+ | `401`, `403` | Key or scope problem. | `onFailure` runs and the call throws. The event stays unanswered and is re-sent. |
207
+ | Anything else: network error, timeout, `5xx`, `429`, `409 conflict`, or an error thrown while preparing | Temporary. | `onFailure` runs with `willRetry: true` and the attempt is repeated after the next delay. After the last one, `onFailure` runs with `willRetry: false`, the call throws, and the event is re-sent. |
205
208
 
206
209
  While processing, the SDK renews the lock every third of its lease. If renewal fails, it aborts its own work through `signal`, so a re-send taken over elsewhere doesn't run alongside it.
207
210
 
@@ -223,7 +226,7 @@ Delivery is at least once. A crash after a tool ran but before the answer was ac
223
226
 
224
227
  ## Live output
225
228
 
226
- `requests.streamEvents(id, { after, signal })` streams `model.delta` events (text and reasoning as it is generated), followed by each step's final event. It reconnects automatically. To resume after a restart, pass the last `event.sequence` you processed as `after`. Streaming is for display only: run tools from the webhook, never from the stream. Stopping the stream does not cancel the request; `requests.cancel(id)` does.
229
+ `requests.streamEvents(id, { after, signal })` streams `model.delta` events (text and reasoning as it is generated), followed by each step's final event. It reconnects automatically and ends when the request has ended: the server closes the stream with an end frame after the request's last event, so the loop finishes without any state polling. To resume after a restart, pass the last `event.sequence` you processed as `after`. Streaming is for display only: run tools from the webhook, never from the stream. Stopping the stream does not cancel the request; `requests.cancel(id)` does.
227
230
 
228
231
  ## Telemetry
229
232
 
package/dist/index.js CHANGED
@@ -253,8 +253,11 @@ class RequestClient {
253
253
  `)) >= 0) {
254
254
  const frame = buffer.slice(0, boundary);
255
255
  buffer = buffer.slice(boundary + 2);
256
- const data = frame.split(`
257
- `).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join(`
256
+ const lines = frame.split(`
257
+ `);
258
+ if (lines.some((line) => line === "event: end" || line === "event:end"))
259
+ return;
260
+ const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join(`
258
261
  `);
259
262
  if (!data)
260
263
  continue;
@@ -270,41 +273,9 @@ class RequestClient {
270
273
  } finally {
271
274
  await reader.cancel().catch(() => {});
272
275
  }
273
- const inspection = await this.#replayRead(`/v1/requests/${encodeURIComponent(id)}/state`, options.signal);
274
- if (["completed", "failed", "cancelled"].includes(inspection.request.status)) {
275
- while (true) {
276
- const before = after;
277
- const tail = await this.#replayRead(`/v1/requests/${encodeURIComponent(id)}/events?after=${after}`, options.signal);
278
- for (const event of tail.items) {
279
- if (event.sequence <= after)
280
- continue;
281
- after = event.sequence;
282
- yield event;
283
- }
284
- if (tail.items.length === 0)
285
- return;
286
- if (after === before)
287
- throw new Error("Replay page did not advance the event cursor");
288
- }
289
- }
290
276
  await pause(500, options.signal);
291
277
  }
292
278
  }
293
- async#replayRead(path, signal) {
294
- for (;; ) {
295
- signal?.throwIfAborted();
296
- try {
297
- return await this.#call(path, "GET", undefined, signal);
298
- } catch (error) {
299
- signal?.throwIfAborted();
300
- if (error instanceof ConstellationError && error.status !== 429 && error.status < 500)
301
- throw error;
302
- if (error instanceof SyntaxError)
303
- throw error;
304
- await pause(500, signal);
305
- }
306
- }
307
- }
308
279
  get models() {
309
280
  return {
310
281
  list: (scope, signal) => this.#call(`/v1/models${queryString(scope)}`, "GET", undefined, signal),
@@ -493,19 +464,13 @@ class EventReceiver {
493
464
  const leaseMs = this.options.leaseMs ?? DEFAULT_LEASE_MS;
494
465
  if (!await this.#lock.acquire(event.id, leaseMs))
495
466
  return null;
496
- return async (signal) => {
497
- let hooks;
498
- try {
499
- hooks = typeof this.hooks === "function" ? await this.hooks(event) : this.hooks;
500
- } catch (error) {
501
- await this.#lock.release(event.id).catch(() => {});
502
- throw error;
503
- }
504
- return runClaimed(this.client, this.#lock, event, hooks, { ...this.options, signal: signal ?? this.options.signal });
505
- };
467
+ const hooks = this.hooks;
468
+ const load = typeof hooks === "function" ? () => hooks(event) : async () => hooks;
469
+ return (signal) => runClaimed(this.client, this.#lock, event, load, { ...this.options, signal: signal ?? this.options.signal });
506
470
  }
507
471
  }
508
472
  var DEFAULT_LEASE_MS = 60000;
473
+ var DEFAULT_RETRY_DELAYS_MS = [1000, 3000, 9000];
509
474
 
510
475
  class ClientTelemetry {
511
476
  parentSpanId;
@@ -699,10 +664,33 @@ async function prepareResponse(event, hooks, signal) {
699
664
  async function processEvent(client, lock, event, hooks, options = {}) {
700
665
  if (!await lock.acquire(event.id, options.leaseMs ?? DEFAULT_LEASE_MS))
701
666
  return { status: "busy" };
702
- return runClaimed(client, lock, event, hooks, options);
667
+ return runClaimed(client, lock, event, async () => hooks, options);
668
+ }
669
+ function retryable(error) {
670
+ if (error instanceof PermanentEventError)
671
+ return false;
672
+ if (error instanceof ConstellationError) {
673
+ return !PERMANENT_RESPONSE_CODES.has(error.code) && (error.status === 0 || [408, 409, 425, 429].includes(error.status) || error.status >= 500);
674
+ }
675
+ return true;
703
676
  }
704
- async function runClaimed(client, lock, event, hooks, options) {
677
+ function wait(ms, signal) {
678
+ signal.throwIfAborted();
679
+ return new Promise((resolve, reject) => {
680
+ const abort = () => {
681
+ clearTimeout(timer);
682
+ reject(signal.reason);
683
+ };
684
+ const timer = setTimeout(() => {
685
+ signal.removeEventListener("abort", abort);
686
+ resolve();
687
+ }, ms);
688
+ signal.addEventListener("abort", abort, { once: true });
689
+ });
690
+ }
691
+ async function runClaimed(client, lock, event, loadHooks, options) {
705
692
  const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
693
+ const delays = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
706
694
  const fence = new AbortController;
707
695
  const signal = options.signal ? AbortSignal.any([options.signal, fence.signal]) : fence.signal;
708
696
  const renewal = setInterval(() => {
@@ -711,48 +699,58 @@ async function runClaimed(client, lock, event, hooks, options) {
711
699
  fence.abort(new Error("Event lock lost"));
712
700
  }, () => fence.abort(new Error("Event lock unavailable")));
713
701
  }, Math.max(1000, Math.floor(leaseMs / 3)));
714
- let stage = "prepare";
715
702
  try {
716
- const response = await prepareResponse(event, hooks, signal);
717
- if (!response)
718
- return { status: "answered", action: "stop", receipt: null, ended: null };
719
- stage = "submit";
720
- signal.throwIfAborted();
721
- const observe = "action" in response && response.action === "observe";
722
- let receipt;
723
- try {
724
- receipt = await client.requests.submit(response, signal);
725
- } catch (error) {
726
- if (!(error instanceof ConstellationError) || !PERMANENT_RESPONSE_CODES.has(error.code) || observe)
727
- throw error;
728
- receipt = await client.requests.submit({
729
- action: "observe",
730
- idempotencyKey: event.id,
731
- inReplyTo: { requestId: event.requestId, eventId: event.id },
732
- telemetry: response.telemetry ?? { spans: [], logs: [] },
733
- error: { code: error.code, message: error.message.slice(0, 8192) || error.code }
734
- }, signal);
735
- return { status: "answered", action: "stop", receipt, ended: receipt.outcome === "event_closed" ? "closed" : "failed" };
736
- }
737
- const failed = observe && "error" in response && !!response.error;
738
- const ended = receipt.outcome === "event_closed" ? "closed" : observe ? failed ? "failed" : "completed" : null;
739
- return { status: "answered", action: observe ? "stop" : "continue", receipt, ended };
740
- } catch (error) {
741
- if (options.onFailure) {
742
- const detail = error instanceof ConstellationError ? { status: error.status, code: error.code, message: error.message } : { status: 0, code: error instanceof Error && error.name === "TimeoutError" ? "timeout" : stage === "submit" ? "transport_error" : "event_prepare_failed", message: error instanceof Error ? error.message : "Event processing failed" };
703
+ for (let attempt = 1;; attempt++) {
704
+ const progress = { stage: "prepare" };
743
705
  try {
744
- await options.onFailure(event.id, detail);
745
- } catch (monitorError) {
746
- if (error && typeof error === "object")
747
- Object.assign(error, { failureHookError: monitorError });
706
+ return await answer(client, event, await loadHooks(), signal, progress);
707
+ } catch (error) {
708
+ const willRetry = attempt <= delays.length && !signal.aborted && retryable(error);
709
+ if (options.onFailure) {
710
+ const detail = error instanceof ConstellationError ? { status: error.status, code: error.code, message: error.message } : { status: 0, code: error instanceof Error && error.name === "TimeoutError" ? "timeout" : progress.stage === "submit" ? "transport_error" : "event_prepare_failed", message: error instanceof Error ? error.message : "Event processing failed" };
711
+ try {
712
+ await options.onFailure(event.id, { ...detail, attempt, willRetry });
713
+ } catch (monitorError) {
714
+ if (error && typeof error === "object")
715
+ Object.assign(error, { failureHookError: monitorError });
716
+ }
717
+ }
718
+ if (!willRetry)
719
+ throw error;
720
+ await wait(delays[attempt - 1] ?? 0, signal);
748
721
  }
749
722
  }
750
- throw error;
751
723
  } finally {
752
724
  clearInterval(renewal);
753
725
  await lock.release(event.id).catch(() => {});
754
726
  }
755
727
  }
728
+ async function answer(client, event, hooks, signal, progress) {
729
+ const response = await prepareResponse(event, hooks, signal);
730
+ if (!response)
731
+ return { status: "answered", action: "stop", receipt: null, ended: null };
732
+ progress.stage = "submit";
733
+ signal.throwIfAborted();
734
+ const observe = "action" in response && response.action === "observe";
735
+ let receipt;
736
+ try {
737
+ receipt = await client.requests.submit(response, signal);
738
+ } catch (error) {
739
+ if (!(error instanceof ConstellationError) || !PERMANENT_RESPONSE_CODES.has(error.code) || observe)
740
+ throw error;
741
+ receipt = await client.requests.submit({
742
+ action: "observe",
743
+ idempotencyKey: event.id,
744
+ inReplyTo: { requestId: event.requestId, eventId: event.id },
745
+ telemetry: response.telemetry ?? { spans: [], logs: [] },
746
+ error: { code: error.code, message: error.message.slice(0, 8192) || error.code }
747
+ }, signal);
748
+ return { status: "answered", action: "stop", receipt, ended: receipt.outcome === "event_closed" ? "closed" : "failed" };
749
+ }
750
+ const failed = observe && "error" in response && !!response.error;
751
+ const ended = receipt.outcome === "event_closed" ? "closed" : observe ? failed ? "failed" : "completed" : null;
752
+ return { status: "answered", action: observe ? "stop" : "continue", receipt, ended };
753
+ }
756
754
 
757
755
  // src/index.ts
758
756
  function queryString2(values) {
@@ -46,11 +46,17 @@ export type EventOptions = {
46
46
  signal?: AbortSignal;
47
47
  /** Lock lease, renewed every third of it while processing. Keep it well below the response window. */
48
48
  leaseMs?: number;
49
- /** Reports a failure that leaves the event unanswered; Constellation will re-send it. */
49
+ /** Pauses before retrying a transient failure while still holding the event, before leaving
50
+ * it to Constellation's re-send. Default 1 s, 3 s and 9 s; `[]` leaves every failure to the re-send. */
51
+ retryDelaysMs?: readonly number[];
52
+ /** Reports each failed attempt. `willRetry` is false for the last one: the event stays
53
+ * unanswered and Constellation re-sends it after its response window. */
50
54
  onFailure?: (eventId: string, error: {
51
55
  status: number;
52
56
  code: string;
53
57
  message: string;
58
+ attempt: number;
59
+ willRetry: boolean;
54
60
  }) => void | Promise<void>;
55
61
  };
56
62
  /** The error result returned to the model for a call whose arguments were not valid JSON. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lionhummer/constellation",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "TypeScript client for durable model requests and model events",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",