@nimbus-sh/fabric 0.1.0 → 0.3.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.
Files changed (104) hide show
  1. package/README.md +208 -293
  2. package/dist/bindings.js +5 -5
  3. package/dist/budgets.d.ts +132 -0
  4. package/dist/budgets.d.ts.map +1 -0
  5. package/dist/budgets.js +248 -0
  6. package/dist/composition.d.ts +3 -0
  7. package/dist/composition.d.ts.map +1 -0
  8. package/dist/composition.js +2 -0
  9. package/dist/connections.d.ts +81 -0
  10. package/dist/connections.d.ts.map +1 -0
  11. package/dist/connections.js +114 -0
  12. package/dist/derived.d.ts +65 -0
  13. package/dist/derived.d.ts.map +1 -0
  14. package/dist/derived.js +95 -0
  15. package/dist/do-calls.d.ts +94 -0
  16. package/dist/do-calls.d.ts.map +1 -0
  17. package/dist/do-calls.js +111 -0
  18. package/dist/facet-pool.d.ts +90 -0
  19. package/dist/facet-pool.d.ts.map +1 -0
  20. package/dist/facet-pool.js +113 -0
  21. package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
  22. package/dist/fanout.d.ts.map +1 -0
  23. package/dist/{fanout-pool.js → fanout.js} +20 -20
  24. package/dist/{launch-journal.d.ts → fenced-work.d.ts} +58 -17
  25. package/dist/fenced-work.d.ts.map +1 -0
  26. package/dist/fenced-work.js +241 -0
  27. package/dist/generation.d.ts +69 -0
  28. package/dist/generation.d.ts.map +1 -0
  29. package/dist/generation.js +118 -0
  30. package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
  31. package/dist/image-store.d.ts.map +1 -0
  32. package/dist/{facet-image-store.js → image-store.js} +4 -4
  33. package/dist/index.d.ts +16 -8
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +16 -8
  36. package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
  37. package/dist/isolate-pool.d.ts.map +1 -0
  38. package/dist/{loader-pool.js → isolate-pool.js} +20 -20
  39. package/dist/journal.d.ts +111 -0
  40. package/dist/journal.d.ts.map +1 -0
  41. package/dist/journal.js +177 -0
  42. package/dist/outbox.d.ts +249 -0
  43. package/dist/outbox.d.ts.map +1 -0
  44. package/dist/outbox.js +355 -0
  45. package/dist/process-fabric.d.ts +33 -15
  46. package/dist/process-fabric.d.ts.map +1 -1
  47. package/dist/process-fabric.js +25 -15
  48. package/dist/process-host.d.ts +1 -1
  49. package/dist/process-host.d.ts.map +1 -1
  50. package/dist/process-host.js +19 -11
  51. package/dist/sealed.d.ts +78 -0
  52. package/dist/sealed.d.ts.map +1 -0
  53. package/dist/sealed.js +145 -0
  54. package/dist/timers.d.ts +138 -0
  55. package/dist/timers.d.ts.map +1 -0
  56. package/dist/timers.js +231 -0
  57. package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +24 -21
  58. package/dist/turn-budget.d.ts.map +1 -0
  59. package/dist/{launch-pacer.js → turn-budget.js} +24 -12
  60. package/dist/workerd-facet-host.d.ts +67 -70
  61. package/dist/workerd-facet-host.d.ts.map +1 -1
  62. package/dist/workerd-facet-host.js +129 -181
  63. package/examples/agent-core-adapter.ts +191 -0
  64. package/package.json +4 -2
  65. package/src/bindings.ts +6 -6
  66. package/src/budgets.ts +308 -0
  67. package/src/composition.ts +16 -0
  68. package/src/connections.ts +140 -0
  69. package/src/derived.ts +135 -0
  70. package/src/do-calls.ts +156 -0
  71. package/src/facet-pool.ts +157 -0
  72. package/src/{fanout-pool.ts → fanout.ts} +35 -35
  73. package/src/{launch-journal.ts → fenced-work.ts} +129 -42
  74. package/src/generation.ts +144 -0
  75. package/src/{facet-image-store.ts → image-store.ts} +9 -9
  76. package/src/index.ts +16 -8
  77. package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
  78. package/src/journal.ts +242 -0
  79. package/src/node-async-hooks.d.ts +14 -0
  80. package/src/outbox.ts +520 -0
  81. package/src/process-fabric.ts +43 -34
  82. package/src/process-host.ts +22 -20
  83. package/src/sealed.ts +150 -0
  84. package/src/timers.ts +294 -0
  85. package/src/{launch-pacer.ts → turn-budget.ts} +34 -27
  86. package/src/workerd-facet-host.ts +159 -208
  87. package/dist/alarms.d.ts +0 -134
  88. package/dist/alarms.d.ts.map +0 -1
  89. package/dist/alarms.js +0 -214
  90. package/dist/ctx-exports.d.ts +0 -47
  91. package/dist/ctx-exports.d.ts.map +0 -1
  92. package/dist/ctx-exports.js +0 -54
  93. package/dist/facet-image-store.d.ts.map +0 -1
  94. package/dist/fanout-pool.d.ts.map +0 -1
  95. package/dist/launch-journal.d.ts.map +0 -1
  96. package/dist/launch-journal.js +0 -154
  97. package/dist/launch-pacer.d.ts.map +0 -1
  98. package/dist/loader-ledger.d.ts +0 -57
  99. package/dist/loader-ledger.d.ts.map +0 -1
  100. package/dist/loader-ledger.js +0 -91
  101. package/dist/loader-pool.d.ts.map +0 -1
  102. package/src/alarms.ts +0 -275
  103. package/src/ctx-exports.ts +0 -77
  104. package/src/loader-ledger.ts +0 -112
