@frockbot/kernel-contracts 0.3.3 → 0.3.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-contracts",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -368,6 +368,32 @@ describe("SessionStore", () => {
368
368
  ]);
369
369
  });
370
370
 
371
+ test("a failed durable write is reported and does not stop later writes", async () => {
372
+ const persisted: string[][] = [];
373
+ let failNext = true;
374
+ const root = await createStore(undefined, {
375
+ persistEvents: async (_sessionId, events) => {
376
+ await Promise.resolve();
377
+ if (failNext) {
378
+ failNext = false;
379
+ throw new Error("storage hiccup");
380
+ }
381
+ persisted.push(events.map((event) => event.type));
382
+ },
383
+ });
384
+ const session = root.sessions.create("durable-session");
385
+
386
+ // The Turn fails loudly rather than carrying on in memory over a log that
387
+ // silently stopped being written.
388
+ await expect(session.flush()).rejects.toThrow("storage hiccup");
389
+
390
+ // And the chain is not poisoned: chaining with `then` alone made every
391
+ // later write skip its callback for the life of the Session.
392
+ session.append({ type: "turn/start", turn: 1 });
393
+ await session.flush().catch(() => undefined);
394
+ expect(persisted).toEqual([["turn/start"]]);
395
+ });
396
+
371
397
  test("rehydrates a session and continues its sequence", async () => {
372
398
  const firstRoot = await createStore();
373
399
  const first = firstRoot.sessions.create("durable-session");
package/src/session.ts CHANGED
@@ -210,6 +210,8 @@ export class Session {
210
210
  #emit: (envelope: SessionEventEnvelope) => void;
211
211
  #persist?: PersistSessionEvents;
212
212
  #pendingPersistence: Promise<void> = Promise.resolve();
213
+ /** The first durable write that failed. Every later `flush` reports it. */
214
+ #persistFailure: Error | undefined;
213
215
  /**
214
216
  * Resolved attachment bytes, keyed by content hash, held only while this
215
217
  * Session is resident.
@@ -273,15 +275,27 @@ export class Session {
273
275
  for (const event of events) this.#emit({ sessionId: this.id, event });
274
276
  if (this.#persist && events.length > 0) {
275
277
  const durableEvents = structuredClone(events);
276
- this.#pendingPersistence = this.#pendingPersistence.then(() =>
277
- this.#persist?.(this.id, durableEvents),
278
- );
278
+ // A chain that has already rejected must still attempt this write.
279
+ // Chaining with `then` alone skipped the callback for the life of the
280
+ // Session — no event was ever written again while the loop carried on
281
+ // in memory — and left the rejection unhandled between an append and
282
+ // the next flush. The failure is remembered instead, and `flush` throws
283
+ // it, so the Turn fails loudly exactly once.
284
+ const pending = this.#pendingPersistence
285
+ .catch(() => undefined)
286
+ .then(() => this.#persist?.(this.id, durableEvents));
287
+ this.#pendingPersistence = pending;
288
+ void pending.catch((error: unknown) => {
289
+ this.#persistFailure ??=
290
+ error instanceof Error ? error : new Error(String(error));
291
+ });
279
292
  }
280
293
  return events;
281
294
  }
282
295
 
283
- flush(): Promise<void> {
284
- return this.#pendingPersistence;
296
+ async flush(): Promise<void> {
297
+ await this.#pendingPersistence.catch(() => undefined);
298
+ if (this.#persistFailure) throw this.#persistFailure;
285
299
  }
286
300
 
287
301
  /**
package/src/types.ts CHANGED
@@ -1478,7 +1478,11 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
1478
1478
  const label = `session event.refusals[${index}]`;
1479
1479
  const entry = eventRecord(refusal, label);
1480
1480
  requireEventKeys(entry, ["path", "reason"], label);
1481
- eventString(entry.path, `${label}.path`);
1481
+ // A refusal that names no path is still a refusal — the read was
1482
+ // declined at the root, and the reason is the part that matters.
1483
+ // Rejecting it here killed the Turn *after* the event was appended,
1484
+ // which left the durable log open inside that Turn and wedged the Bot.
1485
+ eventString(entry.path, `${label}.path`, true);
1482
1486
  eventString(entry.reason, `${label}.reason`);
1483
1487
  });
1484
1488
  break;