feat(phase-1): D10 P1 hardening — providers.enabled wiring + real streaming + spawn timeout

Folds in three production-blocking defects from external Codex review
round-3 of Phase 1 (D5+D6+D8+D9 already merged). Without these, OLP
returns 503 on every request even when `npm start` succeeds, streams
arrive as a single buffered burst after the spawn completes, and hung
CLIs block the engine forever despite ADR 0004 declaring spawn-timeout
a hard trigger.

P1.1 providers.enabled config wiring (ADR 0002 § Disable model):
- loadFallbackConfigSync() returns tri-field {chains, soft_triggers,
  providersEnabled}; server.mjs reads _startupConfig.providersEnabled
  and passes to loadProviders() at startup
- Empty / missing config → 0 enabled providers → 503 no_enabled_provider
  (matches v0.1 0-Enabled posture per ALIGNMENT.md § Provider Inventory)
- __setProvidersEnabled / __resetProvidersEnabled test seams; in-place
  Map mutation preserves existing direct-mutation patterns in Suite 13

P1.2 Real SSE streaming on single-hop cache-miss (ADR 0003 entry adapter
pattern, OpenAI /v1/chat/completions stream=true contract):
- New handleChatCompletions branch when ir.stream === true && chain.length
  === 1 && !bypassCache && !preCheckHit
- for await (const irChunk of provider.spawn(...)) writes SSE per chunk
  via res.write(irChunkToOpenAISSE(...)); accumulates streamedChunks for
  cacheStore.set on stop
- First-chunk rule preserved: error-before-first-chunk → sendError(502);
  error-after-first-chunk → truncated res.end(), no fallback
- Multi-hop chains (chain.length > 1) continue to use buffered
  executeWithFallback to keep fallback safety semantics

P1.3 Spawn timeout hard trigger (ADR 0004 § Trigger taxonomy bullet 4):
- SPAWN_TIMEOUT added to PROVIDER_ERROR_CODES (lib/providers/base.mjs)
  and HARD_TRIGGER_CODES (lib/fallback/engine.mjs)
- All three plugins (anthropic.mjs / codex.mjs / mistral.mjs) wrap drain
  loop with setTimeout (default 600_000ms, configurable via
  hints.maxSpawnTimeMs); on fire: proc.kill('SIGTERM') + reject pending
  drain promise with ProviderError(..., 'SPAWN_TIMEOUT')
- Timer cleared in finally; resolveNext/rejectNext atomically nulled in
  push() + timer-fire path to prevent late-fire double-settle

Tests 277 → 288 (+11). Suite 14 (4 providers.enabled), Suite 15
(3 streaming cache-miss real-time, including arrival-count >= 2
assertion that architecturally proves real streaming), Suite 16
(4 spawn timeout, including 2-hop chain advancement from timed-out
primary). 288/288 pass on Node 20.20.2 + Node 25.8.0.

Authorities:
- ADR 0002 § Disable model — config toggle, not plugin-removal
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0002-plugin-architecture.md
- ADR 0003 § Translation direction model — entry adapter for await pattern
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0003-intermediate-representation.md
- ADR 0004 § Trigger taxonomy + § Fallback safety (first-chunk rule)
  https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- OpenAI /v1/chat/completions stream=true (server-sent events,
  data: {chunk} per delta, terminator data: [DONE])
  https://platform.openai.com/docs/api-reference/chat/streaming

