Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions devlog/_plan/260731_macos_rss_retention/060_impl_roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ the user for this unit's commits.
| `070_harness_warm_fix.md` | wp2 | Apply 050's harness diff (monotonic pause clamp, child-exit/duration split) + post-patch remote smoke calibration | — |
| `080_inspection_bounds.md` | wp3 | relay.ts inspector caps + clear points + disconnect cancel (both consumers) + parse-once + observability counters on `/api/system/memory` | — |
| `090_eager_failed_tail.md` | wp4 | relay-eager.ts synthetic `response.failed` tail on mid-stream reset | 080 (dispose hook touches the eager producer finally) |
| `100_darwin_eager_optin.md` | wp5 | core.ts gate: darwin joins win32 for explicit `eager-relay` no-rewrite traffic; `auto` stays tee; local darwin abort-stress gate | 090 (tail must exist before opt-in is reachable) |
| `100_darwin_eager_optin.md` | wp5 | core.ts gate: darwin joins win32 for explicit `eager-relay`, including inline client rewrites; `auto` stays tee; local darwin abort-stress gate | 090 (tail must exist before opt-in is reachable) |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
| `110_verify_and_push.md` | wp6 | full-suite/typecheck/privacy gates, devlog closeout, push | 070–100 |

Ordering rationale: 070 is independent and unblocks any future macmini re-run.
Expand Down Expand Up @@ -82,19 +82,20 @@ tail loses reset semantics for opted-in clients). 110 is the terminal gate.
queue. Suppress when `sawTerminal()` is true. Do not feed the tail through
the inspector.

## Design decisions locked for 100 (from 052 risk table)
## Design decisions locked for 100 (from 052 risk table; rewrite scope superseded by 100)

- Gate change only: `(win32 || darwin) && !needsClientRewrite`, and darwin
additionally requires the EXPLICIT `streamMode: "eager-relay"` decision
(`reason: "config-eager"`). `auto` on darwin stays tee while
`MIN_FIXED_BUN_VERSION` is null. No rewrite-path widening.
- Windows keeps its existing routing policy. Darwin requires the EXPLICIT
`streamMode: "eager-relay"` decision (`reason: "config-eager"`) for both
no-rewrite and client-rewrite traffic; rewrites run inline after the eager
single reader. `auto` on darwin stays tee while `MIN_FIXED_BUN_VERSION` is
null. See `100_darwin_eager_optin.md` for the superseding gate matrix.
- Bun#32111 posture per 052: eager avoids the known reproducer shape but is
unproven on 1.3.14 — opt-in only, never default.

## Out of scope for this unit's implementation phases

- Allocator residual (053): documented NOOP until a stable Bun ships the
allocator train. No forced GC, no FFI purge, no restart-as-fix.
- Rewrite-traffic eager migration (052 steps 4–6): future unit.
- Default eager migration beyond the explicit Darwin opt-in: future unit.
- `auto` default flip: blocked on a released Bun carrying #32120 (UNSAFE
boundary per goalplan).
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,9 @@ gate execution record is authoritative.

## Not changed

- Rewrite traffic (image-gen aliases / item-id repair) stays on tee on both
platforms (`needsClientRewrite` guard intact).
- Darwin `auto` rewrite traffic stays on tee. An explicit
`streamMode: "eager-relay"` may compose a client-facing rewrite inline on
the single reader; raw inspection still receives the original bytes.
- `decideEagerRelay` itself unchanged — the darwin restriction lives at the
call site because it is platform policy, not runtime capability.
- Linux unchanged (no opt-in until asked; smallest honest scope).
Expand All @@ -159,7 +160,7 @@ extraction is preferred for clarity, not necessity. Test matrix:
2. win32 + no-rewrite + auto/known-bad → tee (unchanged).
3. darwin + no-rewrite + config-eager → eager (NEW).
4. darwin + no-rewrite + auto (even with minFixed satisfied) → tee.
5. darwin + rewrite + config-eager → tee.
5. darwin + rewrite + config-eager → eager single-reader inline rewrite.
6. linux + anything → tee.

Plus one DIRECT `handleResponses` integration test gated to darwin only
Expand Down Expand Up @@ -251,3 +252,21 @@ Confirmed by the same round: C1-1 ack chain sound, C1-2 stall detection and
all-or-nothing unreachable classification internally sound, C1-3 single
normalized selector contract with darwin-auto never eager and win32
unchanged.

## 2026-08-02 client-rewrite amendment

An opt-in Responses snapshot repair exposed a contradiction in the original
wp5 scope: keeping Darwin rewrite traffic on tee would reintroduce the second
reader and its retention cost precisely when a provider needs client-facing
compatibility repair. Explicit Darwin eager mode now permits inline payload
rewrites after raw inspection; Darwin `auto` and every Linux mode remain
unchanged.

