mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
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:
+64
-29
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user