@frockbot/kernel-contracts 0.3.6 → 0.3.7

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.6",
3
+ "version": "0.3.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from "./isolate-context-catalog.generated.js";
7
7
  export * from "./loop-events.js";
8
8
  export * from "./model-invocation.js";
9
9
  export * from "./prompt-assembly.js";
10
+ export * from "./remote.js";
10
11
  export * from "./send-to-user.js";
11
12
  export * from "./session.js";
12
13
  export * from "./skills.js";
@@ -67,8 +67,22 @@ export type LlmReconciliationOutcome =
67
67
  events: readonly LlmStreamEvent[];
68
68
  }
69
69
  | {
70
+ /**
71
+ * The effect may still exist at the provider but cannot be read right
72
+ * now. The run parks and can be reconciled again later.
73
+ */
70
74
  status: "unavailable";
71
75
  reason: string;
76
+ }
77
+ | {
78
+ /**
79
+ * The provider keeps no durable copy of this effect, so no later attempt
80
+ * can do better. The run settles as a failure — with whatever text was
81
+ * already journaled preserved — rather than parking forever on a
82
+ * retrieval that will never succeed.
83
+ */
84
+ status: "not-retrievable";
85
+ reason: string;
72
86
  };
73
87
 
74
88
  export interface LlmReconciliationCapability {
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ RemoteCallTimeoutError,
4
+ remoteCallV1,
5
+ retryOnceV1,
6
+ withDeadlineV1,
7
+ } from "./remote.js";
8
+
9
+ describe("a remote call is bounded", () => {
10
+ test("a call that never answers is abandoned, not waited on", async () => {
11
+ const signals: AbortSignal[] = [];
12
+ const call = await withDeadlineV1(
13
+ "the ledger",
14
+ (signal) => {
15
+ signals.push(signal);
16
+ return new Promise<never>(() => {});
17
+ },
18
+ 10,
19
+ ).catch((error: unknown) => error);
20
+
21
+ expect(call).toBeInstanceOf(RemoteCallTimeoutError);
22
+ expect((call as Error).message).toContain("the ledger");
23
+ // The binding is told, even though the deadline is a bound on waiting and
24
+ // not a guarantee that the effect did not land.
25
+ expect(signals[0]!.aborted).toBe(true);
26
+ });
27
+
28
+ test("an answer inside the deadline is returned unchanged", async () => {
29
+ expect(
30
+ await withDeadlineV1("the ledger", () => Promise.resolve(7), 1_000),
31
+ ).toBe(7);
32
+ });
33
+
34
+ test("a transient failure is tried once more, and only once", async () => {
35
+ let attempts = 0;
36
+ expect(
37
+ await retryOnceV1(() => {
38
+ attempts += 1;
39
+ return attempts === 1
40
+ ? Promise.reject(new Error("blip"))
41
+ : Promise.resolve("second");
42
+ }),
43
+ ).toBe("second");
44
+ expect(attempts).toBe(2);
45
+
46
+ let always = 0;
47
+ await expect(
48
+ retryOnceV1(() => {
49
+ always += 1;
50
+ return Promise.reject(new Error("really down"));
51
+ }),
52
+ ).rejects.toThrow("really down");
53
+ expect(always).toBe(2);
54
+ });
55
+
56
+ test("a hung call is retried under its own fresh deadline", async () => {
57
+ let attempts = 0;
58
+ await expect(
59
+ remoteCallV1(
60
+ "the memory index",
61
+ () => {
62
+ attempts += 1;
63
+ return new Promise<never>(() => {});
64
+ },
65
+ 10,
66
+ ),
67
+ ).rejects.toBeInstanceOf(RemoteCallTimeoutError);
68
+ expect(attempts).toBe(2);
69
+ });
70
+ });
package/src/remote.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Bounds on a remote call.
3
+ *
4
+ * `MEMORY_FILES` and `MEMORY_INDEX` are remote bindings even in development,
5
+ * and so are the cross-Durable-Object ledger and membership calls beside them.
6
+ * Every one of those seams used to be a bare `await`: no deadline, no retry.
7
+ * A hung binding therefore hung the whole Turn to the platform limit, and a
8
+ * blip that a second attempt would have survived failed a Turn instead.
9
+ *
10
+ * These are two small functions rather than a client wrapper on purpose. The
11
+ * seams are in several Packages and take several shapes, and what they all
12
+ * need is the same two sentences: do not wait forever, and try a transient
13
+ * failure once more.
14
+ */
15
+
16
+ /** How long one remote call may take before it is abandoned. */
17
+ export const REMOTE_CALL_TIMEOUT_MS_V1 = 10_000;
18
+
19
+ export class RemoteCallTimeoutError extends Error {
20
+ constructor(label: string, timeoutMs: number) {
21
+ super(`${label} did not answer within ${timeoutMs}ms`);
22
+ this.name = "RemoteCallTimeoutError";
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Runs one remote call under a deadline.
28
+ *
29
+ * The deadline is a bound on *waiting*, not a cancellation: a binding that
30
+ * ignores its `AbortSignal` may still land its effect, which is why every
31
+ * caller of this treats a timeout the way it treats any other uncertain
32
+ * outcome rather than assuming nothing happened.
33
+ */
34
+ export async function withDeadlineV1<T>(
35
+ label: string,
36
+ call: (signal: AbortSignal) => Promise<T>,
37
+ timeoutMs: number = REMOTE_CALL_TIMEOUT_MS_V1,
38
+ ): Promise<T> {
39
+ const controller = new AbortController();
40
+ let timer: ReturnType<typeof setTimeout> | undefined;
41
+ const expiry = new Promise<never>((_resolve, reject) => {
42
+ timer = setTimeout(() => {
43
+ controller.abort();
44
+ reject(new RemoteCallTimeoutError(label, timeoutMs));
45
+ }, timeoutMs);
46
+ });
47
+ try {
48
+ return await Promise.race([call(controller.signal), expiry]);
49
+ } finally {
50
+ if (timer !== undefined) clearTimeout(timer);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Runs a call, and runs it once more if the first attempt threw.
56
+ *
57
+ * One retry, not a backoff schedule: the caller is inside a Turn a person is
58
+ * waiting on, and the failure this recovers from is a blip. Anything that
59
+ * fails twice is a real failure and is reported as one.
60
+ */
61
+ export async function retryOnceV1<T>(call: () => Promise<T>): Promise<T> {
62
+ try {
63
+ return await call();
64
+ } catch {
65
+ return call();
66
+ }
67
+ }
68
+
69
+ /** A remote call under a deadline, attempted twice. */
70
+ export function remoteCallV1<T>(
71
+ label: string,
72
+ call: (signal: AbortSignal) => Promise<T>,
73
+ timeoutMs: number = REMOTE_CALL_TIMEOUT_MS_V1,
74
+ ): Promise<T> {
75
+ return retryOnceV1(() => withDeadlineV1(label, call, timeoutMs));
76
+ }
package/src/types.ts CHANGED
@@ -514,7 +514,8 @@ export interface SessionEventMap {
514
514
  facts: Array<{
515
515
  scope: MemoryScopeNameV1;
516
516
  projectId: string;
517
- tier: "profile" | "log";
517
+ /** The tier it was written as; a note lives in the log file. */
518
+ tier: "profile" | "log" | "note";
518
519
  via: string;
519
520
  learnedAt: string;
520
521
  text: string;
@@ -548,7 +549,14 @@ export interface SessionEventMap {
548
549
  action: "write" | "forget";
549
550
  scope: MemoryScopeNameV1;
550
551
  projectId: string;
551
- tier: "profile" | "log" | "note";
552
+ /**
553
+ * `pending` when the intent cannot name a tier yet. A forget may rewrite
554
+ * the profile file, one or more log files, or write a retraction, and
555
+ * which it is, is not known until it has run; the `memory/written` events
556
+ * that follow name the real tier and path.
557
+ */
558
+ tier: "profile" | "log" | "note" | "pending";
559
+ /** Empty when the intent cannot name a path yet, for the same reason. */
552
560
  path: string;
553
561
  contentHash: string;
554
562
  };
@@ -801,6 +809,17 @@ function memoryTier(value: unknown, label: string): void {
801
809
  }
802
810
  }
803
811
 
812
+ /**
813
+ * The tier an *intent* names. A forget does not know which files it will
814
+ * touch until it has run — it may rewrite the profile file, one or more log
815
+ * files, or write a retraction — so `pending` is the honest answer, and the
816
+ * `memory/written` events that follow name the real tier and path.
817
+ */
818
+ function memoryIntentTier(value: unknown, label: string): void {
819
+ if (value === "pending") return;
820
+ memoryTier(value, label);
821
+ }
822
+
804
823
  function memoryAction(value: unknown, label: string): void {
805
824
  if (value !== "write" && value !== "forget") {
806
825
  throw new Error(`${label} is invalid`);
@@ -1624,9 +1643,9 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
1624
1643
  );
1625
1644
  memoryScope(entry.scope, `${label}.scope`);
1626
1645
  eventString(entry.projectId, `${label}.projectId`, true);
1627
- if (entry.tier !== "profile" && entry.tier !== "log") {
1628
- throw new Error(`${label}.tier is invalid`);
1629
- }
1646
+ // `note` too: a note lives in the log file, and recording it as `log`
1647
+ // left a reader of the durable event unable to tell the tiers apart.
1648
+ memoryTier(entry.tier, `${label}.tier`);
1630
1649
  eventString(entry.via, `${label}.via`, true);
1631
1650
  eventString(entry.learnedAt, `${label}.learnedAt`);
1632
1651
  eventString(entry.text, `${label}.text`);
@@ -1681,8 +1700,8 @@ export function decodeSessionEvent(input: unknown): SessionEvent {
1681
1700
  memoryAction(event.action, "session event.action");
1682
1701
  memoryScope(event.scope, "session event.scope");
1683
1702
  eventString(event.projectId, "session event.projectId", true);
1684
- memoryTier(event.tier, "session event.tier");
1685
- eventString(event.path, "session event.path");
1703
+ memoryIntentTier(event.tier, "session event.tier");
1704
+ eventString(event.path, "session event.path", true);
1686
1705
  eventString(event.contentHash, "session event.contentHash");
1687
1706
  break;
1688
1707
  case "memory/written":
package/src/workspace.ts CHANGED
@@ -313,7 +313,18 @@ export function isWorkspaceConflictV1(
313
313
  }
314
314
 
315
315
  export type WorkspaceWriteOutcomeV1 =
316
- | { status: "ok"; generation: WorkspaceGenerationV1 }
316
+ | {
317
+ status: "ok";
318
+ generation: WorkspaceGenerationV1;
319
+ /**
320
+ * The bytes are durable but the generation ledger has not recorded them
321
+ * yet. A write is still `ok`: the fact is the bytes, and the ledger is
322
+ * the index of the fact. The generation travels beside the bytes, so
323
+ * `reconcile` repairs the entry on its own; a caller that wants to say
324
+ * so may, and a caller that does not may ignore it.
325
+ */
326
+ ledgerPending?: true;
327
+ }
317
328
  | WorkspaceConflictV1
318
329
  | WorkspaceFailureV1;
319
330