fix+docs: D34 — FINAL batch (F1+F4+F7+F8); audit cadence stops

cold-audit catch from 2026-05-24 (round 6 — FINAL)

This is the closing D-day of a 24-day round-1→round-6 audit cycle.
After this commit + the 9 round-6 follow-up issue filings, no more
audit rounds. Trajectory R1=17 → R2=13 → R3=13 → R4=10 → R5=12 →
R6=14 — the method did not converge; owner chose Option A (focused
batch of most consequential items, then STOP).

Changes (6 files, +138 / -47):

**Code changes**

1. lib/cache/keys.mjs (+14/-?) — F4 P2 cache key array-field
   normalization:
   - New `normalizeArrayField` helper: `(Array.isArray(v) && v.length === 0) ? null : (v ?? null)`
   - Applied to `tools` and `stop` in computeCacheKey
   - Now `tools: []` and `tools` omitted produce IDENTICAL cache
     keys (and same for `stop: []` vs omitted). ADR 0005 Amendment 2's
     own claim that "[] and undefined share a cache entry" was
     empirically FALSE pre-D34; round-6 reviewer verified hashes
     differ. The fix makes the claim literally true at the
     key-composition layer.

2. lib/providers/base.mjs (+16/-?) — F7 P2 dead error code removal:
   - `QUOTA_EXHAUSTED` removed from PROVIDER_ERROR_CODES
   - `RATE_LIMITED` removed from PROVIDER_ERROR_CODES
   - v0.1 live codes: SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING,
     SPAWN_TIMEOUT
   - Comment block documents removal + cites ADR 0004 Amendment 3

3. lib/fallback/engine.mjs (+29/-?) — F7 P2 sibling:
   - HARD_TRIGGER_CODES: QUOTA_EXHAUSTED + RATE_LIMITED removed
   - SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING(false), SPAWN_TIMEOUT
     remain
   - evaluateHardTriggers HTTP-status branches KEPT (option (b)) with
     forward-compat comment: "v0.1 plugins never attach statusCode;
     branches reserved for v1.x when plugin gains HTTP-status parsing"

4. test-features.mjs (+93) — F4 + F7 test work:
   - 4 new F4 regression tests (tools:[] vs undefined, stop:[] vs
     undefined, tools:[] vs null, tools:non-empty vs undefined sanity)
   - ~14 integration test code-swap edits (QUOTA_EXHAUSTED →
     SPAWN_FAILED, RATE_LIMITED → SPAWN_FAILED) preserving original
     hard-trigger semantic
   - 2 dead unit tests for QUOTA_EXHAUSTED/RATE_LIMITED removed
     (tombstone comment retained for audit trail)

**ADR amendments (docs-only, no code change)**

5. docs/adr/0004-fallback-engine.md (+12) — F7 Amendment 3:
   - Documents the v0.1 hard-trigger code narrowing
   - 4 live codes listed explicitly
   - Captures evaluateHardTriggers HTTP-status branch retention rationale
   - v1.x re-activation path: plugin gains HTTP-status parsing →
     re-add codes → branches activate naturally

6. docs/adr/0005-cache-cross-provider.md (+21) — TWO amendments + 1
   prior-amendment update:
   - **Amendment 6 (F1 P1)**: Formal v1.x deferral of D4 streaming
     singleflight. Buffered path (executeHopFn) uses cacheStore.getOrCompute
     and participates in D4 fully. Streaming cache-miss path
     (server.mjs:609-741) bypasses singleflight — N concurrent identical
     streamers each spawn fresh. v0.1 trade-off accepted for
     personal/family scale; v1.x design ADR needed for tee-streaming +
     per-key inflight Map. Cross-references CLAUDE.md release_kit.
     phase_rolling_mode as the deferral pattern precedent.
   - **Amendment 7 (F8 P2)**: Documents the v0.1 conservative cache-key
     posture: includes all IR fields including those plugins discard
     (anthropic/codex/mistral drop temperature/max_tokens/top_p/stop/
     tools/tool_choice at spawn). Consequence: 2 requests with different
     temperature produce identical CLI output (CLI ignores) but
     different cache keys → spurious miss. Trade-off justified:
     spurious miss > spurious hit. v1.x forward path:
     per-plugin cacheKeyFields contract extension (ADR 0002 amendment
     needed). 3 implementation subtasks enumerated for the v1.x PR.
   - **Amendment 2 update (F4)**: heading renamed to "Note on
     null-coalescing AND array normalization"; body documents the
     new normalizeArrayField helper; quotes the regression test name.