The handler-level regression test uses `streamMode: "eager-relay"` together
with an active snapshot rewrite and asserts the eager-path marker plus the
repaired terminal. Relay tests separately prove fragmented SSE framing,
client-only failed-tail behavior, upstream abort/cancel on rewrite failure,
and raw-terminal accounting. The 2026-08-01 abort-stress record above did not
exercise payload rewriting, so it remains evidence for the shared
single-reader transport rather than a separate rewrite-stress claim; the
feature remains explicit opt-in with `legacy-tee` as rollback.
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ description: プロバイダー エントリ、認証、エンドポイント、
| `noPenaltyModels?` | `string[]` |存在/周波数ペナルティを拒否するモデル。 |
| `parallelToolCalls?` | `boolean` |並列ツール呼び出しを切り替えます。 OpenAI Chat はデフォルトでオンになっています。非チャット アダプターは明示的な `true` でのみアドバタイズします。 |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` |正確なプレースホルダー ID および欠落している端末 ID に対するダウンストリーム SSE 修復はデフォルトで無効になっています。関数呼び出し ID は決して書き換えられません。 |
| `responsesSnapshotRepair?` | `boolean` | デフォルトで無効のクライアント向け修復です。SSE と JSON の Responses ライフサイクルで欠落した status、output、ツールメタデータを補完し、raw 検査と永続化は変更しません。 |
| `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` が `auto` または `none` のみを受け入れるモデル。強制的な選択は格下げされます。 |
| `preserveReasoningContentModels?` | `string[]` |チャット履歴に以前のアシスタント `reasoning_content` が必要なモデル。 |
| `thinkingToggleModels?` | `string[]` |エフォート ラダーではなく `thinking.enabled` を使用してモデルをチャットします。 |
Expand Down Expand Up @@ -186,7 +187,8 @@ Anthropic アカウント ポリシーのリスクを理解していない限り
"reasoning": ["rs_0"],
"message": ["msg_0"],
"repairMissingTerminalIds": true
}
},
"responsesSnapshotRepair": true
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할
| `noPenaltyModels?` | `string[]` | presence/frequency penalty를 허용하지 않는 모델입니다. |
| `parallelToolCalls?` | `boolean` | 병렬 도구 호출을 켜거나 끕니다. OpenAI Chat은 기본으로 켜져 있고, 비-chat 어댑터는 명시적으로 `true`일 때만 이를 노출합니다. |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 기본값이 꺼진 downstream SSE 복구입니다. 정확한 자리표시자 id와 누락된 종료 id를 복구합니다. function-call id는 다시 쓰지 않습니다. |
| `responsesSnapshotRepair?` | `boolean` | 기본값이 꺼진 클라이언트용 복구입니다. SSE와 JSON의 Responses 수명 주기에서 누락된 status, output, 도구 메타데이터를 채우며 raw 검사와 영속화는 변경하지 않습니다. |
| `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice`가 `auto` 또는 `none`만 받는 모델입니다. 강제 선택은 낮은 수준으로 바뀝니다. |
| `preserveReasoningContentModels?` | `string[]` | chat 기록에서 이전 assistant `reasoning_content`가 필요한 모델입니다. |
| `thinkingToggleModels?` | `string[]` | effort 계층 대신 `thinking.enabled`를 쓰는 chat 모델입니다. |
Expand Down Expand Up @@ -189,7 +190,8 @@ Anthropic 계정 정책 위험을 이해하지 못한다면 이 기능은 꺼두
"reasoning": ["rs_0"],
"message": ["msg_0"],
"repairMissingTerminalIds": true
}
},
"responsesSnapshotRepair": true
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids.
| `noPenaltyModels?` | `string[]` | Models that reject presence/frequency penalties. |
| `parallelToolCalls?` | `boolean` | Toggle parallel tool calls. OpenAI Chat defaults on; non-chat adapters advertise only on explicit `true`. |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | Disabled-by-default downstream SSE repair for exact placeholder ids and missing terminal ids. Function-call ids are never rewritten. |
| `responsesSnapshotRepair?` | `boolean` | Disabled-by-default client-facing repair for sparse Responses lifecycle snapshots in SSE and JSON. Fills missing canonical status, output, and tool metadata while raw inspection and persistence remain unchanged. |
| `autoToolChoiceOnlyModels?` | `string[]` | Models whose `tool_choice` accepts only `auto` or `none`; forced choices are downgraded. |
| `preserveReasoningContentModels?` | `string[]` | Models requiring prior assistant `reasoning_content` in chat history. |
| `thinkingToggleModels?` | `string[]` | Chat models using `thinking.enabled` rather than an effort ladder. |
Expand Down Expand Up @@ -230,7 +231,8 @@ For a broken `openai-responses` gateway, repair belongs on the provider object:
"reasoning": ["rs_0"],
"message": ["msg_0"],
"repairMissingTerminalIds": true
}
},
"responsesSnapshotRepair": true
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ cross-route credential fallback не существует. Строки API GPT-
| `noPenaltyModels?` | `string[]` | Модели, отвергающие penalty presence/frequency. |
| `parallelToolCalls?` | `boolean` | Переключатель parallel tool call'ов. Для OpenAI Chat по умолчанию включено; не-chat adapter'ы рекламируют это только при явном `true`. |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | По умолчанию выключенная downstream SSE-repair для exact placeholder-id и отсутствующих terminal-id. Function-call id никогда не переписываются. |
| `responsesSnapshotRepair?` | `boolean` | По умолчанию выключенная клиентская repair для неполных lifecycle snapshot'ов Responses в SSE и JSON. Добавляет отсутствующие status, output и tool metadata, не меняя raw inspection и persistence. |
| `autoToolChoiceOnlyModels?` | `string[]` | Модели, у которых `tool_choice` принимает только `auto` или `none`; forced choice понижается. |
| `preserveReasoningContentModels?` | `string[]` | Модели, которым нужен предыдущий assistant `reasoning_content` в chat history. |
| `thinkingToggleModels?` | `string[]` | Chat-модели, использующие `thinking.enabled` вместо effort-ladder. |
Expand Down Expand Up @@ -240,7 +241,8 @@ Beijing, а `alibaba-token-plan-intl` обслуживает междунаро
"reasoning": ["rs_0"],
"message": ["msg_0"],
"repairMissingTerminalIds": true
}
},
"responsesSnapshotRepair": true
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、
| `noPenaltyModels?` | `string[]` | 会拒绝 presence/frequency penalty 的模型。 |
| `parallelToolCalls?` | `boolean` | 切换并行工具调用。OpenAI Chat 默认开启;非 chat 适配器只有显式 `true` 时才会声明支持。 |
| `responsesItemIdRepair?` | `{ message?: string[]; reasoning?: string[]; repairMissingTerminalIds?: boolean }` | 默认关闭的下游 SSE 修复,用于精确占位 id 和缺失的终止 id。function-call id 永远不会被重写。 |
| `responsesSnapshotRepair?` | `boolean` | 默认关闭的客户端修复,用于补全 SSE 与 JSON 中稀疏 Responses 生命周期快照缺失的 status、output 和工具元数据;原始检查与持久化保持不变。 |
| `autoToolChoiceOnlyModels?` | `string[]` | `tool_choice` 只接受 `auto` 或 `none` 的模型;强制选择会被降级。 |
| `preserveReasoningContentModels?` | `string[]` | 需要在聊天历史中保留先前 assistant `reasoning_content` 的模型。 |
| `thinkingToggleModels?` | `string[]` | 使用 `thinking.enabled` 而不是 effort 阶梯的 chat 模型。 |
Expand Down Expand Up @@ -183,7 +184,8 @@ affinity。这些策略不能规避 provider enforcement。
"reasoning": ["rs_0"],
"message": ["msg_0"],
"repairMissingTerminalIds": true
}
},
"responsesSnapshotRepair": true
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,7 @@ const providerConfigSchema = z.object({
reasoning: z.array(z.string().min(1)).optional(),
repairMissingTerminalIds: z.boolean().optional(),
}).strict().optional(),
responsesSnapshotRepair: z.boolean().optional(),
}).passthrough();

const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
Expand Down
9 changes: 6 additions & 3 deletions src/lib/bun-stream-caps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ export function decideEagerRelay(
* Apply the two-platform eager-relay policy to the runtime/config capability.
* Windows preserves the decision for no-rewrite traffic. Darwin permits only
* explicit config opt-in; `auto` remains tee even on a future fixed runtime.
* Returns the normalized effective decision, or null when platform policy,
* rewrite needs, or a Darwin non-config-eager mode selects tee.
* Returns the normalized effective decision, or null when platform policy, a Windows rewrite,
* or a Darwin non-config-eager mode excludes this path. Windows rewrites are forced onto the
* eager single-reader path separately by `isWin32EagerRewrite`; Darwin's explicit eager mode may
* compose a client-facing rewrite after the single-reader relay.
*/
export function selectEagerPath(
platform: NodeJS.Platform,
Expand All @@ -102,7 +104,8 @@ export function selectEagerPath(
version: string = Bun.version,
minFixed: string | null = MIN_FIXED_BUN_VERSION,
): EagerRelayDecision | null {
if (needsClientRewrite || (platform !== "win32" && platform !== "darwin")) {
if ((needsClientRewrite && platform === "win32")
|| (platform !== "win32" && platform !== "darwin")) {
return null;
}

Expand Down
23 changes: 18 additions & 5 deletions src/server/relay-eager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ export function relaySseEagerBounded(
let queuedBytes = 0;
let cancelled = false;
let done = false;
// Raw inspection can observe a terminal before an inline rewrite succeeds.
// Track client delivery separately so a rewrite failure cannot leave the
// client with an empty/truncated stream while persistence records success.
let clientTerminalEnqueued = false;
// Pause gate: resolved by client pull, client cancel, or upstream abort so a
// paused producer ALWAYS resumes (audit blocker 2 — no deadlock; onDone and
// turn unregistration stay reachable, drainAndShutdown never hangs).
Expand Down Expand Up @@ -192,6 +196,7 @@ export function relaySseEagerBounded(

const producer = async () => {
let syntheticKind: "incomplete" | "failed" | null = null;
let clientRelayFailed = false;
// reader.read() is not intrinsically tied to the upstream AbortController
// (a fetch body usually rejects on abort, but that coupling is the fetch
// implementation's, not the stream's). Race every read against the abort
Expand All @@ -212,7 +217,10 @@ export function relaySseEagerBounded(
const tail = flushRewriteTail();
if (tail.byteLength > 0 && !cancelled) {
queuedBytes += tail.byteLength;
try { controllerRef?.enqueue(tail); } catch { /* client already gone */ }
try {
controllerRef?.enqueue(tail);
if (hooks.sawTerminal()) clientTerminalEnqueued = true;
} catch { /* client already gone */ }
}
}
if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
Expand All @@ -235,6 +243,7 @@ export function relaySseEagerBounded(
queuedBytes += outbound.byteLength;
try {
controllerRef?.enqueue(outbound);
if (hooks.sawTerminal()) clientTerminalEnqueued = true;
} catch {
// Controller already torn down (client went away without cancel()).
cancelled = true;
Expand All @@ -249,16 +258,19 @@ export function relaySseEagerBounded(
} catch (err) {
// Upstream read failure. Distinguish genuine mid-stream reset from
// abort-driven teardown (shutdown/cancel-expiry) — audit M3.
if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
if (!clientTerminalEnqueued && !cancelled && !upstream.signal.aborted) {
// Serializing `err` can run user-defined accessors (Error.message
// getters, toString) that re-entrantly cancel the client or abort the
// upstream. Build the tail FIRST, then re-check eligibility before
// committing to the synthetic terminal (adversarial review blocker).
const tail = new TextEncoder().encode(
`\n\nevent: response.failed\ndata: ${buildFailedTailPayload(err)}\n\ndata: [DONE]\n\n`,
);
if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
syntheticKind = "failed";
if (!clientTerminalEnqueued && !cancelled && !upstream.signal.aborted) {
// If raw inspection already recorded a real terminal, the failed
// tail is client-only. Do not overwrite raw terminal accounting.
if (!hooks.sawTerminal()) syntheticKind = "failed";
clientRelayFailed = true;
queuedBytes += tail.byteLength;
try { controllerRef?.enqueue(tail); } catch { /* client already torn down */ }
try { controllerRef?.close(); } catch { /* client already torn down */ }
Expand All @@ -276,14 +288,15 @@ export function relaySseEagerBounded(
if (cancelled && !hooks.sawTerminal()) {
hooks.onClientCancel();
}
if (cancelled || upstream.signal.aborted || syntheticKind === "failed") {
if (cancelled || upstream.signal.aborted || syntheticKind === "failed" || clientRelayFailed) {
upstream.abort();
reader.cancel().catch(() => {});
}
if (!cancelled) {
try { controllerRef?.close(); } catch { /* already closed/errored */ }
}
try { hooks.disposeInspection?.(); } catch { /* inspection teardown must not block lifecycle cleanup */ }
try { rewrite?.dispose?.(); } catch { /* rewrite teardown must not block lifecycle cleanup */ }
fireDone();
}
};
Expand Down
10 changes: 7 additions & 3 deletions src/server/relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,11 +711,15 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
return;
}
if (!hasAuthoritativeOutput && completedItemsByOutputIndex!.size > 0) {
const orderedItems = [...completedItemsByOutputIndex!.entries()]
.sort(([left], [right]) => left - right);
if (!orderedItems.every(([index], position) => index === position)) {
clearCompletedItems();
return;
}
response = {
...response,
output: [...completedItemsByOutputIndex!.entries()]
.sort(([left], [right]) => left - right)
.map(([, retained]) => retained.item),
output: orderedItems.map(([, retained]) => retained.item),
};
}
try {
Expand Down
Loading
Loading