@nimbus-sh/platform 0.1.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -0
  3. package/dist/diag-counters.d.ts +210 -0
  4. package/dist/diag-counters.d.ts.map +1 -0
  5. package/dist/diag-counters.js +170 -0
  6. package/dist/heap-estimate.d.ts +225 -0
  7. package/dist/heap-estimate.d.ts.map +1 -0
  8. package/dist/heap-estimate.js +185 -0
  9. package/dist/heavy-alloc-coord.d.ts +93 -0
  10. package/dist/heavy-alloc-coord.d.ts.map +1 -0
  11. package/dist/heavy-alloc-coord.js +181 -0
  12. package/dist/index.d.ts +19 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +18 -0
  15. package/dist/install-phase.d.ts +47 -0
  16. package/dist/install-phase.d.ts.map +1 -0
  17. package/dist/install-phase.js +1 -0
  18. package/dist/limits.d.ts +83 -0
  19. package/dist/limits.d.ts.map +1 -0
  20. package/dist/limits.js +146 -0
  21. package/dist/oom-classify.d.ts +172 -0
  22. package/dist/oom-classify.d.ts.map +1 -0
  23. package/dist/oom-classify.js +336 -0
  24. package/dist/oom-discriminator.d.ts +176 -0
  25. package/dist/oom-discriminator.d.ts.map +1 -0
  26. package/dist/oom-discriminator.js +272 -0
  27. package/dist/rpc-dispose.d.ts +4 -0
  28. package/dist/rpc-dispose.d.ts.map +1 -0
  29. package/dist/rpc-dispose.js +30 -0
  30. package/dist/w7-frame.d.ts +107 -0
  31. package/dist/w7-frame.d.ts.map +1 -0
  32. package/dist/w7-frame.js +842 -0
  33. package/dist/weighted-credit-pool.d.ts +61 -0
  34. package/dist/weighted-credit-pool.d.ts.map +1 -0
  35. package/dist/weighted-credit-pool.js +196 -0
  36. package/package.json +57 -0
  37. package/src/diag-counters.ts +310 -0
  38. package/src/heap-estimate.ts +331 -0
  39. package/src/heavy-alloc-coord.ts +241 -0
  40. package/src/index.ts +19 -0
  41. package/src/install-phase.ts +56 -0
  42. package/src/limits.ts +165 -0
  43. package/src/oom-classify.ts +350 -0
  44. package/src/oom-discriminator.ts +405 -0
  45. package/src/rpc-dispose.ts +31 -0
  46. package/src/w7-frame.ts +1046 -0
  47. package/src/weighted-credit-pool.ts +244 -0
