mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
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:
+12
-5
@@ -40,6 +40,7 @@ const HARD_TRIGGER_CODES = {
|
||||
CLI_NOT_FOUND: true,
|
||||
AUTH_MISSING: false, // user config problem — never fall over (ADR 0004 § Decision)
|
||||
OUTPUT_PARSE_ERROR: true,
|
||||
SPAWN_TIMEOUT: true, // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -444,17 +445,20 @@ function defaultConfigPath() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads fallback chain configuration from ~/.olp/config.json (or the given path).
|
||||
* Loads the full OLP config from ~/.olp/config.json (or the given path).
|
||||
*
|
||||
* Per D9 implementation notes:
|
||||
* v0.1 ships with no config file. Fallback engine is wired but single-hop
|
||||
* until the user populates routing.chains.
|
||||
*
|
||||
* Returns empty config (no chains, no soft triggers) if the file is absent,
|
||||
* unreadable, or malformed.
|
||||
* Per ADR 0002 § Disable model: providers.enabled is the toggle that transitions
|
||||
* a Candidate provider to Enabled. Missing entries default to false (disabled).
|
||||
*
|
||||
* Returns empty config (no chains, no soft triggers, no enabled providers) if the
|
||||
* file is absent, unreadable, or malformed.
|
||||
*
|
||||
* @param {string} [configPath] — override path (for testing — do NOT write to ~/.olp/config.json in tests)
|
||||
* @returns {{ chains: object, soft_triggers: object }}
|
||||
* @returns {{ chains: object, soft_triggers: object, providersEnabled: Record<string, boolean> }}
|
||||
*/
|
||||
export function loadFallbackConfigSync(configPath) {
|
||||
try {
|
||||
@@ -462,12 +466,15 @@ export function loadFallbackConfigSync(configPath) {
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const routing = parsed?.routing ?? {};
|
||||
const providers = parsed?.providers ?? {};
|
||||
return {
|
||||
chains: routing.chains ?? {},
|
||||
soft_triggers: routing.soft_triggers ?? {},
|
||||
providersEnabled: providers.enabled ?? {},
|
||||
};
|
||||
} catch {
|
||||
// File absent, unreadable, or malformed → no fallback config (single-hop mode)
|
||||
return { chains: {}, soft_triggers: {} };
|
||||
// Empty providersEnabled → all providers disabled → 503 per ALIGNMENT.md v0.1 posture.
|
||||
return { chains: {}, soft_triggers: {}, providersEnabled: {} };
|
||||
}
|
||||
}
|
||||
|
||||
+63
-20
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ export const PROVIDER_ERROR_CODES = /** @type {const} */ ([
|
||||
'CLI_NOT_FOUND',
|
||||
'SPAWN_FAILED',
|
||||
'OUTPUT_PARSE_ERROR',
|
||||
'SPAWN_TIMEOUT', // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
]);
|
||||
|
||||
export class ProviderError extends Error {
|
||||
|
||||
+76
-37
@@ -132,7 +132,7 @@ import { 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_CODEX_BIN env takes priority, then falls back to 'codex' from PATH.
|
||||
@@ -382,12 +382,15 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
let accumulatedStderr = '';
|
||||
let stopEmitted = false;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -429,59 +432,92 @@ 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 Codex outputs.
|
||||
const maxSpawnTimeMs = codex.hints?.maxSpawnTimeMs ?? 600_000;
|
||||
|
||||
let spawnTimedOut = false;
|
||||
const spawnDeadlineTimer = setTimeout(() => {
|
||||
spawnTimedOut = true;
|
||||
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
if (rejectNext) {
|
||||
const r = rejectNext;
|
||||
rejectNext = null;
|
||||
resolveNext = null;
|
||||
r(new ProviderError(
|
||||
`codex spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
));
|
||||
}
|
||||
}, maxSpawnTimeMs);
|
||||
|
||||
// Drain the chunk buffer
|
||||
let isFirstChunk = true;
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
await new Promise((resolve) => { resolveNext = resolve; });
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`codex spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'ndjson_line') {
|
||||
const irChunk = codexChunkToIR(item.line);
|
||||
if (irChunk === null) continue; // ignored event type (progress, etc.)
|
||||
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
stopEmitted = true;
|
||||
yield irChunk;
|
||||
try {
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
if (spawnTimedOut) {
|
||||
throw new ProviderError(
|
||||
`codex spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
);
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
rejectNext = reject;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (irChunk.type === 'delta') {
|
||||
// Add role to first delta chunk (matching anthropic.mjs pattern)
|
||||
if (isFirstChunk) {
|
||||
yield { type: 'delta', role: 'assistant', content: irChunk.content };
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`codex spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'ndjson_line') {
|
||||
const irChunk = codexChunkToIR(item.line);
|
||||
if (irChunk === null) continue; // ignored event type (progress, etc.)
|
||||
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
stopEmitted = true;
|
||||
yield irChunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (irChunk.type === 'delta') {
|
||||
// Add role to first delta chunk (matching anthropic.mjs pattern)
|
||||
if (isFirstChunk) {
|
||||
yield { type: 'delta', role: 'assistant', content: irChunk.content };
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
yield irChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(spawnDeadlineTimer);
|
||||
}
|
||||
|
||||
// Process close
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode !== 0 && !spawnTimedOut) {
|
||||
const errMsg = accumulatedStderr.slice(0, 300) || `codex exit ${exitCode}`;
|
||||
throw new ProviderError(errMsg, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
// Normal exit: emit stop chunk if the NDJSON stream didn't already emit one.
|
||||
if (!stopEmitted) {
|
||||
if (!spawnTimedOut && !stopEmitted) {
|
||||
yield { type: 'stop', finish_reason: 'stop' };
|
||||
}
|
||||
}
|
||||
@@ -603,6 +639,9 @@ const codex = {
|
||||
requiresTTY: false, // codex exec runs headless (per CLI reference § exec)
|
||||
concurrentSpawnSafe: true, // each invocation is independent
|
||||
maxConcurrent: 4, // conservative default, same as anthropic
|
||||
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
|
||||
// 600_000ms = 10 minutes. Tests can lower this by mutating codex.hints.maxSpawnTimeMs.
|
||||
maxSpawnTimeMs: 600_000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+77
-39
@@ -230,7 +230,7 @@ import { 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_VIBE_BIN env takes priority, then falls back to 'vibe' from PATH.
|
||||
@@ -505,6 +505,8 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
let accumulatedStderr = '';
|
||||
let stopEmitted = false;
|
||||
let resolveNext = null;
|
||||
// timeout: tracks a pending rejectNext for spawn timeout
|
||||
let rejectNext = null;
|
||||
let isFirstChunk = true;
|
||||
|
||||
function push(item) {
|
||||
@@ -512,13 +514,13 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
if (resolveNext) {
|
||||
const r = resolveNext;
|
||||
resolveNext = null;
|
||||
rejectNext = null;
|
||||
r();
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer stdout by line for JSON parsing.
|
||||
// Assumption A7: '--output json' emits one JSON object per line (NDJSON).
|
||||
// D-later: if '--output json' emits a single JSON blob, switch to '--output streaming'.
|
||||
// Assumption A7: '--output streaming' emits one JSON object per line (NDJSON).
|
||||
let stdoutBuf = '';
|
||||
proc.stdout.on('data', (d) => {
|
||||
stdoutBuf += d.toString();
|
||||
@@ -553,58 +555,91 @@ 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 Vibe outputs.
|
||||
const maxSpawnTimeMs = mistral.hints?.maxSpawnTimeMs ?? 600_000;
|
||||
|
||||
let spawnTimedOut = false;
|
||||
const spawnDeadlineTimer = setTimeout(() => {
|
||||
spawnTimedOut = true;
|
||||
try { proc.kill('SIGTERM'); } catch { /* ignore */ }
|
||||
if (rejectNext) {
|
||||
const r = rejectNext;
|
||||
rejectNext = null;
|
||||
resolveNext = null;
|
||||
r(new ProviderError(
|
||||
`vibe spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
));
|
||||
}
|
||||
}, maxSpawnTimeMs);
|
||||
|
||||
// Drain the chunk buffer
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
await new Promise((resolve) => { resolveNext = resolve; });
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`vibe spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'json_line') {
|
||||
const irChunk = mistralChunkToIR(item.line);
|
||||
if (irChunk === null) continue; // ignored event type
|
||||
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
stopEmitted = true;
|
||||
yield irChunk;
|
||||
try {
|
||||
while (true) {
|
||||
if (chunks.length === 0) {
|
||||
if (done) break;
|
||||
if (spawnTimedOut) {
|
||||
throw new ProviderError(
|
||||
`vibe spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
'SPAWN_TIMEOUT',
|
||||
);
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
rejectNext = reject;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (irChunk.type === 'delta') {
|
||||
// Add role to first delta chunk (matching anthropic.mjs / codex.mjs pattern)
|
||||
if (isFirstChunk) {
|
||||
yield { type: 'delta', role: 'assistant', content: irChunk.content };
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
const item = chunks.shift();
|
||||
|
||||
if (item.type === 'error') {
|
||||
throw new ProviderError(`vibe spawn error: ${item.err.message}`, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (item.type === 'close') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (item.type === 'json_line') {
|
||||
const irChunk = mistralChunkToIR(item.line);
|
||||
if (irChunk === null) continue; // ignored event type
|
||||
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(irChunk.error, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
if (irChunk.type === 'stop') {
|
||||
stopEmitted = true;
|
||||
yield irChunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (irChunk.type === 'delta') {
|
||||
// Add role to first delta chunk (matching anthropic.mjs / codex.mjs pattern)
|
||||
if (isFirstChunk) {
|
||||
yield { type: 'delta', role: 'assistant', content: irChunk.content };
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
yield irChunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(spawnDeadlineTimer);
|
||||
}
|
||||
|
||||
// Process close
|
||||
if (exitCode !== 0) {
|
||||
if (exitCode !== 0 && !spawnTimedOut) {
|
||||
const errMsg = accumulatedStderr.slice(0, 300) || `vibe exit ${exitCode}`;
|
||||
throw new ProviderError(errMsg, 'SPAWN_FAILED');
|
||||
}
|
||||
|
||||
// Normal exit: emit stop chunk if the JSON stream didn't already emit one.
|
||||
if (!stopEmitted) {
|
||||
if (!spawnTimedOut && !stopEmitted) {
|
||||
yield { type: 'stop', finish_reason: 'stop' };
|
||||
}
|
||||
}
|
||||
@@ -743,6 +778,9 @@ const mistral = {
|
||||
requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode
|
||||
concurrentSpawnSafe: true, // each invocation is independent
|
||||
maxConcurrent: 4, // conservative default matching anthropic + codex
|
||||
// ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger.
|
||||
// 600_000ms = 10 minutes. Tests can lower this by mutating mistral.hints.maxSpawnTimeMs.
|
||||
maxSpawnTimeMs: 600_000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user