@@ -0,0 +1,65 @@
1
+ /**
2
+ * derived.ts — a watermark memo: derive a cheap key, compare, rebuild only
3
+ * on change.
4
+ *
5
+ * Proteus's ActorAgent hand-rolls this pair four times — cached value plus
6
+ * cached key, compared and rebuilt inline: the system prompt (key composed
7
+ * from soul text, executors, model, tools, stance), the tool set (keyed
8
+ * partly on `_craftCacheKey()`, two synchronous SQLite aggregates), the MCP
9
+ * tool surface (keyed on UserDO's `mcp_updated_at`), and the SOUL text
10
+ * (no key at all — push-invalidated by `setSoul`). The consumer port moved
11
+ * the prompt and tool-set pairs onto `derived` cleanly; the MCP pair needs
12
+ * the per-call context and the hooks; the SOUL pair cannot move at all.
13
+ *
14
+ * The SOUL pair names this module's limit: a value LOADED asynchronously
15
+ * (an awaited file read at turn start) but READ synchronously (the prompt
16
+ * builder consults what is already in memory). Neither variant expresses
17
+ * that split — `derived` cannot await the load, `derivedAsync` cannot serve
18
+ * a synchronous read — so an async-load/sync-read snapshot stays a
19
+ * hand-rolled field with a push invalidation.
20
+ *
21
+ * `derived` is synchronous end to end, because its consumers are: Think
22
+ * calls `getSystemPrompt(): string` synchronously, and nothing synchronous
23
+ * may await — the init-gate rule. `derivedAsync` exists because ONE consumer
24
+ * call site needs it: the MCP surface awaits a cross-DO RPC for both the
25
+ * watermark and the build, and its proven failure policy is stale-on-error —
26
+ * a watermark or build failure serves the last good value without touching
27
+ * the stored key, and only surfaces when there is no value to serve.
28
+ *
29
+ * `invalidate()` is the push half the SOUL memo proved: an out-of-band write
30
+ * (`setSoul`) clears the memo so the next read rebuilds under an unchanged
31
+ * watermark.
32
+ */
33
+ export interface Derived<T, C = void> {
34
+ /**
35
+ * `context` reaches the watermark and the build of THIS call — the seam
36
+ * for per-call state (a stub, a caller identity, a work mode). The memo
37
+ * stores one value; the watermark must cover everything the build reads,
38
+ * context included, or a context change serves another context's value.
39
+ */
40
+ get(context: C): T;
41
+ /** Force the next get to rebuild, watermark unchanged. */
42
+ invalidate(): void;
43
+ }
44
+ /**
45
+ * The consumer's logging seams. Both MCP logs the port could not express:
46
+ * `onRebuild` fires after a build stores (the "rebuilt @ wm=N" line), and
47
+ * `onStale` — async only — fires when a failure serves the stale value,
48
+ * the one path where the error is otherwise absorbed. A surfaced error
49
+ * (nothing stale to serve) reports itself.
50
+ */
51
+ export interface DerivedHooks {
52
+ /** After a build stores. `previousKey` is undefined on the first build. */
53
+ onRebuild?(previousKey: string | number | undefined, nextKey: string | number): void;
54
+ }
55
+ export interface DerivedAsyncHooks extends DerivedHooks {
56
+ /** A watermark or build failure just served the stale value. */
57
+ onStale?(error: unknown): void;
58
+ }
59
+ export declare function derived<T, C = void>(watermark: (context: C) => string | number, build: (context: C, key: string | number) => T, hooks?: DerivedHooks): Derived<T, C>;
60
+ export interface DerivedAsync<T, C = void> {
61
+ get(context: C): Promise<T>;
62
+ invalidate(): void;
63
+ }
64
+ export declare function derivedAsync<T, C = void>(watermark: (context: C) => Promise<string | number>, build: (context: C, key: string | number) => Promise<T>, hooks?: DerivedAsyncHooks): DerivedAsync<T, C>;
65
+ //# sourceMappingURL=derived.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derived.d.ts","sourceRoot":"","sources":["../src/derived.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,MAAM,WAAW,OAAO,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI;IAClC;;;;;OAKG;IACH,GAAG,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,CAAC;IACnB,0DAA0D;IAC1D,UAAU,IAAI,IAAI,CAAC;CACpB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,SAAS,CAAC,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;CACtF;AAED,MAAM,WAAW,iBAAkB,SAAQ,YAAY;IACrD,gEAAgE;IAChE,OAAO,CAAC,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;CAChC;AAED,wBAAgB,OAAO,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,EACjC,SAAS,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,MAAM,GAAG,MAAM,EAC1C,KAAK,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,KAAK,CAAC,EAC9C,KAAK,GAAE,YAAiB,GACvB,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAmBf;AAED,MAAM,WAAW,YAAY,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI;IACvC,GAAG,CAAC,OAAO,EAAE,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5B,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,YAAY,CAAC,CAAC,EAAE,CAAC,GAAG,IAAI,EACtC,SAAS,EAAE,CAAC,OAAO,EAAE,CAAC,KAAK,OAAO,CAAC,MAAM,GAAG,MAAM,CAAC,EACnD,KAAK,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,EACvD,KAAK,GAAE,iBAAsB,GAC5B,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,CAsCpB"}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * derived.ts — a watermark memo: derive a cheap key, compare, rebuild only
3
+ * on change.
4
+ *
5
+ * Proteus's ActorAgent hand-rolls this pair four times — cached value plus
6
+ * cached key, compared and rebuilt inline: the system prompt (key composed
7
+ * from soul text, executors, model, tools, stance), the tool set (keyed
8
+ * partly on `_craftCacheKey()`, two synchronous SQLite aggregates), the MCP
9
+ * tool surface (keyed on UserDO's `mcp_updated_at`), and the SOUL text
10
+ * (no key at all — push-invalidated by `setSoul`). The consumer port moved
11
+ * the prompt and tool-set pairs onto `derived` cleanly; the MCP pair needs
12
+ * the per-call context and the hooks; the SOUL pair cannot move at all.
13
+ *
14
+ * The SOUL pair names this module's limit: a value LOADED asynchronously
15
+ * (an awaited file read at turn start) but READ synchronously (the prompt
16
+ * builder consults what is already in memory). Neither variant expresses
17
+ * that split — `derived` cannot await the load, `derivedAsync` cannot serve
18
+ * a synchronous read — so an async-load/sync-read snapshot stays a
19
+ * hand-rolled field with a push invalidation.
20
+ *
21
+ * `derived` is synchronous end to end, because its consumers are: Think
22
+ * calls `getSystemPrompt(): string` synchronously, and nothing synchronous
23
+ * may await — the init-gate rule. `derivedAsync` exists because ONE consumer
24
+ * call site needs it: the MCP surface awaits a cross-DO RPC for both the
25
+ * watermark and the build, and its proven failure policy is stale-on-error —
26
+ * a watermark or build failure serves the last good value without touching
27
+ * the stored key, and only surfaces when there is no value to serve.
28
+ *
29
+ * `invalidate()` is the push half the SOUL memo proved: an out-of-band write
30
+ * (`setSoul`) clears the memo so the next read rebuilds under an unchanged
31
+ * watermark.
32
+ */
33
+ export function derived(watermark, build, hooks = {}) {
34
+ let key;
35
+ let value;
36
+ let has = false;
37
+ return {
38
+ get(context) {
39
+ const next = watermark(context);
40
+ if (has && next === key)
41
+ return value;
42
+ value = build(context, next);
43
+ hooks.onRebuild?.(key, next);
44
+ key = next;
45
+ has = true;
46
+ return value;
47
+ },
48
+ invalidate() {
49
+ has = false;
50
+ value = undefined;
51
+ },
52
+ };
53
+ }
54
+ export function derivedAsync(watermark, build, hooks = {}) {
55
+ let key;
56
+ let value;
57
+ let has = false;
58
+ return {
59
+ async get(context) {
60
+ let next;
61
+ try {
62
+ next = await watermark(context);
63
+ }
64
+ catch (e) {
65
+ if (has) {
66
+ hooks.onStale?.(e);
67
+ return value;
68
+ }
69
+ throw e;
70
+ }
71
+ if (has && next === key)
72
+ return value;
73
+ let built;
74
+ try {
75
+ built = await build(context, next);
76
+ }
77
+ catch (e) {
78
+ if (has) {
79
+ hooks.onStale?.(e);
80
+ return value;
81
+ }
82
+ throw e;
83
+ }
84
+ value = built;
85
+ hooks.onRebuild?.(key, next);
86
+ key = next;
87
+ has = true;
88
+ return built;
89
+ },
90
+ invalidate() {
91
+ has = false;
92
+ value = undefined;
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * do-calls.ts — the two verbs for calling another Durable Object, split by
3
+ * the one property that decides whether a retry is safe.
4
+ *
5
+ * Both consumers asked for this. Proteus hand-wrote the retry
6
+ * (`cf-backend/src/lib/do-rpc.ts`) with the rule its header states:
7
+ * "An operation that appends, sends, charges or mints is never wrapped: a
8
+ * dropped call there may already have run, so a retry is a correctness bug
9
+ * wearing resilience as a costume." agent-core has no retry machinery at all
10
+ * and its backlog calls the gap "the most production-proven gap in the
11
+ * corpus". Here the rule is a type: `idempotent` retries, `mutating` cannot.
12
+ *
13
+ * What the platform contract requires, and this keeps:
14
+ * - a FRESH stub per attempt. Cloudflare documents that many exceptions
15
+ * leave a stub permanently broken, so both verbs take a stub RESOLVER,
16
+ * not a stub — which is also what lets placement pins and auth wrappers
17
+ * compose (agent-core's PlacementResolver pins an Actor to one
18
+ * jurisdiction for life; the resolver seam is where that lives).
19
+ * - `overloaded` is never retried, by either verb: retrying an overloaded
20
+ * object is what overloaded it.
21
+ * - attempts and backoff are the consumer-proven bounds: 3 attempts total,
22
+ * full-jitter delays in [0, 2**attempt * 60ms).
23
+ *
24
+ * The resolver MINTS a stub per call and the verb disposes each one it
25
+ * minted — that ownership is what makes the fresh-stub retry real.
26
+ *
27
+ * The stub method call itself happens inside the CALLER's closure
28
+ * (`(stub) => stub.method(args)`): nothing here proxies property resolution
29
+ * or dispatches by method name. Do NOT use these verbs around a dynamically
30
+ * loaded worker's entrypoint stub — those calls must stay direct property
31
+ * calls bracketed by `beginLoaderFetch` (budgets.ts records the 7/7 staging
32
+ * poisoning that rule comes from). These verbs are for Durable Object
33
+ * namespace stubs, where the thunk shape is production-proven in Proteus.
34
+ */
35
+ import { type DoCallClass } from '@nimbus-sh/platform/oom-classify.js';
36
+ export interface DoCallRetryPolicy {
37
+ maxAttempts?: number;
38
+ baseDelayMs?: number;
39
+ /**
40
+ * Called once per retry, before its backoff delay, with the failure the
41
+ * retry is answering. The consumer's logging seam: Proteus's hand-rolled
42
+ * predecessor logged every retry so a flaky object is visible in Workers
43
+ * Logs rather than silently absorbed, and `operation` names it there.
44
+ */
45
+ onRetry?(info: DoCallRetryInfo): void;
46
+ }
47
+ /** What one retry is answering: which call, which platform class, which
48
+ * attempt just failed out of how many. */
49
+ export interface DoCallRetryInfo {
50
+ operation: string;
51
+ classification: DoCallClass;
52
+ /** The 1-based attempt that failed; the retry about to run is attempt+1. */
53
+ attempt: number;
54
+ maxAttempts: number;
55
+ error: unknown;
56
+ }
57
+ /**
58
+ * Mints one stub per call. `idempotent` calls it once per attempt.
59
+ *
60
+ * MINT means mint. Both verbs dispose the stub they were handed when the
61
+ * call settles — on success as much as on failure (`disposeRpcResource`).
62
+ * A resolver that returns a shared, long-lived stub hands its other users
63
+ * a disposed stub, and only in production: a test double is a plain object
64
+ * with nothing to dispose, so the test stays green while the deployed
65
+ * Worker breaks on the second call.
66
+ */
67
+ export type DoStubResolver<S> = () => S | Promise<S>;
68
+ /**
69
+ * A failed `mutating` call, typed so the caller can act on WHAT failed:
70
+ * `classification` names the platform condition, and a transient class on a
71
+ * mutating call means the call may already have run — the indeterminacy the
72
+ * consumer's rule exists to surface rather than paper over.
73
+ */
74
+ export declare class DoCallError extends Error {
75
+ readonly operation: string;
76
+ readonly verb: 'idempotent' | 'mutating';
77
+ readonly classification: DoCallClass;
78
+ constructor(operation: string, verb: 'idempotent' | 'mutating', classification: DoCallClass, cause: unknown);
79
+ }
80
+ /**
81
+ * Call another Durable Object with an operation that is safe to repeat: a
82
+ * read, or a converge-to-a-value write. Transient failures retry on a fresh
83
+ * stub with full-jitter backoff; overloaded and permanent failures surface
84
+ * unchanged, as does the last error at exhaustion.
85
+ */
86
+ export declare function idempotent<S, T>(operation: string, stub: DoStubResolver<S>, call: (stub: S) => Promise<T>, policy?: DoCallRetryPolicy): Promise<T>;
87
+ /**
88
+ * Call another Durable Object with an operation that appends, sends, charges
89
+ * or mints. NEVER retried — a dropped call may already have run. Failure
90
+ * surfaces as a {@link DoCallError} carrying the classification, so the
91
+ * caller can tell a refusal from an indeterminate drop.
92
+ */
93
+ export declare function mutating<S, T>(operation: string, stub: DoStubResolver<S>, call: (stub: S) => Promise<T>): Promise<T>;
94
+ //# sourceMappingURL=do-calls.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"do-calls.d.ts","sourceRoot":"","sources":["../src/do-calls.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AAEH,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,qCAAqC,CAAC;AAU1G,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;OAKG;IACH,OAAO,CAAC,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;CACvC;AAED;2CAC2C;AAC3C,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,WAAW,CAAC;IAC5B,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,IAAI,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAErD;;;;;GAKG;AACH,qBAAa,WAAY,SAAQ,KAAK;IAElC,QAAQ,CAAC,SAAS,EAAE,MAAM;IAC1B,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,UAAU;IACxC,QAAQ,CAAC,cAAc,EAAE,WAAW;gBAF3B,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,YAAY,GAAG,UAAU,EAC/B,cAAc,EAAE,WAAW,EACpC,KAAK,EAAE,OAAO;CASjB;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,CAAC,CAAC,EAAE,CAAC,EACnC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,EAC7B,MAAM,GAAE,iBAAsB,GAC7B,OAAO,CAAC,CAAC,CAAC,CAoBZ;AAED;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,CAAC,EAAE,CAAC,EACjC,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,cAAc,CAAC,CAAC,CAAC,EACvB,IAAI,EAAE,CAAC,IAAI,EAAE,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,GAC5B,OAAO,CAAC,CAAC,CAAC,CAUZ"}
@@ -0,0 +1,111 @@
1
+ /**
2
+ * do-calls.ts — the two verbs for calling another Durable Object, split by
3
+ * the one property that decides whether a retry is safe.
4
+ *
5
+ * Both consumers asked for this. Proteus hand-wrote the retry
6
+ * (`cf-backend/src/lib/do-rpc.ts`) with the rule its header states:
7
+ * "An operation that appends, sends, charges or mints is never wrapped: a
8
+ * dropped call there may already have run, so a retry is a correctness bug
9
+ * wearing resilience as a costume." agent-core has no retry machinery at all
10
+ * and its backlog calls the gap "the most production-proven gap in the
11
+ * corpus". Here the rule is a type: `idempotent` retries, `mutating` cannot.
12
+ *
13
+ * What the platform contract requires, and this keeps:
14
+ * - a FRESH stub per attempt. Cloudflare documents that many exceptions
15
+ * leave a stub permanently broken, so both verbs take a stub RESOLVER,
16
+ * not a stub — which is also what lets placement pins and auth wrappers
17
+ * compose (agent-core's PlacementResolver pins an Actor to one
18
+ * jurisdiction for life; the resolver seam is where that lives).
19
+ * - `overloaded` is never retried, by either verb: retrying an overloaded
20
+ * object is what overloaded it.
21
+ * - attempts and backoff are the consumer-proven bounds: 3 attempts total,
22
+ * full-jitter delays in [0, 2**attempt * 60ms).
23
+ *
24
+ * The resolver MINTS a stub per call and the verb disposes each one it
25
+ * minted — that ownership is what makes the fresh-stub retry real.
26
+ *
27
+ * The stub method call itself happens inside the CALLER's closure
28
+ * (`(stub) => stub.method(args)`): nothing here proxies property resolution
29
+ * or dispatches by method name. Do NOT use these verbs around a dynamically
30
+ * loaded worker's entrypoint stub — those calls must stay direct property
31
+ * calls bracketed by `beginLoaderFetch` (budgets.ts records the 7/7 staging
32
+ * poisoning that rule comes from). These verbs are for Durable Object
33
+ * namespace stubs, where the thunk shape is production-proven in Proteus.
34
+ */
35
+ import { classifyDoCall, isRetryableDoCall } from '@nimbus-sh/platform/oom-classify.js';
36
+ import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
37
+ /** Total attempts. Two retries is what a dropped connection or a deploy
38
+ * bounce needs; beyond that the object is not coming back inside this
39
+ * request (the consumer's measured bound). */
40
+ const MAX_ATTEMPTS = 3;
41
+ /** Full-jitter base, in the shape the Agents SDK itself uses. */
42
+ const BASE_DELAY_MS = 60;
43
+ /**
44
+ * A failed `mutating` call, typed so the caller can act on WHAT failed:
45
+ * `classification` names the platform condition, and a transient class on a
46
+ * mutating call means the call may already have run — the indeterminacy the
47
+ * consumer's rule exists to surface rather than paper over.
48
+ */
49
+ export class DoCallError extends Error {
50
+ operation;
51
+ verb;
52
+ classification;
53
+ constructor(operation, verb, classification, cause) {
54
+ const text = cause instanceof Error ? cause.message : String(cause);
55
+ const indeterminate = isRetryableDoCall(classification)
56
+ ? ' — a dropped mutating call may already have run, so it is not retried'
57
+ : '';
58
+ super(`${verb} call '${operation}' failed [${classification}]: ${text}${indeterminate}`, { cause });
59
+ this.operation = operation;
60
+ this.verb = verb;
61
+ this.classification = classification;
62
+ this.name = 'DoCallError';
63
+ }
64
+ }
65
+ /**
66
+ * Call another Durable Object with an operation that is safe to repeat: a
67
+ * read, or a converge-to-a-value write. Transient failures retry on a fresh
68
+ * stub with full-jitter backoff; overloaded and permanent failures surface
69
+ * unchanged, as does the last error at exhaustion.
70
+ */
71
+ export async function idempotent(operation, stub, call, policy = {}) {
72
+ const maxAttempts = policy.maxAttempts ?? MAX_ATTEMPTS;
73
+ const baseDelayMs = policy.baseDelayMs ?? BASE_DELAY_MS;
74
+ for (let attempt = 1;; attempt++) {
75
+ const minted = await stub();
76
+ try {
77
+ const result = await call(minted);
78
+ disposeRpcResource(minted);
79
+ return result;
80
+ }
81
+ catch (error) {
82
+ // A stub that threw may be permanently broken; it is never reused.
83
+ disposeRpcResource(minted);
84
+ const classification = classifyDoCall(error);
85
+ if (!isRetryableDoCall(classification) || attempt >= maxAttempts)
86
+ throw error;
87
+ policy.onRetry?.({ operation, classification, attempt, maxAttempts, error });
88
+ await new Promise((resolve) => {
89
+ setTimeout(resolve, Math.floor(Math.random() * 2 ** attempt * baseDelayMs));
90
+ });
91
+ }
92
+ }
93
+ }
94
+ /**
95
+ * Call another Durable Object with an operation that appends, sends, charges
96
+ * or mints. NEVER retried — a dropped call may already have run. Failure
97
+ * surfaces as a {@link DoCallError} carrying the classification, so the
98
+ * caller can tell a refusal from an indeterminate drop.
99
+ */
100
+ export async function mutating(operation, stub, call) {
101
+ const minted = await stub();
102
+ try {
103
+ const result = await call(minted);
104
+ disposeRpcResource(minted);
105
+ return result;
106
+ }
107
+ catch (error) {
108
+ disposeRpcResource(minted);
109
+ throw new DoCallError(operation, 'mutating', classifyDoCall(error), error);
110
+ }
111
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * facet-pool.ts — leased facets, so reclaiming storage is the default and
3
+ * leaking it takes intent.
4
+ *
5
+ * Proteus's facet-spawn.ts (313 lines) exists because the platform's two
6
+ * teardown verbs are indistinguishable to a caller and only one gives
7
+ * storage back: `abort` is mid-flight eviction with storage KEPT, `delete`
8
+ * is terminal with storage WIPED. Its docstring records the cost of
9
+ * confusing them: "the leak this module previously had, in which every head
10
+ * and every MCTS branch abandoned a permanent database inside the
11
+ * orchestrator DO". The lease makes that leak unreachable: disposal retires
12
+ * the facet (evict, then wipe), and keeping storage is the explicit opt-in
13
+ * (`detach()`, today's abort).
14
+ *
15
+ * The constraints a caller must not be surprised by, from Proteus's platform
16
+ * catalog (all proven by probe or by source):
17
+ * - a facet cannot set alarms (`do.facet.no_alarms`) — a head cannot
18
+ * schedule its own resumption; everything time-driven routes through the
19
+ * root's single alarm (`timers`).
20
+ * - a facet stub is coordinator-local (`do.facet.stub_local`) — it cannot
21
+ * be transferred, stored, or re-invoked indirectly.
22
+ * - facet storage is charged to the ROOT's shared budget, and a clone that
23
+ * crosses it is an uncatchable reset, not an error (`do.storage.bytes`).
24
+ * - a parent and its facets are evicted JOINTLY after minutes idle, so
25
+ * in-memory facet state is never safe to assume between two RPCs.
26
+ * - 65,536 facet ids per DO lifetime (`do.facet.count`), append-only and
27
+ * never reclaimed — the binding constraint for the leak, reached an
28
+ * order of magnitude before the byte quota. The pool counts first-use
29
+ * names in the durable ledger (budgets.ts) and refuses a NEW name at the
30
+ * wall by name, instead of letting the platform fail opaquely. Refusal
31
+ * at the wall is exact, not a threshold: the ledger never overcounts.
32
+ *
33
+ * A failed reclaim stays loud (facet-spawn's `runOnceAndReclaim`): storage
34
+ * that was not given back is a permanent charge against the root's quota,
35
+ * and swallowing that is how the original leak stayed invisible.
36
+ *
37
+ * The pool drives the RAW `ctx.facets` container and assumes it is the only
38
+ * thing naming facets on this actor. The Agents SDK's sub-agent layer makes
39
+ * the same assumption from the other side — it owns facet naming and runs
40
+ * its own cleanup — so the two are mutually exclusive on one actor:
41
+ * whichever acts second aborts or retires facets the other still tracks,
42
+ * and the facet-id ledger here counts only the names this pool minted.
43
+ */
44
+ /** `ctx.facets`, as the pool drives it — same surface the facet host uses. */
45
+ export interface FacetPoolContainer {
46
+ get(name: string, start: () => Promise<{
47
+ class: unknown;
48
+ }>): unknown;
49
+ abort(name: string, reason?: unknown): void;
50
+ delete(name: string): void;
51
+ }
52
+ /** The hosting actor's context: its facet container, and the storage the
53
+ * facet-id ledger persists through. */
54
+ export interface FacetPoolContext {
55
+ facets?: FacetPoolContainer;
56
+ storage: {
57
+ get(key: string): Promise<unknown> | unknown;
58
+ put(key: string, value: unknown): Promise<void>;
59
+ };
60
+ }
61
+ /**
62
+ * One leased facet. Dispose (or `retire()`) evicts the instance and WIPES
63
+ * its storage; `detach()` first to keep the storage — after it, disposal
64
+ * only evicts. The stub is coordinator-local: do not store it past the turn
65
+ * or hand it to anything else.
66
+ */
67
+ export interface FacetLease<S> {
68
+ readonly name: string;
69
+ readonly stub: S;
70
+ /** Keep the facet's storage: disposal becomes eviction only. */
71
+ detach(): void;
72
+ /** Idempotent. Throws, loudly, when the platform refuses the wipe. */
73
+ retire(): Promise<void>;
74
+ [Symbol.asyncDispose](): Promise<void>;
75
+ }
76
+ /** The facet pool of one hosting actor. Cheap accessor, like `timers()`. */
77
+ export declare function facetPool(ctx: FacetPoolContext): FacetPool;
78
+ export declare class FacetPool {
79
+ private readonly ctx;
80
+ constructor(ctx: FacetPoolContext);
81
+ /**
82
+ * Open (or re-enter) the named facet under a lease. A first-use name
83
+ * consumes one of the object's 65,536 lifetime facet ids and is refused at
84
+ * the wall; a reused name costs nothing, in this incarnation or any other.
85
+ */
86
+ acquire<S = unknown>(name: string, start: () => Promise<{
87
+ class: unknown;
88
+ }>): Promise<FacetLease<S>>;
89
+ }
90
+ //# sourceMappingURL=facet-pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"facet-pool.d.ts","sourceRoot":"","sources":["../src/facet-pool.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AASH,8EAA8E;AAC9E,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,GAAG,OAAO,CAAC;IACrE,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;IAC5C,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;wCACwC;AACxC,MAAM,WAAW,gBAAgB;IAC/B,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,OAAO,EAAE;QACP,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;QAC7C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;KACjD,CAAC;CACH;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU,CAAC,CAAC;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACjB,gEAAgE;IAChE,MAAM,IAAI,IAAI,CAAC;IACf,sEAAsE;IACtE,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC;AAKD,4EAA4E;AAC5E,wBAAgB,SAAS,CAAC,GAAG,EAAE,gBAAgB,GAAG,SAAS,CAE1D;AAED,qBAAa,SAAS;IACR,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,EAAE,gBAAgB;IAElD;;;;OAIG;IACG,OAAO,CAAC,CAAC,GAAG,OAAO,EACvB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAA;KAAE,CAAC,GACvC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;CAiD1B"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * facet-pool.ts — leased facets, so reclaiming storage is the default and
3
+ * leaking it takes intent.
4
+ *
5
+ * Proteus's facet-spawn.ts (313 lines) exists because the platform's two
6
+ * teardown verbs are indistinguishable to a caller and only one gives
7
+ * storage back: `abort` is mid-flight eviction with storage KEPT, `delete`
8
+ * is terminal with storage WIPED. Its docstring records the cost of
9
+ * confusing them: "the leak this module previously had, in which every head
10
+ * and every MCTS branch abandoned a permanent database inside the
11
+ * orchestrator DO". The lease makes that leak unreachable: disposal retires
12
+ * the facet (evict, then wipe), and keeping storage is the explicit opt-in
13
+ * (`detach()`, today's abort).
14
+ *
15
+ * The constraints a caller must not be surprised by, from Proteus's platform
16
+ * catalog (all proven by probe or by source):
17
+ * - a facet cannot set alarms (`do.facet.no_alarms`) — a head cannot
18
+ * schedule its own resumption; everything time-driven routes through the
19
+ * root's single alarm (`timers`).
20
+ * - a facet stub is coordinator-local (`do.facet.stub_local`) — it cannot
21
+ * be transferred, stored, or re-invoked indirectly.
22
+ * - facet storage is charged to the ROOT's shared budget, and a clone that
23
+ * crosses it is an uncatchable reset, not an error (`do.storage.bytes`).
24
+ * - a parent and its facets are evicted JOINTLY after minutes idle, so
25
+ * in-memory facet state is never safe to assume between two RPCs.
26
+ * - 65,536 facet ids per DO lifetime (`do.facet.count`), append-only and
27
+ * never reclaimed — the binding constraint for the leak, reached an
28
+ * order of magnitude before the byte quota. The pool counts first-use
29
+ * names in the durable ledger (budgets.ts) and refuses a NEW name at the
30
+ * wall by name, instead of letting the platform fail opaquely. Refusal
31
+ * at the wall is exact, not a threshold: the ledger never overcounts.
32
+ *
33
+ * A failed reclaim stays loud (facet-spawn's `runOnceAndReclaim`): storage
34
+ * that was not given back is a permanent charge against the root's quota,
35
+ * and swallowing that is how the original leak stayed invisible.
36
+ *
37
+ * The pool drives the RAW `ctx.facets` container and assumes it is the only
38
+ * thing naming facets on this actor. The Agents SDK's sub-agent layer makes
39
+ * the same assumption from the other side — it owns facet naming and runs
40
+ * its own cleanup — so the two are mutually exclusive on one actor:
41
+ * whichever acts second aborts or retires facets the other still tracks,
42
+ * and the facet-id ledger here counts only the names this pool minted.
43
+ */
44
+ import { FACET_ID_LIFETIME_BUDGET, facetNameCount, recordFacetNameMinted, withFacetBudgetNamed, } from './budgets.js';
45
+ /** Names this ctx's pool has already charged to the lifetime ledger. */
46
+ const chargedNames = new WeakMap();
47
+ /** The facet pool of one hosting actor. Cheap accessor, like `timers()`. */
48
+ export function facetPool(ctx) {
49
+ return new FacetPool(ctx);
50
+ }
51
+ export class FacetPool {
52
+ ctx;
53
+ constructor(ctx) {
54
+ this.ctx = ctx;
55
+ }
56
+ /**
57
+ * Open (or re-enter) the named facet under a lease. A first-use name
58
+ * consumes one of the object's 65,536 lifetime facet ids and is refused at
59
+ * the wall; a reused name costs nothing, in this incarnation or any other.
60
+ */
61
+ async acquire(name, start) {
62
+ const facets = this.ctx.facets;
63
+ if (!facets || typeof facets.get !== 'function') {
64
+ throw new Error('fabric: ctx.facets is unavailable in this Durable Object; facets cannot be leased');
65
+ }
66
+ let charged = chargedNames.get(this.ctx);
67
+ if (!charged) {
68
+ charged = new Set();
69
+ chargedNames.set(this.ctx, charged);
70
+ }
71
+ if (!charged.has(name)) {
72
+ const consumed = facetNameCount(this.ctx);
73
+ if (consumed >= FACET_ID_LIFETIME_BUDGET) {
74
+ throw withFacetBudgetNamed(consumed, new Error(`facet '${name}' refused before creation: no lifetime ids remain`));
75
+ }
76
+ recordFacetNameMinted(this.ctx, consumed + 1);
77
+ charged.add(name);
78
+ }
79
+ const stub = facets.get(name, start);
80
+ let settled = false;
81
+ let keepStorage = false;
82
+ const retire = async () => {
83
+ if (settled)
84
+ return;
85
+ settled = true;
86
+ // Evict first so the wipe never lands under a live writer; a facet
87
+ // already gone makes the abort a no-op.
88
+ try {
89
+ facets.abort(name, new Error('fabric: facet lease retired'));
90
+ }
91
+ catch { /* already gone */ }
92
+ if (keepStorage)
93
+ return;
94
+ try {
95
+ facets.delete(name);
96
+ }
97
+ catch (e) {
98
+ throw new Error(`fabric: facet '${name}' was evicted but its storage was not reclaimed — `
99
+ + `it is leaked into the root Durable Object's shared quota: ${errorText(e)}`, { cause: e });
100
+ }
101
+ };
102
+ return {
103
+ name,
104
+ stub,
105
+ detach() { keepStorage = true; },
106
+ retire,
107
+ [Symbol.asyncDispose]: retire,
108
+ };
109
+ }
110
+ }
111
+ function errorText(error) {
112
+ return error instanceof Error ? error.message : String(error);
113
+ }