Tests: 414 → 416 (+4 F4 regression, -2 F7 dead, +0 net from F7
integration rewrites).

Pre-commit fold-in: NONE — D34 reviewer APPROVE with all 4 suggestions
non-blocking/cosmetic.

Authority:
- ADR 0005 Amendment 2 invariant restored at code level (F4)
- ADR 0005 Amendment 6 formalizes F1 streaming-singleflight deferral
  per the same pattern as D22 ADR 0004 Amendment 2 soft-trigger
  deferral
- ADR 0005 Amendment 7 documents F8 conservative posture as v0.1
  intentional design (not an accident)
- ADR 0004 Amendment 3 narrows v0.1 trigger taxonomy (F7)
- Round-6 cold audit findings F2 / F3 / F6 / F9 / F10 / F11 / F12 /
  F13 / F14 filed as GitHub issues after this commit (NOT in scope)
- CC 开发铁律 v1.6 § 10.x — final round of the audit cadence

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Critical depth checks:
- B5 over-normalization: verified `normalizeArrayField` only applies
  to `tools` and `stop`; `response_format: {}` and `tool_choice: ''`
  unaffected (Array.isArray guard)
- C10 test cleanup: 21 references reconciled (3 tombstone, 18
  rewrites/removals); integration test rewrites preserve hard-trigger
  semantics (QUOTA_EXHAUSTED → SPAWN_FAILED is also a hard trigger,
  so fallback advancement behavior unchanged)

---