package/dist/limits.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * limits.ts — the measured Cloudflare platform limits Nimbus is built
3
+ * against.
4
+ *
5
+ * Every constant here is a fact about the platform — a hard ceiling, or a
6
+ * measured envelope proven safe against one — rather than a Nimbus policy
7
+ * choice. Policy constants (versions, defaults, feature bounds) live in
8
+ * `@nimbus-sh/core/constants.js`; this table is what they are derived from.
9
+ */
10
+ // ── Storage-transaction bounds ──────────────────────────────────────────
11
+ //
12
+ // The platform resets a Durable Object over what ONE TURN has outstanding
13
+ // in storage ("Internal error in Durable Object storage caused object to
14
+ // be reset"), not over what it eventually writes. These bounds keep any
15
+ // one transaction below that wall.
16
+ /**
17
+ * The storage/wire chunk quantum: bytes per content chunk in the SQLite
18
+ * VFS schema, and therefore the unit every transaction bound and streamed
19
+ * write frame is denominated in. SQLite's per-ROW value cap is 2 MB (key
20
+ * length included, measured single-value ceiling 2,199,981 B), so 64 KiB
21
+ * keeps a chunk row far under it while amortizing per-row overhead.
22
+ */
23
+ export const CHUNK_SIZE = 65_536;
24
+ export const MAX_TX_BLOB_BYTES = 1 * 1024 * 1024;
25
+ export const MAX_TX_LOGICAL_ROWS = 256;
26
+ export const MAX_TX_SQL_EXECS = 64;
27
+ // ── SQLite storage walls ────────────────────────────────────────────────
28
+ /**
29
+ * Storage per SQLite-backed Durable Object: 10 decimal GB documented
30
+ * (Workers Paid), shared by the root object, every facet beneath it, and
31
+ * every clone — a copy-on-write clone consumes its FULL logical bytes with
32
+ * no CoW credit. Probed window: 10.58e9 logical bytes fit, 11.6e9 failed —
33
+ * 10 GiB (10,737,418,240) falls INSIDE that window and is not a number to
34
+ * design to. At the wall, ordinary writes fail catchably as SQLITE_FULL
35
+ * ('database or disk is full') while reads and DELETEs keep working, so the
36
+ * recovery is to drain; a facet CLONE over the wall is an uncatchable reset
37
+ * that empties the destination, so clone admission is decided BEFORE the
38
+ * clone, with reserve.
39
+ */
40
+ export const DO_STORAGE_LIMIT_BYTES = 10_000_000_000;
41
+ /**
42
+ * Maximum SQL statement text per exec. Documented "100 KB"; read as binary
43
+ * KiB because the value is SQLite's compile-time SQLITE_MAX_SQL_LENGTH
44
+ * rather than a billing quantity (unverified reading — reached by generated
45
+ * statements, a batched multi-VALUES insert being the realistic breach).
46
+ */
47
+ export const SQLITE_MAX_STATEMENT_BYTES = 100 * 1024;
48
+ /**
49
+ * Maximum bound parameters per query. The easiest cap to hit accidentally:
50
+ * a batched insert of more than 100/columns rows in one statement breaches
51
+ * it.
52
+ */
53
+ export const SQLITE_MAX_BOUND_PARAMETERS = 100;
54
+ /**
55
+ * Maximum bytes of one string, BLOB, or table ROW — the bound is per ROW,
56
+ * key length included, not per value. The measured single-value ceiling is
57
+ * 2,199,981 bytes, ABOVE this constant: budgeting each value against the
58
+ * row bound is conservative and correct, budgeting a row against the value
59
+ * ceiling is not. Writes over it fail; reads and deletes keep working.
60
+ */
61
+ export const SQLITE_MAX_ROW_BYTES = 2_000_000;
62
+ // ── Init-gate hazard ────────────────────────────────────────────────────
63
+ /**
64
+ * A `blockConcurrencyWhile()` callback still pending this long is
65
+ * cancelled and the Durable Object is RESET — every event queued behind
66
+ * the gate dies with it. Proven by probe (reset observed at 31 s against a
67
+ * 31 s-busy neighbour; cold activations track a busy neighbour 1:1), and
68
+ * partyserver runs `onStart()` inside the gate, so ordinary traffic can
69
+ * reach this. Anything on that path must be bounded well below this;
70
+ * fabric defers async reconciliation off the gate entirely (`onColdStart`).
71
+ */
72
+ export const BLOCK_CONCURRENCY_CANCEL_MS = 30_000;
73
+ // ── WebSocket attachment bound ──────────────────────────────────────────
74
+ /**
75
+ * Bytes one hibernatable WebSocket attachment may serialize to. Verified in
76
+ * workerd source at the pinned version (v1.20260603.1,
77
+ * src/workerd/api/web-socket.h: `MAX_ATTACHMENT_SIZE = 1024 * 16`). The
78
+ * bound is on the SERIALIZED bytes — workerd re-serializes on every
79
+ * `serializeAttachment` call to check it — not on UTF-8 JSON text, so a
80
+ * JSON-length measurement is an approximation and only the platform's own
81
+ * refusal ("A WebSocket 'attachment' cannot be larger than 16384 bytes.")
82
+ * is the ceiling.
83
+ */
84
+ export const WS_ATTACHMENT_LIMIT_BYTES = 16_384;
85
+ // ── RPC payload envelope ────────────────────────────────────────────────
86
+ // Largest byte payload Nimbus sends through an ordinary Workers RPC value.
87
+ // The platform limit is 32 MiB; 28 MiB leaves room for structured-clone
88
+ // metadata and matches the proven on-demand facet transfer envelope.
89
+ export const MAX_RPC_SAFE_PAYLOAD_BYTES = 28 * 1024 * 1024;
90
+ // ── Supervisor heap budget [C'.1] ───────────────────────────────────────
91
+ //
92
+ // Three distinct memory regimes, not one shared pool:
93
+ //
94
+ // - Supervisor DO isolate: 128 MiB HARD platform ceiling ("Each isolate
95
+ // can consume up to 128 MB of memory" —
96
+ // https://developers.cloudflare.com/workers/platform/limits/).
97
+ // - Facets: ~208-256 MiB EACH, measured — memory independent of the
98
+ // supervisor and of each other (1,664 MiB live across 8 facets +
99
+ // parent) but CPU SHARED across the actor thread. See
100
+ // packages/fabric/src/process-fabric.ts.
101
+ // - Peer DOs: independent memory AND CPU budgets, measured 1.2 GiB
102
+ // live across 8 peers.
103
+ //
104
+ // Nimbus targets HALF of the supervisor's 128 MiB as a self-imposed soft
105
+ // ceiling so the supervisor always has runway when workerd LRU-evicts
106
+ // neighbours or AIR (Asynchronous Isolate Recreation) folds growing
107
+ // isolates.
108
+ //
109
+ // 64 MiB is a soft admission budget, not a measurement — the right value
110
+ // is unmeasured — and nothing enforces it directly.
111
+ // heap-estimate.ts sums the INSTRUMENTED contributors —
112
+ // the supervisor baseline, VFS LRU and in-flight writes, pre-bundle
113
+ // slices, streaming RPC buffers, and the prefetch-bundle build lease and
114
+ // cache gauge — which is a lower bound, not full coverage; allocation
115
+ // sites known to be missing are listed in HEAP_BLIND_SPOTS. Read a low
116
+ // percentOfCeiling accordingly.
117
+ export const SUPERVISOR_HEAP_CEILING_BYTES = 64 * 1024 * 1024;
118
+ // Shared allowance for transient allocations in the supervisor DO. With the
119
+ // VFS LRU shrunk to 8 MiB during an active reservation, 40 MiB of admitted
120
+ // payload plus the 9 MiB bundle baseline stays below the 64 MiB soft ceiling
121
+ // with 7 MiB left for metadata, structured-clone overhead, and runtime state.
122
+ // This is 31.25% of the platform's 128 MiB hard isolate ceiling.
123
+ export const SUPERVISOR_IN_FLIGHT_ALLOCATION_BUDGET_BYTES = 40 * 1024 * 1024;
124
+ // Reserved slice of that allowance for chunk-sized filesystem reads, so a
125
+ // read is never queued behind a multi-megabyte owner for a wait that has
126
+ // nothing to do with its own cost. A read only draws on this when the shared
127
+ // budget is already contended — which is exactly when the VFS LRU is shrunk
128
+ // to 8 MiB — so peak accounting is 40 + 1 admitted, 8 LRU, 9 bundle baseline:
129
+ // 58 MiB, still under the 64 MiB soft ceiling.
130
+ //
131
+ // 1 MiB is 16 concurrent READ_STREAM_CHUNK_BYTES reads. The point is not
132
+ // depth, it is that a read loop keeps moving while a heavy owner works
133
+ // instead of stopping dead for the duration.
134
+ export const SUPERVISOR_READ_RESERVE_BYTES = 1024 * 1024;
135
+ // Global bound on supervisor-resident write-stream credit, shared by every
136
+ // concurrent streamed bulk write into one DO. Part of the same supervisor
137
+ // budget arithmetic as the allocation budget above.
138
+ export const MAX_GLOBAL_WRITE_STREAM_CREDIT_BYTES = 8 * 1024 * 1024;
139
+ // ── Pre-bundle admission envelope ───────────────────────────────────────
140
+ //
141
+ // The proven-safe envelope for shipping module slices through a shared DO
142
+ // isolate: one slice at the RPC envelope at a time. Measured the hard way —
143
+ // concurrency=2 of max slices crashed Mossaic-scale installs on shared DO
144
+ // isolates. The heap estimator's pre-bundle lane models exactly this bound.
145
+ export const PRE_BUNDLE_SLICE_CAP_BYTES = MAX_RPC_SAFE_PAYLOAD_BYTES;
146
+ export const PRE_BUNDLE_CONCURRENCY = 1;
@@ -0,0 +1,172 @@
1
+ /**
2
+ * oom-classify.ts — discriminator for OOM-like errors at Nimbus
3
+ * boundaries.
4
+ *
5
+ * Why this exists
6
+ * ───────────────
7
+ * Nimbus has at least five distinct "the work failed" failure modes
8
+ * that all surface today as either a thrown JS Error or a console
9
+ * line:
10
+ *
11
+ * 1. SQLITE_NOMEM at the storage layer (per-DO SQLite cap, post-
12
+ * STOR/SPEC: Address SQLITE_NOMEM issues).
13
+ * 2. Isolate memory exhaustion → 'oom'.
14
+ * 3. Isolate CPU-budget exhaustion → 'cpu_exceeded'.
15
+ * 4. Structured-clone refusal (`Cannot deserialize cloned data`)
16
+ * between supervisor ↔ facet RPC (32 MiB cap).
17
+ * 5. RPC timeout (TimeoutError from facet-pool's per-task race).
18
+ *
19
+ * Plus a few platform-side terminations (subrequest cap, condemnation,
20
+ * hard eviction) that the user sees but Nimbus has no first-party
21
+ * signal for.
22
+ *
23
+ * Memory and CPU are separate buckets
24
+ * ───────────────────────────────────
25
+ * workerd models them as distinct trace outcomes — `exceededMemory` vs
26
+ * `exceededCpu` (EventOutcome, workerd/io/outcome.capnp) — and words the
27
+ * two message families so neither one's signature appears in the other:
28
+ *
29
+ * memory "Worker has exceeded memory limit."
30
+ * "Worker exceeded memory limit."
31
+ * "broken.exceededMemory; jsg.Error: Durable Object's isolate
32
+ * exceeded its memory limit …"
33
+ * "Memory limit exceeded" (RangeError)
34
+ * cpu "Worker exceeded CPU time limit."
35
+ * "Durable Object exceeded its CPU time limit and was reset."
36
+ * "Python Worker exceeded CPU time limit"
37
+ *
38
+ * Read a low 'cpu_exceeded' count carefully: neither condition reliably
39
+ * produces a message. Both are uncatchable inside the isolate that died,
40
+ * so the string is only observable by a CALLER across an RPC boundary,
41
+ * and Nimbus has repeatedly measured isolates vanishing with no throw at
42
+ * all. Absence of 'cpu_exceeded' entries is therefore NOT evidence that
43
+ * CPU was not the cause — confirm against `wrangler tail` either way.
44
+ *
45
+ * Without a classifier, every recordFailure() call has to stringify-
46
+ * match its own error site. With this module, we pin the regex once
47
+ * and reuse it everywhere.
48
+ *
49
+ * Inputs we accept
50
+ * ────────────────
51
+ * The classifier accepts:
52
+ * - Error instances (read e.message)
53
+ * - plain strings (use directly — for stderr-line classification at
54
+ * facet-exit boundaries where we don't have an Error)
55
+ * - anything else (return 'unknown')
56
+ *
57
+ * Output values
58
+ * ─────────────
59
+ * Same set used by `DiagFailure.cause` in oom-discriminator.ts. Keep
60
+ * the union narrow and additive — adding a new value is fine, but
61
+ * never re-purpose an existing one.
62
+ */
63
+ declare const OOM_CAUSES: readonly ["sqlite_nomem", "sqlite_full", "oom", "cpu_exceeded", "clone_refused", "rpc_timeout", "subrequest_cap", "dynamic_worker_cap", "condemnation", "hard_evict", "unknown"];
64
+ export type OomCause = typeof OOM_CAUSES[number];
65
+ export declare function isOomCause(input: unknown): input is OomCause;
66
+ /**
67
+ * Classify an error or message string into an OomCause. Returns
68
+ * 'unknown' when no signature matches — callers should still record
69
+ * the message via DiagFailure.message so a human can later widen the
70
+ * classifier rules.
71
+ */
72
+ export declare function classifyError(input: unknown): OomCause;
73
+ /**
74
+ * Variant for callers that already have the message string. Prefer
75
+ * classifyError() at boundaries; this is exposed for cases where the
76
+ * message has already been extracted (e.g. truncated / sanitised).
77
+ */
78
+ export declare function classifyMessage(msg: string): OomCause;
79
+ /**
80
+ * Transient Durable Object reset — the object was reset by the platform
81
+ * mid-request for a reason unrelated to the request's own resource use:
82
+ * a code deploy rolling over, or a storage-subsystem cold-start hiccup.
83
+ * The in-flight request/RPC rejects, but the work itself never ran to a
84
+ * conclusion and is safe to re-attempt.
85
+ *
86
+ * Deliberately narrow: it must NOT match resource kills ("isolate exceeded
87
+ * its memory limit and was reset", "Durable Object exceeded its CPU time
88
+ * limit and was reset"), which classifyMessage() routes to 'oom' and
89
+ * 'cpu_exceeded' — those recur on retry and must surface, not loop. Note
90
+ * both end in "and was reset"; the checks below key on the CAUSE clause,
91
+ * never on the reset itself.
92
+ * Observed verbatim signatures:
93
+ * - "Durable Object reset because its code was updated."
94
+ * - "Internal error while starting up Durable Object storage caused
95
+ * object to be reset; reference = ..."
96
+ * - "Internal error in Durable Object storage caused object to be reset;
97
+ * reference = ..."
98
+ * - "Durable Object storage operation exceeded timeout which caused
99
+ * the object to be reset."
100
+ *
101
+ * The second and third are the SAME condition worded for two moments —
102
+ * startup versus a live write — and only the first was matched here, which is
103
+ * why an object reset mid-write still failed a whole install. Nimbus has
104
+ * measured the live-write wording twice: a 45.7 MB single-turn write reset the
105
+ * object once and the same write then succeeded 12/12 on retry
106
+ * (`vfs/facet-resident-store.ts`), and it reset a session DO mid-npm-install
107
+ * on 2026-08-10 (probe `agentic-cli/new/pi-official-installer`). Retrying is
108
+ * bounded, so the one case that is NOT transient — crossing the object's
109
+ * storage budget, which the platform also reports this way
110
+ * (`loaders/process-fabric.ts`) — recurs, exhausts the budget and surfaces.
111
+ */
112
+ export declare function isTransientDoReset(input: unknown): boolean;
113
+ /**
114
+ * Workerd shed the call because the target Durable Object's input-gate
115
+ * queue was too deep or too old: "Durable Object is overloaded."
116
+ *
117
+ * Distinct from `isTransientDoReset` on purpose. The object is alive and
118
+ * the work never started, so the call is safe to re-attempt — but the
119
+ * cause is queue pressure, so the only useful retry is one that backs off
120
+ * long enough for the queue to drain. Callers pair this with a longer
121
+ * backoff than a reset retry uses.
122
+ */
123
+ export declare function isDoOverloaded(input: unknown): boolean;
124
+ /**
125
+ * How one failed Durable Object call relates to a retry. The taxonomy
126
+ * Proteus hand-wrote in `cf-backend/src/lib/do-rpc.ts:71-79` because the
127
+ * Agents SDK does not export its own, plus the `overloaded` class both
128
+ * consumers need: Cloudflare documents that errors carry `.retryable` and
129
+ * `.overloaded`, that a retryable error should be retried with backoff on a
130
+ * FRESH stub, and that an overloaded one must not be retried at all
131
+ * (developers.cloudflare.com/durable-objects/best-practices/error-handling).
132
+ */
133
+ export type DoCallClass =
134
+ /** A deploy replaced the isolate mid-call. */
135
+ 'superseded_isolate'
136
+ /** The stub or storage connection dropped. */
137
+ | 'connection_lost'
138
+ /** Storage reset the object — cold start, live write, or a timeout. */
139
+ | 'storage_reset'
140
+ /** The runtime flagged the error `retryable` itself. */
141
+ | 'retryable_flag'
142
+ /** The object shed the call under queue pressure. NEVER retried: retrying
143
+ * an overloaded object is what overloaded it. */
144
+ | 'overloaded'
145
+ /** Everything else — the call ran and the answer is the error. */
146
+ | 'permanent';
147
+ /** The classes a fresh-stub retry may act on. `overloaded` is deliberately
148
+ * outside: the platform's own docs forbid retrying it. */
149
+ export declare function isRetryableDoCall(input: DoCallClass): boolean;
150
+ /**
151
+ * Classify one failed Durable Object call. Overloaded is checked first, per
152
+ * link, because it vetoes everything: an overloaded-and-retryable error is
153
+ * overloaded. The `.retryable` flag is also read per link — it is a
154
+ * property, not prose, so the rendered chain cannot carry it. The walk is
155
+ * cycle-guarded.
156
+ */
157
+ export declare function classifyDoCall(input: unknown): DoCallClass;
158
+ /**
159
+ * One line about a failure, in terms someone can act on.
160
+ *
161
+ * npm install's catch sites each wrote `e?.remoteMessage || e?.message ||
162
+ * String(e)`. That reduces a failure to a bare sentence, and when the platform
163
+ * declines to describe a rejected Durable Object call the sentence is the word
164
+ * `internal error` — which is how `resolver-fanout layer 2 failed: internal
165
+ * error` reached users for months carrying nothing at all. The message is
166
+ * often the least we know: the error's class, whether the description came
167
+ * from a remote isolate, and which condition it classifies as are all still in
168
+ * hand, and naming them costs nothing and invents nothing.
169
+ */
170
+ export declare function describeError(input: unknown): string;
171
+ export {};
172
+ //# sourceMappingURL=oom-classify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oom-classify.d.ts","sourceRoot":"","sources":["../src/oom-classify.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AAEH,QAAA,MAAM,UAAU,kLAYN,CAAC;AAEX,MAAM,MAAM,QAAQ,GAAG,OAAO,UAAU,CAAC,MAAM,CAAC,CAAC;AAEjD,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,QAAQ,CAE5D;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,QAAQ,CAItD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,CAkErD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAO1D;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEtD;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,WAAW;AACrB,8CAA8C;AAC5C,oBAAoB;AACtB,8CAA8C;GAC5C,iBAAiB;AACnB,uEAAuE;GACrE,eAAe;AACjB,wDAAwD;GACtD,gBAAgB;AAClB;kDACkD;GAChD,YAAY;AACd,kEAAkE;GAChE,WAAW,CAAC;AAEhB;2DAC2D;AAC3D,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,WAAW,GAAG,OAAO,CAK7D;AAmBD;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,WAAW,CAsB1D;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAgBpD"}
@@ -0,0 +1,336 @@
1
+ /**
2
+ * oom-classify.ts — discriminator for OOM-like errors at Nimbus
3
+ * boundaries.
4
+ *
5
+ * Why this exists
6
+ * ───────────────
7
+ * Nimbus has at least five distinct "the work failed" failure modes
8
+ * that all surface today as either a thrown JS Error or a console
9
+ * line:
10
+ *
11
+ * 1. SQLITE_NOMEM at the storage layer (per-DO SQLite cap, post-
12
+ * STOR/SPEC: Address SQLITE_NOMEM issues).
13
+ * 2. Isolate memory exhaustion → 'oom'.
14
+ * 3. Isolate CPU-budget exhaustion → 'cpu_exceeded'.
15
+ * 4. Structured-clone refusal (`Cannot deserialize cloned data`)
16
+ * between supervisor ↔ facet RPC (32 MiB cap).
17
+ * 5. RPC timeout (TimeoutError from facet-pool's per-task race).
18
+ *
19
+ * Plus a few platform-side terminations (subrequest cap, condemnation,
20
+ * hard eviction) that the user sees but Nimbus has no first-party
21
+ * signal for.
22
+ *
23
+ * Memory and CPU are separate buckets
24
+ * ───────────────────────────────────
25
+ * workerd models them as distinct trace outcomes — `exceededMemory` vs
26
+ * `exceededCpu` (EventOutcome, workerd/io/outcome.capnp) — and words the
27
+ * two message families so neither one's signature appears in the other:
28
+ *
29
+ * memory "Worker has exceeded memory limit."
30
+ * "Worker exceeded memory limit."
31
+ * "broken.exceededMemory; jsg.Error: Durable Object's isolate
32
+ * exceeded its memory limit …"
33
+ * "Memory limit exceeded" (RangeError)
34
+ * cpu "Worker exceeded CPU time limit."
35
+ * "Durable Object exceeded its CPU time limit and was reset."
36
+ * "Python Worker exceeded CPU time limit"
37
+ *
38
+ * Read a low 'cpu_exceeded' count carefully: neither condition reliably
39
+ * produces a message. Both are uncatchable inside the isolate that died,
40
+ * so the string is only observable by a CALLER across an RPC boundary,
41
+ * and Nimbus has repeatedly measured isolates vanishing with no throw at
42
+ * all. Absence of 'cpu_exceeded' entries is therefore NOT evidence that
43
+ * CPU was not the cause — confirm against `wrangler tail` either way.
44
+ *
45
+ * Without a classifier, every recordFailure() call has to stringify-
46
+ * match its own error site. With this module, we pin the regex once
47
+ * and reuse it everywhere.
48
+ *
49
+ * Inputs we accept
50
+ * ────────────────
51
+ * The classifier accepts:
52
+ * - Error instances (read e.message)
53
+ * - plain strings (use directly — for stderr-line classification at
54
+ * facet-exit boundaries where we don't have an Error)
55
+ * - anything else (return 'unknown')
56
+ *
57
+ * Output values
58
+ * ─────────────
59
+ * Same set used by `DiagFailure.cause` in oom-discriminator.ts. Keep
60
+ * the union narrow and additive — adding a new value is fine, but
61
+ * never re-purpose an existing one.
62
+ */
63
+ const OOM_CAUSES = [
64
+ 'sqlite_nomem',
65
+ 'sqlite_full',
66
+ 'oom',
67
+ 'cpu_exceeded',
68
+ 'clone_refused',
69
+ 'rpc_timeout',
70
+ 'subrequest_cap',
71
+ 'dynamic_worker_cap',
72
+ 'condemnation',
73
+ 'hard_evict',
74
+ 'unknown',
75
+ ];
76
+ export function isOomCause(input) {
77
+ return OOM_CAUSES.some((cause) => cause === input);
78
+ }
79
+ /**
80
+ * Classify an error or message string into an OomCause. Returns
81
+ * 'unknown' when no signature matches — callers should still record
82
+ * the message via DiagFailure.message so a human can later widen the
83
+ * classifier rules.
84
+ */
85
+ export function classifyError(input) {
86
+ const msg = readMessage(input);
87
+ if (msg.length === 0)
88
+ return 'unknown';
89
+ return classifyMessage(msg);
90
+ }
91
+ /**
92
+ * Variant for callers that already have the message string. Prefer
93
+ * classifyError() at boundaries; this is exposed for cases where the
94
+ * message has already been extracted (e.g. truncated / sanitised).
95
+ */
96
+ export function classifyMessage(msg) {
97
+ // Use lower-case match for forgiveness; SQLITE_NOMEM is canonically
98
+ // upper but stderr can be either.
99
+ const m = msg.toLowerCase();
100
+ // SQLITE_FULL: the 10 GB storage wall (platform catalog do.storage.bytes,
101
+ // DO_STORAGE_LIMIT_BYTES). A DIFFERENT condition from NOMEM with the
102
+ // opposite remedy: reads and DELETEs keep working, so the recovery is to
103
+ // drain data — where NOMEM asks for a smaller transaction. agent-core maps
104
+ // this to the same generic code as a malformed statement, which is the
105
+ // defect this split exists to avoid.
106
+ if (m.includes('sqlite_full'))
107
+ return 'sqlite_full';
108
+ if (m.includes('database or disk is full'))
109
+ return 'sqlite_full';
110
+ // SQLITE_NOMEM signals
111
+ if (m.includes('sqlite_nomem'))
112
+ return 'sqlite_nomem';
113
+ if (m.includes('out of memory'))
114
+ return 'sqlite_nomem';
115
+ // Structured-clone refusal — a 32 MiB-cap cousin
116
+ if (m.includes('deserialize cloned data'))
117
+ return 'clone_refused';
118
+ if (m.includes('could not be cloned'))
119
+ return 'clone_refused';
120
+ // RPC timeout (Nimbus's TimeoutError from facet-pool)
121
+ if (m.includes('timeouterror'))
122
+ return 'rpc_timeout';
123
+ if (/\btimed?\s*out\b/.test(m))
124
+ return 'rpc_timeout';
125
+ // Subrequest cap (Cloudflare platform)
126
+ if (m.includes('too many subrequests'))
127
+ return 'subrequest_cap';
128
+ // Worker Loader concurrency cap (Cloudflare platform): a Durable Object
129
+ // admits ~5-6 concurrent dynamic workers, and loader-cache entries are
130
+ // never released — every distinct loader.get(id) permanently consumes a
131
+ // slot, so this cap recurs until the DO itself is replaced.
132
+ if (m.includes('too many concurrent dynamic workers'))
133
+ return 'dynamic_worker_cap';
134
+ // Memory exhaustion. workerd trace outcome `exceededMemory`; in-band it
135
+ // arrives as a `broken.exceededMemory` actor break, a "…exceeded [its]
136
+ // memory limit" kill, or the "Memory limit exceeded" RangeError.
137
+ if (m.includes('exceededmemory'))
138
+ return 'oom';
139
+ if (/exceeded (?:its )?memory limit/.test(m))
140
+ return 'oom';
141
+ if (m.includes('memory limit exceeded'))
142
+ return 'oom';
143
+ if (m.includes('memory limit') && m.includes('reset'))
144
+ return 'oom';
145
+ // CPU-budget exhaustion. A DIFFERENT platform condition from memory —
146
+ // workerd reports it as trace outcome `exceededCpu`. Never fold the two:
147
+ // a CPU kill recurs on the same input, a memory kill recurs on the same
148
+ // working-set size, and the remedies are opposites.
149
+ //
150
+ // `cpu time limit` covers both scopes, which differ only by the
151
+ // possessive: "Worker exceeded CPU time limit." and "Durable Object
152
+ // exceeded its CPU time limit and was reset."
153
+ //
154
+ // Wall-clock handler timeouts are deliberately NOT matched here.
155
+ // "Actor exceeded event execution time and was disconnected." is the
156
+ // caller-configurable setHibernatableWebSocketEventTimeout() bound and
157
+ // "Alarm exceeded its allowed execution time" is its alarm twin. Both
158
+ // are elapsed-time bounds a handler blocked on I/O trips without burning
159
+ // CPU, so bucketing them here would repeat the merge this split undoes.
160
+ if (m.includes('cpu time limit'))
161
+ return 'cpu_exceeded';
162
+ if (m.includes('exceeded cpu limit'))
163
+ return 'cpu_exceeded';
164
+ // Explicit eviction (per CF research §A.1.2)
165
+ if (m.includes('condemned'))
166
+ return 'condemnation';
167
+ if (m.includes('hard evict'))
168
+ return 'hard_evict';
169
+ return 'unknown';
170
+ }
171
+ /**
172
+ * Transient Durable Object reset — the object was reset by the platform
173
+ * mid-request for a reason unrelated to the request's own resource use:
174
+ * a code deploy rolling over, or a storage-subsystem cold-start hiccup.
175
+ * The in-flight request/RPC rejects, but the work itself never ran to a
176
+ * conclusion and is safe to re-attempt.
177
+ *
178
+ * Deliberately narrow: it must NOT match resource kills ("isolate exceeded
179
+ * its memory limit and was reset", "Durable Object exceeded its CPU time
180
+ * limit and was reset"), which classifyMessage() routes to 'oom' and
181
+ * 'cpu_exceeded' — those recur on retry and must surface, not loop. Note
182
+ * both end in "and was reset"; the checks below key on the CAUSE clause,
183
+ * never on the reset itself.
184
+ * Observed verbatim signatures:
185
+ * - "Durable Object reset because its code was updated."
186
+ * - "Internal error while starting up Durable Object storage caused
187
+ * object to be reset; reference = ..."
188
+ * - "Internal error in Durable Object storage caused object to be reset;
189
+ * reference = ..."
190
+ * - "Durable Object storage operation exceeded timeout which caused
191
+ * the object to be reset."
192
+ *
193
+ * The second and third are the SAME condition worded for two moments —
194
+ * startup versus a live write — and only the first was matched here, which is
195
+ * why an object reset mid-write still failed a whole install. Nimbus has
196
+ * measured the live-write wording twice: a 45.7 MB single-turn write reset the
197
+ * object once and the same write then succeeded 12/12 on retry
198
+ * (`vfs/facet-resident-store.ts`), and it reset a session DO mid-npm-install
199
+ * on 2026-08-10 (probe `agentic-cli/new/pi-official-installer`). Retrying is
200
+ * bounded, so the one case that is NOT transient — crossing the object's
201
+ * storage budget, which the platform also reports this way
202
+ * (`loaders/process-fabric.ts`) — recurs, exhausts the budget and surfaces.
203
+ */
204
+ export function isTransientDoReset(input) {
205
+ const m = readMessage(input).toLowerCase();
206
+ if (m.length === 0)
207
+ return false;
208
+ if (m.includes('reset because its code was updated'))
209
+ return true;
210
+ if (m.includes('durable object storage caused object to be reset'))
211
+ return true;
212
+ if (m.includes('storage operation') && m.includes('reset'))
213
+ return true;
214
+ return false;
215
+ }
216
+ /**
217
+ * Workerd shed the call because the target Durable Object's input-gate
218
+ * queue was too deep or too old: "Durable Object is overloaded."
219
+ *
220
+ * Distinct from `isTransientDoReset` on purpose. The object is alive and
221
+ * the work never started, so the call is safe to re-attempt — but the
222
+ * cause is queue pressure, so the only useful retry is one that backs off
223
+ * long enough for the queue to drain. Callers pair this with a longer
224
+ * backoff than a reset retry uses.
225
+ */
226
+ export function isDoOverloaded(input) {
227
+ return readMessage(input).toLowerCase().includes('durable object is overloaded');
228
+ }
229
+ /** The classes a fresh-stub retry may act on. `overloaded` is deliberately
230
+ * outside: the platform's own docs forbid retrying it. */
231
+ export function isRetryableDoCall(input) {
232
+ return input === 'superseded_isolate'
233
+ || input === 'connection_lost'
234
+ || input === 'storage_reset'
235
+ || input === 'retryable_flag';
236
+ }
237
+ /**
238
+ * Prose signatures of the transient classes, matched against the rendered
239
+ * cause chain — a wrapper error (Proteus measured its SqlError doing this)
240
+ * drops the `.retryable` property, so only the joined prose survives it.
241
+ *
242
+ * `storage_reset` carries the live-write wording on purpose. Proteus's
243
+ * mirror of the SDK matcher excludes it because a storage-budget breach is
244
+ * reported the same way; Nimbus measured the reset-mid-write case succeed
245
+ * 12/12 on retry ({@link isTransientDoReset}), and a bounded retry lets the
246
+ * budget-wall case recur and surface.
247
+ */
248
+ const DO_CALL_TRANSIENT = [
249
+ ['superseded_isolate', /reset because its code was updated|this script has been upgraded/i],
250
+ ['connection_lost', /network connection lost/i],
251
+ ['storage_reset', /durable object storage caused object to be reset|storage operation exceeded timeout which caused the object to be reset/i],
252
+ ];
253
+ /**
254
+ * Classify one failed Durable Object call. Overloaded is checked first, per
255
+ * link, because it vetoes everything: an overloaded-and-retryable error is
256
+ * overloaded. The `.retryable` flag is also read per link — it is a
257
+ * property, not prose, so the rendered chain cannot carry it. The walk is
258
+ * cycle-guarded.
259
+ */
260
+ export function classifyDoCall(input) {
261
+ if (!(input instanceof Error))
262
+ return 'permanent';
263
+ const seen = new Set();
264
+ const messages = [];
265
+ let flagged = false;
266
+ let link = input;
267
+ while (link !== null && !seen.has(link)) {
268
+ seen.add(link);
269
+ const overloaded = ('overloaded' in link && link.overloaded === true)
270
+ || isDoOverloaded(link.message);
271
+ if (overloaded)
272
+ return 'overloaded';
273
+ if ('retryable' in link && link.retryable === true)
274
+ flagged = true;
275
+ messages.push(link.message ?? '');
276
+ const cause = link.cause;
277
+ link = cause instanceof Error ? cause : null;
278
+ }
279
+ const chain = messages.join('; ');
280
+ for (const [transient, pattern] of DO_CALL_TRANSIENT) {
281
+ if (pattern.test(chain))
282
+ return transient;
283
+ }
284
+ if (flagged)
285
+ return 'retryable_flag';
286
+ return 'permanent';
287
+ }
288
+ /**
289
+ * One line about a failure, in terms someone can act on.
290
+ *
291
+ * npm install's catch sites each wrote `e?.remoteMessage || e?.message ||
292
+ * String(e)`. That reduces a failure to a bare sentence, and when the platform
293
+ * declines to describe a rejected Durable Object call the sentence is the word
294
+ * `internal error` — which is how `resolver-fanout layer 2 failed: internal
295
+ * error` reached users for months carrying nothing at all. The message is
296
+ * often the least we know: the error's class, whether the description came
297
+ * from a remote isolate, and which condition it classifies as are all still in
298
+ * hand, and naming them costs nothing and invents nothing.
299
+ */
300
+ export function describeError(input) {
301
+ const remote = typeof input === 'object' && input !== null
302
+ ? input.remoteMessage
303
+ : undefined;
304
+ const message = (typeof remote === 'string' && remote.length > 0)
305
+ ? `${remote} (remote)`
306
+ : readMessage(input) || String(input);
307
+ const name = input instanceof Error && input.name && input.name !== 'Error'
308
+ ? `${input.name}: `
309
+ : '';
310
+ const cause = classifyError(input);
311
+ const suffix = cause !== 'unknown' ? ` [${cause}]`
312
+ : isTransientDoReset(input) ? ' [transient-do-reset]'
313
+ : isDoOverloaded(input) ? ' [do-overloaded]'
314
+ : '';
315
+ return `${name}${message}${suffix}`;
316
+ }
317
+ function readMessage(input) {
318
+ if (input == null)
319
+ return '';
320
+ if (typeof input === 'string')
321
+ return input;
322
+ if (input instanceof Error)
323
+ return input.message ?? '';
324
+ // Some thrown values are plain objects with .message
325
+ if (typeof input === 'object') {
326
+ const m = input.message;
327
+ if (typeof m === 'string')
328
+ return m;
329
+ }
330
+ try {
331
+ return String(input);
332
+ }
333
+ catch {
334
+ return '';
335
+ }
336
+ }