Reviewer (Iron Rule 10): fresh-context opus, independent of drafter.
Verdict: APPROVE_WITH_MINOR. Folded the one cheap minor before commit
(Suite 15a arrival-count assertion strengthened from chunks.length > 0
to arrivalTimestamps.length >= 2 — the prior assertion would have
admitted a buffered impl). Two remaining non-blocking notes deferred:
optional writeHead deferral (low value; single-hop guard makes pre-
content 200 + empty body safe), and version bump (Phase 1 ships as
v0.1.0 aggregate when D11–D16 land).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 06:57:11 +10:00
co-authored by Claude Opus 4.7
parent 95f6dbd1e2
commit 2cfd0b194e
8 changed files with 970 additions and 106 deletions
+63 -20
View File
@@ -50,7 +50,7 @@ import { execFileSync, execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { ProviderError } from './base.mjs';
import { ProviderError, PROVIDER_ERROR_CODES } from './base.mjs';
// ── Binary resolution ─────────────────────────────────────────────────────
// OLP_CLAUDE_BIN env takes priority, then falls back to 'claude' from PATH.
@@ -256,12 +256,15 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
let exitCode = null;
let exitSignal = null;
let resolveNext = null;
// timeout: tracks a pending rejectNext for spawn timeout
let rejectNext = null;
function push(item) {
chunks.push(item);
if (resolveNext) {
const r = resolveNext;
resolveNext = null;
rejectNext = null;
r();
}
}
@@ -286,40 +289,77 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
push({ type: 'close' });
});
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
// maxSpawnTimeMs: from hints field so tests can lower it without changing the plugin.
// Default: 600 000 ms (10 minutes) — reasonable for long Claude outputs.
const maxSpawnTimeMs = anthropic.hints?.maxSpawnTimeMs ?? 600_000;
// Overall wall-clock deadline — fires if the process hasn't closed in time.
let spawnTimedOut = false;
const spawnDeadlineTimer = setTimeout(() => {
spawnTimedOut = true;
// Kill the process and wake up the drain loop via a rejection.
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
if (rejectNext) {
const r = rejectNext;
rejectNext = null;
resolveNext = null;
r(new ProviderError(
`claude spawn timed out after ${maxSpawnTimeMs}ms`,
'SPAWN_TIMEOUT',
));
}
}, maxSpawnTimeMs);
// Drain the chunk buffer
while (true) {
if (chunks.length === 0) {
if (done) break;
// Wait for the next event
await new Promise((resolve) => { resolveNext = resolve; });
continue;
}
try {
while (true) {
if (chunks.length === 0) {
if (done) break;
if (spawnTimedOut) {
throw new ProviderError(
`claude spawn timed out after ${maxSpawnTimeMs}ms`,
'SPAWN_TIMEOUT',
);
}
// Wait for the next event (or timeout rejection)
await new Promise((resolve, reject) => {
resolveNext = resolve;
rejectNext = reject;
});
continue;
}
const item = chunks.shift();
const item = chunks.shift();
if (item.type === 'error') {
throw new ProviderError(`claude spawn error: ${item.err.message}`, 'SPAWN_FAILED');
}
if (item.type === 'error') {
throw new ProviderError(`claude spawn error: ${item.err.message}`, 'SPAWN_FAILED');
}
if (item.type === 'close') {
break;
}
if (item.type === 'close') {
break;
}
if (item.type === 'data') {
yield anthropicChunkToIR(item.text, isFirstChunk);
isFirstChunk = false;
if (item.type === 'data') {
yield anthropicChunkToIR(item.text, isFirstChunk);
isFirstChunk = false;
}
}
} finally {
clearTimeout(spawnDeadlineTimer);
}
// Process close
if (exitCode !== 0) {
if (exitCode !== 0 && !spawnTimedOut) {
const errMsg = accumulatedStderr.slice(0, 300) || `claude exit ${exitCode}`;
// Map known exit patterns to IR error chunk, then throw
throw new ProviderError(errMsg, 'SPAWN_FAILED');
}
// Normal exit: emit stop chunk
yield anthropicStopToIR('stop');
if (!spawnTimedOut) {
yield anthropicStopToIR('stop');
}
}
// ── spawn (public, contract method) ──────────────────────────────────────
@@ -447,6 +487,9 @@ const anthropic = {
requiresTTY: false,
concurrentSpawnSafe: true,
maxConcurrent: 4,
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
// 600_000ms = 10 minutes. Tests can lower this by mutating anthropic.hints.maxSpawnTimeMs.
maxSpawnTimeMs: 600_000,
},
};