**End of audit cycle.** 24 D-days shipped from D10 (P1 hardening) through
D34 (final batch). 6 cold-audit rounds executed; 78+ findings raised;
~50 closed via implementation; ~28 deferred to GitHub issues / v1.x
ADR amendments. v0.1 tag remains explicit-maintainer-action per
phase_rolling_mode policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 19:33:48 +10:00
co-authored by Claude Opus 4.7
parent f784fdb947
commit 60570ef074
6 changed files with 138 additions and 47 deletions
+12
View File
@@ -7,6 +7,18 @@
## Amendments
### Amendment 3 — 2026-05-24: Narrow v0.1 hard-trigger code taxonomy (D34 F7)
- **Finding:** Round-6 cold-audit F7 (P2) — `PROVIDER_ERROR_CODES` in `lib/providers/base.mjs` and `HARD_TRIGGER_CODES` in `lib/fallback/engine.mjs` both listed `QUOTA_EXHAUSTED` and `RATE_LIMITED` as live hard-trigger codes. No v0.1 plugin emits either code. The Anthropic, Codex, and Mistral plugins all use `claude -p`, `codex exec --json`, and `vibe --prompt` respectively — none parse the underlying-API HTTP response status code or surface a structured quota/rate error; they only throw `SPAWN_FAILED`, `SPAWN_TIMEOUT`, `CLI_NOT_FOUND`, or `AUTH_MISSING`. The two `Hard triggers` bullets in § Trigger taxonomy ("HTTP 5xx from provider's underlying API" and "HTTP 4xx quota exhaustion") are therefore unreachable through the `ProviderError` code path at v0.1.
- **Also found:** `evaluateHardTriggers` in `engine.mjs` has HTTP `statusCode` branches (`>= 500` and `>= 400`) that are also unreachable at v0.1 — plugins never attach `statusCode` to thrown errors. These branches are left in place as forward-compatible intent-signal code (option (b) per D34 discussion), with a prominent comment. Removing them would require corresponding ADR revision; keeping them preserves the design intent for when a future plugin gains HTTP-status parsing.
- **Change (D34 F7):**
- `QUOTA_EXHAUSTED` and `RATE_LIMITED` removed from `PROVIDER_ERROR_CODES` (base.mjs) and `HARD_TRIGGER_CODES` (engine.mjs). Dead code removal.
- A comment block added in `evaluateHardTriggers` labeling the HTTP-status branches as "forward-compat reserved — v0.1 plugins never attach statusCode."
- Test coverage: the two unit tests for `evaluateHardTriggers: ProviderError QUOTA_EXHAUSTED/RATE_LIMITED → fires` are removed (tombstoned with a removal comment). All other hard-trigger tests that used these codes as convenient test vectors are rewritten to use `SPAWN_FAILED` / `SPAWN_TIMEOUT`.
- **v0.1 live hard-trigger codes after this amendment:** `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`. `AUTH_MISSING` remains in the table as an explicit `false` entry (deliberate non-trigger per ADR 0004 § "No fallback for client-side errors" analogue).
- **v1.x re-activation path:** When a plugin gains HTTP-status parsing (e.g., an Anthropic plugin variant that makes direct Messages API calls rather than spawning `claude -p`), add the plugin-layer HTTP parsing, re-add `QUOTA_EXHAUSTED` and `RATE_LIMITED` to both tables, and amend this entry. The `evaluateHardTriggers` HTTP-status branches will then activate naturally with no further engine changes.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-6 Cold Audit).
### Amendment 1 — 2026-05-24: Buffered-path SPAWN_FAILED with usable chunks must NOT trigger fallback (D16)
- **Finding:** Cold-audit Finding 17 (P2 fallback correctness) — Hard triggers bullet 3 in the Decision section below reads: "Provider CLI exit code ≠ 0 **with no usable response chunks streamed**." The qualifier "with no usable response chunks streamed" is load-bearing. Pre-D16 code in `server.mjs` `collectAllChunks` had no catch block: if the provider generator threw `ProviderError(SPAWN_FAILED)` after yielding N>0 IR delta/stop chunks, the throw propagated out of the `for await` loop, discarding the accumulated chunks. The fallback engine then treated SPAWN_FAILED as a hard trigger unconditionally, advanced the chain, and served the next provider's response. The original provider's actual partial output — content the user had already paid for — was silently dropped.
+20 -1
View File
@@ -7,6 +7,25 @@
## Amendments
### Amendment 7 — 2026-05-24: Document cache-key-vs-CLI-args discrepancy as v0.1 conservative trade-off (D34 F8)
- **Finding:** Round-6 cold-audit F8 (P2) — Provider plugins (`lib/providers/anthropic.mjs`, `codex.mjs`, `mistral.mjs`) drop `temperature`, `max_tokens`, `top_p`, `stop`, `tools`, and `tool_choice` when spawning their respective CLIs. These CLIs (`claude -p`, `codex exec --json`, `vibe --prompt`) do not accept those flags. However, the cache key (per Amendment 2) includes all of them. Consequence: two requests that differ only in `temperature` produce identical CLI output (the CLI ignores it) but different cache keys → spurious miss. The caller pays the spawn cost twice for what is, at the CLI layer, the same request.
- **Decision — conservative posture at v0.1:** The key includes every IR field rather than a per-plugin subset. Rationale: a spurious miss is strictly safer than a spurious hit. A spurious hit would serve one model's response to a caller that requested a different sampling configuration — even if the delta is small (temperature 0.7 vs 0.8), it is the wrong answer dressed in the right cache key. At v0.1 with personal/family-scale load, the extra spawn cost from spurious misses is negligible; the risk from spurious hits is not worth taking even at low probability.
- **Each plugin's lossy-translation behavior is documented:** The "fields dropped at spawn" table lives in the plugin file header for each provider. Reviewers adding a new plugin must populate this table; ADR 0006 inclusion entries reference it.
- **Forward path (deferred to v1.x):** Per-plugin `cacheKeyFields` introspection: each provider plugin declares which IR fields it actually uses when constructing its CLI invocation. `computeCacheKey` would accept an optional `pluginCacheKeyMask` (a set of field names) and only include masked fields. For example, the Anthropic plugin mask would exclude `temperature`, `max_tokens`, `top_p`, `stop`, `tools`, `tool_choice` (all dropped at spawn) and include only `messages`, `model`, `response_format`. This reduces spurious-miss rate to near-zero for Anthropic. Implementing this requires: (a) extending the Provider contract (ADR 0002 amendment) with an optional `cacheKeyFields` property; (b) plumbing the mask through `buildDefaultChain``executeHopFn``computeCacheKey`; (c) a test verifying that two requests differing only in a masked field share a cache entry. Deferred because the yield at v0.1 is marginal and the contract extension adds complexity.
- **No code change:** F8 is a docs-only amendment. The conservative posture reflects existing code behavior. The amendment formalizes it as intentional v0.1 design rather than an oversight.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-6 Cold Audit).
### Amendment 6 — 2026-05-24: Formally defer streaming-path D4 singleflight to v1.x (D34 F1)
- **Finding:** Round-6 cold-audit F1 (P1) — § D4 states "Concurrent requests with identical cache keys share one spawn." The buffered path (`server.mjs` `executeHopFn` via `cacheStore.getOrCompute`) participates fully in D4 singleflight: `getOrCompute` uses a per-key inflight `Map` to deduplicate concurrent requests. The streaming cache-miss path (`server.mjs` lines 609741) bypasses `getOrCompute` and calls `streamPlugin.spawn(ir, authContext)` directly — no inflight Map participation. Consequence: N concurrent streaming requests with identical cache keys each spawn their own CLI instance at v0.1. The ADR promises behavior the streaming path does not deliver, with no deferral note.
- **v0.1 trade-off:** At personal/family-scale single-tenant load, N concurrent identical streaming requests is an edge case that has not been reported. Each spawner receives the correct response. The waste (N CLI processes instead of one) is real but acceptable for the target deployment scale. This deferral mirrors the approach taken in Amendment 2 (ADR 0004) for soft triggers: the mechanism exists and is correct on the buffered path; the extension to the streaming path requires non-trivial design work that is not justified at v0.1.
- **Buffered path is fully compliant:** `cacheStore.getOrCompute` provides both singleflight (per-key inflight promise deduplication during populate) and cache read/write. The buffered path uses it unconditionally (except for D2 cache_control bypass and D23 `hints.cacheable=false` opt-out). The streaming path is the only non-participant.
- **Forward path to v1.x — tee-streaming + per-key inflight promise:** Full D4 compliance on the streaming path requires: (a) a design ADR for the tee-streaming architecture — when the first streaming caller's generator is in progress, subsequent identical-key callers must be wired to receive the same chunks as they are emitted (a "broadcast SSE tee"), not block until the first completes; (b) a per-key inflight `Map<string, AsyncIterator>` on the streaming path; (c) clean teardown semantics when the first caller's stream ends. This is genuinely v1.x work: the design alone warrants a dedicated ADR, and the implementation touches the real-streaming branch in a way that must be carefully reviewed for race conditions. Implementing it as an ad-hoc D-day would violate the minimum-reviewable-unit principle.
- **Precedent:** Phase rolling mode (CLAUDE.md `release_kit.phase_rolling_mode`) explicitly establishes the pattern of deferring spec-vs-implementation gaps with a formal note when the gap is safe at current deployment scale. This amendment applies that precedent.
- **No code change in D34:** The deferral is docs-only. Implementing tee-streaming + per-key inflight promise is the v1.x milestone tracked by a future issue (filed as F2-sibling follow-up per D34 issue-filing batch).
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-6 Cold Audit elevated to P1 because the ADR promised behavior the code didn't deliver without a deferral note. This amendment provides the deferral note.).
### Amendment 4 — 2026-05-24: Clarify D2 cache_control detection surface (F10)
- **Finding:** Round-3 cold-audit F10 (P3 detection-surface drift) — § D2 reads as if detection happens on the IR ("If THE IR REQUEST contains Anthropic cache_control markers AND..."), but `openai-to-ir.mjs` strips `cache_control` markers from messages per ADR 0003's whitelist policy. Actual v1.0 detection happens on the raw OpenAI request body at the entry surface in `server.mjs`.
@@ -39,7 +58,7 @@
- **Change:** Expand cache key composition to include `max_tokens`, `top_p`, `stop`, and `tool_choice` in addition to the existing seven fields. The four new fields are appended after the existing set so that the ordering of existing fields is stable (pre-D15 cache entries are invalidated on first request — a forced miss — but the schema is forward-compatible). Field serialization follows the existing `?? null` null-coalescing pattern: a request with `max_tokens: undefined` serializes as `null` and is treated as equivalent to an absent field; `max_tokens: 100` serializes as `100`. This is consistent with how `temperature` and `response_format` are handled.
- **Rationale:** ADR 0005's own stated invariant — "different output → different cache entry" — requires that any IR field affecting output be included in the cache key. The original v1.0 key composition was correct for the seven named fields but provided incomplete coverage of ADR 0003's IR field set. The four omitted fields are all defined in ADR 0003 § Optional fields and are all sourced directly from the OpenAI `/v1/chat/completions` spec parameters that affect generation.
- **Forward-looking note:** Future IR field additions (e.g., `seed`, `frequency_penalty`, `presence_penalty`, `logit_bias`, `logprobs`, `n`) must be evaluated for cache-key inclusion at the time they are added to the IR. The default is to **include** the field in the cache key unless an explicit rationale documents why omitting it is safe (e.g., the field has no effect on the provider's output for any value, or it is a purely client-side metadata field). This evaluation must appear in the amending ADR per ALIGNMENT.md Rule 2(c) spirit.
- **Note on null-coalescing collisions:** The `?? null` serialization pattern treats `field: undefined`, `field: null`, and `field: []` (for array fields such as `tools`) as equivalent cache keys. This is intentional — semantically these all mean "field absent or empty." In practice, `tools: []` (explicit empty array) and `tools` omitted (undefined) both mean "no tools available" and produce identical model output, so they correctly share a cache entry. If a future provider distinguishes empty-array from absent (e.g., `tools: []` explicitly meaning "tools disabled" vs `tools` omitted meaning "tools not specified"), the serialization for that field must be revisited and this amendment will need revision.
- **Note on null-coalescing and array normalization:** The `?? null` serialization pattern treats `field: undefined` and `field: null` as equivalent. For array-typed fields (`tools`, `stop`), the normalization goes one step further: `[]` (explicit empty array) is also collapsed to `null` before serialization. This is implemented via a `normalizeArrayField` helper in `computeCacheKey` (D34 F4). In practice, `tools: []` (explicit empty array) and `tools` omitted (undefined) both mean "no tools available" and produce identical model output, so they correctly share a cache entry. The normalization step at `computeCacheKey` collapses `[]` to `null` for array-typed fields (`tools`, `stop`) so the claim holds at the key-composition layer. A regression test (`F4 regression: tools:[] and tools:undefined produce the same cache key`) verifies this. If a future provider distinguishes empty-array from absent (e.g., `tools: []` explicitly meaning "tools disabled" vs `tools` omitted meaning "tools not specified"), the normalization for that field must be revisited and this amendment will need revision.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Cold Audit caught it). This follows the calibration-loop precedent established by D11 / Amendment 1 of ADR 0002: the diff-review pass that approved the original ADR 0005 did not cross-reference all ADR 0003 optional fields; the cold-audit pass on 2026-05-23 caught the gap as Finding 7.
## Context
+12 -2
View File
@@ -235,11 +235,21 @@ export function computeCacheKey(provider, model, ir, _options = {}) {
// path in server.mjs side-channels through the raw body to compensate. Do
// NOT remove the slot — once IR carries cache_control, this key composition
// is already correct.
// F4 (ADR 0005 Amendment 2 clarification, D34): normalize array-typed fields so
// that [] (explicit empty) and undefined/null (absent) produce the same key.
// `tools: []` and `tools` omitted both mean "no tools available" — they produce
// identical model output and must collide on the same cache entry.
// `stop: []` similarly means "no stop sequences" — equivalent to absent.
// Per Amendment 2: "The normalization step at computeCacheKey collapses [] to null
// for array-typed fields (tools, stop) so the claim holds at the key-composition layer."
const normalizeArrayField = (v) =>
(Array.isArray(v) && v.length === 0) ? null : (v ?? null);
const keyObj = {
provider,
model,
messages: normalized,
tools: ir.tools ?? null,
tools: normalizeArrayField(ir.tools),
temperature: ir.temperature ?? null,
response_format: ir.response_format ?? null,
cache_control: cacheControlMarkers.length > 0 ? cacheControlMarkers : null,
@@ -247,7 +257,7 @@ export function computeCacheKey(provider, model, ir, _options = {}) {
// Appended after the existing seven fields to avoid invalidating ordering-dependent keys.
max_tokens: ir.max_tokens ?? null,
top_p: ir.top_p ?? null,
stop: ir.stop ?? null,
stop: normalizeArrayField(ir.stop),
tool_choice: ir.tool_choice ?? null,
};
+16 -7
View File
@@ -26,20 +26,22 @@ import { computeIRRequestHash } from '../cache/keys.mjs';
/**
* Maps ProviderError codes to hard-trigger decisions.
* Per ADR 0004 § Decision § Trigger taxonomy — Hard triggers:
* - QUOTA_EXHAUSTED → hard trigger
* - RATE_LIMITED → hard trigger
*
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7):
* - SPAWN_FAILED → hard trigger (provider CLI failed)
* - CLI_NOT_FOUND → hard trigger (binary missing)
* - AUTH_MISSING → NOT a hard trigger (user-config failure; user must fix)
* - OUTPUT_PARSE_ERRORremoved (D32 F4): no plugin emits it; dead code.
* Re-add via ADR 0004 amendment if needed.
* - SPAWN_TIMEOUT hard trigger (per ADR 0004 § Trigger taxonomy bullet 4)
*
* QUOTA_EXHAUSTED and RATE_LIMITED removed (D34 F7 / ADR 0004 Amendment 3):
* no v0.1 plugin parses underlying-API HTTP status codes, so these codes
* are never emitted at v0.1. Re-add via ADR 0004 amendment when a plugin
* gains HTTP-status parsing capability.
* OUTPUT_PARSE_ERROR removed earlier (D32 F4): no plugin emits it; dead code.
*
* @type {Record<string, boolean>}
*/
const HARD_TRIGGER_CODES = {
QUOTA_EXHAUSTED: true,
RATE_LIMITED: true,
SPAWN_FAILED: true,
CLI_NOT_FOUND: true,
AUTH_MISSING: false, // user config problem — never fall over (ADR 0004 § Decision)
@@ -120,6 +122,13 @@ export function evaluateHardTriggers(error, _providerHints = {}) {
}
// HTTP status code present on the error object.
// NOTE (ADR 0004 Amendment 3, D34 F7): these HTTP-status branches are
// forward-compat reserved code. v0.1 plugins never attach statusCode to
// thrown errors — they throw ProviderError with codes (SPAWN_FAILED,
// SPAWN_TIMEOUT, CLI_NOT_FOUND, AUTH_MISSING) and never surface underlying-API
// HTTP status. When a future plugin gains HTTP-status parsing, it will attach
// statusCode to the thrown error and these branches will activate. Until then,
// this path is unreachable in production. Left in place as intent signal.
const status = error.statusCode ?? error.status ?? null;
if (status !== null && typeof status === 'number') {
// Client-side errors: never fall over (ADR 0004 § No fallback for client-side errors)
+11 -5
View File
@@ -138,15 +138,21 @@ export function validateProvider(p) {
// ── Error class ───────────────────────────────────────────────────────────
/** Error codes surfaced by provider plugins */
/**
* Error codes surfaced by provider plugins.
*
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7):
* SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING, SPAWN_TIMEOUT
*
* QUOTA_EXHAUSTED and RATE_LIMITED were removed (D34 F7): no plugin parses
* underlying-API HTTP status codes at v0.1, so these codes are never emitted.
* Re-add via ADR 0004 amendment when a plugin gains HTTP-status parsing.
* (Previously removed: OUTPUT_PARSE_ERROR, D32 F4.)
*/
export const PROVIDER_ERROR_CODES = /** @type {const} */ ([
'AUTH_MISSING',
'QUOTA_EXHAUSTED',
'RATE_LIMITED',
'CLI_NOT_FOUND',
'SPAWN_FAILED',
// OUTPUT_PARSE_ERROR removed (D32 F4): no plugin emits it; dead code.
// Re-add via ADR 0004 amendment if a future plugin surfaces parse failures.
'SPAWN_TIMEOUT', // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
]);
+64 -29
View File
@@ -1500,6 +1500,47 @@ describe('Cache layer — computeCacheKey (Suite 9)', () => {
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir2);
assert.equal(k1, k2);
});
// ── F4 regression (D34): tools:[] vs tools:undefined must produce identical keys ─
// ADR 0005 Amendment 2 claims: "tools: [] (explicit empty array) and tools omitted
// (undefined) both mean 'no tools available' and produce identical model output,
// so they correctly share a cache entry."
// computeCacheKey now normalizes [] → null before serialization so this claim holds.
it('F4 regression: tools:[] and tools:undefined produce the same cache key (D34)', () => {
const base = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
const irEmpty = { ...base, tools: [] };
const irUndef = { ...base, tools: undefined };
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', irEmpty);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', irUndef);
assert.equal(k1, k2, 'tools:[] and tools:undefined must produce identical cache keys');
});
it('F4 regression: stop:[] and stop:undefined produce the same cache key (D34)', () => {
const base = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
const irEmpty = { ...base, stop: [] };
const irUndef = { ...base, stop: undefined };
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', irEmpty);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', irUndef);
assert.equal(k1, k2, 'stop:[] and stop:undefined must produce identical cache keys');
});
it('F4 regression: tools:[] and tools:null produce the same cache key (D34)', () => {
const base = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
const irEmpty = { ...base, tools: [] };
const irNull = { ...base, tools: null };
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', irEmpty);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', irNull);
assert.equal(k1, k2, 'tools:[] and tools:null must produce identical cache keys');
});
it('F4 regression: non-empty tools array still differs from tools:undefined (D34)', () => {
const base = makeIR({ messages: [{ role: 'user', content: 'hello' }] });
const irWithTools = { ...base, tools: [{ type: 'function', function: { name: 'foo' } }] };
const irNoTools = { ...base, tools: undefined };
const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', irWithTools);
const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', irNoTools);
assert.notEqual(k1, k2, 'non-empty tools array must produce a different cache key from no tools');
});
});
describe('Cache layer — extractCacheControlMarkers + hasCacheControl (Suite 9 cont.)', () => {
@@ -3866,15 +3907,9 @@ describe('Fallback engine — trigger taxonomy (D9)', () => {
assert.equal(evaluateHardTriggers(err), true);
});
it('evaluateHardTriggers: ProviderError QUOTA_EXHAUSTED → fires', () => {
const err = new ProviderError('Quota exhausted', 'QUOTA_EXHAUSTED');
assert.equal(evaluateHardTriggers(err), true);
});
it('evaluateHardTriggers: ProviderError RATE_LIMITED → fires', () => {
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
assert.equal(evaluateHardTriggers(err), true);
});
// QUOTA_EXHAUSTED and RATE_LIMITED tests removed (D34 F7): those codes were
// removed from PROVIDER_ERROR_CODES and HARD_TRIGGER_CODES — no v0.1 plugin
// emits them. Re-add tests via ADR 0004 amendment when HTTP-status parsing lands.
it('evaluateHardTriggers: ProviderError SPAWN_FAILED → fires', () => {
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
@@ -4014,7 +4049,7 @@ describe('Fallback engine — executeWithFallback (D9)', () => {
});
it('single-hop chain with hard-triggered error → exhausted, returns originalError', async () => {
const err = new ProviderError('Quota exhausted', 'QUOTA_EXHAUSTED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [{ provider: 'anthropic', model: 'claude-sonnet-4-6' }];
const result = await executeWithFallback(chain, dummyIR, makeHopFn({ anthropic: err }));
assert.equal(result.chunks, null, 'Expected null chunks on exhausted chain');
@@ -4038,7 +4073,7 @@ describe('Fallback engine — executeWithFallback (D9)', () => {
});
it('three-hop chain, primary + secondary fail → tertiary returns chunks, fallbackHops=2', async () => {
const errA = new ProviderError('Rate limited', 'RATE_LIMITED');
const errA = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errB = Object.assign(new Error('Service unavailable'), { statusCode: 503 });
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
@@ -4057,8 +4092,8 @@ describe('Fallback engine — executeWithFallback (D9)', () => {
});
it('three-hop chain, all fail → exhausted, originalError is from FIRST hop (not last)', async () => {
const errA = new ProviderError('Rate limited', 'RATE_LIMITED');
const errB = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errA = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errB = new ProviderError('Spawn timeout', 'SPAWN_TIMEOUT');
const errC = new ProviderError('CLI not found', 'CLI_NOT_FOUND');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
@@ -4158,7 +4193,7 @@ describe('Fallback engine — first-chunk safety (D9)', () => {
it('error from executeHopFn (hard trigger) means zero chunks emitted — advance is safe', async () => {
// Simulate: anthropic throws (no bytes emitted), openai succeeds
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -4273,7 +4308,7 @@ describe('Fallback engine — observability / header annotation (D9)', () => {
});
it('success on fallback: providerUsed + modelUsed match the serving hop', async () => {
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -4289,9 +4324,9 @@ describe('Fallback engine — observability / header annotation (D9)', () => {
});
it('chain exhausted: originalError is from FIRST hop, not second or third', async () => {
const errA = new ProviderError('Rate limited', 'RATE_LIMITED');
const errB = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errC = new ProviderError('CLI not found', 'CLI_NOT_FOUND');
const errA = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const errB = new ProviderError('CLI not found', 'CLI_NOT_FOUND');
const errC = new ProviderError('Spawn timeout', 'SPAWN_TIMEOUT');
const chain = [
{ provider: 'a', model: 'model-a' },
{ provider: 'b', model: 'model-b' },
@@ -4310,7 +4345,7 @@ describe('Fallback engine — observability / header annotation (D9)', () => {
});
it('triedProviders lists all attempted hops in chain order', async () => {
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'a', model: 'model-a' },
{ provider: 'b', model: 'model-b' },
@@ -4980,7 +5015,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('chain_id is present in all events and is 16-char hex', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -4999,7 +5034,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('chain_id is consistent across all hops from one executeWithFallback call', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'a', model: 'model-a' },
{ provider: 'b', model: 'model-b' },
@@ -5034,7 +5069,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('ir_request_hash is the same across all hops within one chain', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -5060,9 +5095,9 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
// ── trigger_type: classification per error type ───────────────────────────
it('trigger_type is "hard" for QUOTA_EXHAUSTED (fallback_hard_trigger event)', async () => {
it('trigger_type is "hard" for SPAWN_FAILED (fallback_hard_trigger event)', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Quota exhausted', 'QUOTA_EXHAUSTED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -5142,7 +5177,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('next_provider is chain[i+1].provider when chain advances on hard trigger', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
{ provider: 'openai', model: 'gpt-5.5' },
@@ -5159,7 +5194,7 @@ describe('Fallback engine — D28 observability fields (chain_id / ir_request_ha
it('next_provider is null on the last hop (chain exhausted)', async () => {
const { logEvent, events } = makeLogCapture();
const err = new ProviderError('Rate limited', 'RATE_LIMITED');
const err = new ProviderError('Spawn failed', 'SPAWN_FAILED');
const chain = [
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
];
@@ -7486,7 +7521,7 @@ describe('D33 round-5 cold-audit cleanup', () => {
describe('F8_fallback — fallback-hop cache-hit reports X-OLP-Cache: hit', () => {
it('F8_fallback: 2-hop chain, primary fails, secondary cache-hit → X-OLP-Cache: hit', async () => {
// Two providers: primary (alpha) always fails with QUOTA_EXHAUSTED; secondary
// Two providers: primary (alpha) always fails with SPAWN_FAILED (hard trigger); secondary
// (beta) succeeds. On second request, secondary serves from cache.
// The X-OLP-Cache header must be 'hit' on the second request.
//
@@ -7497,12 +7532,12 @@ describe('D33 round-5 cold-audit cleanup', () => {
setProviders33({ anthropic: true, mistral: true });
clearCache33();
// Override anthropic spawn to always throw QUOTA_EXHAUSTED
// Override anthropic spawn to always throw SPAWN_FAILED (hard trigger)
const savedAnthropic = loadedProviders33.get('anthropic');
const failingPrimary = {
...savedAnthropic,
spawn: async function* () {
throw new ProviderError('quota exhausted (test)', 'QUOTA_EXHAUSTED');
throw new ProviderError('spawn failed (test)', 'SPAWN_FAILED');
},
};
loadedProviders33.set('anthropic', failingPrimary);