feat(fb-33): streaming ask (ndjson delta) #124

Merged
altair823 merged 11 commits from feat/fb-33-streaming-ask into main 2026-05-09 07:34:08 +00:00
Owner

Summary

  • adds kebab ask --stream emitting answer_event.v1 ndjson events on stderr (retrieval_donetoken* → final); final stdout line stays answer.v1 for backwards compat (mirrors ingest_progress.v1 pattern)
  • internal API: AskOpts.stream_sink now carries discriminated StreamEvent instead of bare String; TUI worker adapted (token concat behavior unchanged)
  • cancel: stderr close → BrokenPipe → drop receiver → pipeline SendError → LLM loop break + RefusalReason::LlmStreamAborted; partial answer still persisted to answers table for audit
  • RagPipeline::ask adds RetrievalDone (after retrieve+stale-stamp) + Token per chunk + Final on success; refusal logic lifts cancel above LlmSelfJudge
  • MCP kebab__ask streaming deferred to v0.5+ (rmcp progress notifications need verification first)

Test plan

  • cargo test --workspace --no-fail-fast -j 1 — green
  • cargo clippy --workspace --all-targets -- -D warnings — clean
  • new tests:
    • streaming_events (kebab-rag): order invariant + cancel propagation (2 tests)
    • serde round-trip on StreamEvent (kebab-rag, 3 tests)
    • wire_ask_stream (kebab-cli): ndjson shape + stdout final + BrokenPipe cancel (3 tests, Ollama-gated, verified locally with gemma4:e4b)
  • manual smoke per docs/SMOKE.md "Streaming ask" walkthrough

Architectural notes

  • RetrievalDone.hits includes the post-stale-stamp values so consumers see the same stale data the App-level wire path emits.
  • Final mirrors the canonical Answer; TUI worker ignores it (worker join already delivers Answer in poll_worker).
  • Refusal paths (refuse_score_gate, refuse_no_chunks) emit retrieval_done then no token/final — agents read stdout answer.v1 for the canonical refusal signal.
  • compute_stale on cancel-aware refusal logic: Cancelled takes priority over LlmSelfJudge so telemetry reflects "caller bailed" rather than "model didn't cite".
  • StreamEvent lives in kebab-rag and is re-exported via kebab-app::StreamEvent for CLI/TUI consumption.
  • #[allow(clippy::large_enum_variant)] on StreamEvent (Final.answer ~320B) — boxing would force every consumer to deref; channel is short-lived (one per ask) so the size cost is amortized.

Files of interest

  • spec: docs/superpowers/specs/2026-05-09-p9-fb-33-streaming-ask-design.md
  • plan: docs/superpowers/plans/2026-05-09-p9-fb-33-streaming-ask.md
  • pipeline: crates/kebab-rag/src/pipeline.rs (StreamEvent + emit + cancel)
  • CLI: crates/kebab-cli/src/main.rs (Cmd::Ask --stream branch), crates/kebab-cli/src/wire.rs (wire_answer_event)
  • wire: docs/wire-schema/v1/answer_event.schema.json
  • TUI: crates/kebab-tui/src/ask.rs (drain_stream match)
  • tests: crates/kebab-rag/tests/streaming_events.rs, crates/kebab-cli/tests/wire_ask_stream.rs
## Summary - adds `kebab ask --stream` emitting `answer_event.v1` ndjson events on stderr (`retrieval_done` → `token`* → `final`); final stdout line stays `answer.v1` for backwards compat (mirrors `ingest_progress.v1` pattern) - internal API: `AskOpts.stream_sink` now carries discriminated `StreamEvent` instead of bare `String`; TUI worker adapted (token concat behavior unchanged) - cancel: stderr close → `BrokenPipe` → drop receiver → pipeline `SendError` → LLM loop break + `RefusalReason::LlmStreamAborted`; partial answer still persisted to `answers` table for audit - `RagPipeline::ask` adds `RetrievalDone` (after retrieve+stale-stamp) + `Token` per chunk + `Final` on success; refusal logic lifts cancel above LlmSelfJudge - MCP `kebab__ask` streaming deferred to v0.5+ (rmcp progress notifications need verification first) ## Test plan - [x] `cargo test --workspace --no-fail-fast -j 1` — green - [x] `cargo clippy --workspace --all-targets -- -D warnings` — clean - [x] new tests: - `streaming_events` (kebab-rag): order invariant + cancel propagation (2 tests) - serde round-trip on `StreamEvent` (kebab-rag, 3 tests) - `wire_ask_stream` (kebab-cli): ndjson shape + stdout final + BrokenPipe cancel (3 tests, Ollama-gated, verified locally with gemma4:e4b) - [x] manual smoke per `docs/SMOKE.md` "Streaming ask" walkthrough ## Architectural notes - `RetrievalDone.hits` includes the post-stale-stamp values so consumers see the same `stale` data the App-level wire path emits. - `Final` mirrors the canonical Answer; TUI worker ignores it (worker join already delivers Answer in `poll_worker`). - Refusal paths (`refuse_score_gate`, `refuse_no_chunks`) emit `retrieval_done` then no `token`/`final` — agents read stdout `answer.v1` for the canonical refusal signal. - `compute_stale` on cancel-aware refusal logic: `Cancelled` takes priority over LlmSelfJudge so telemetry reflects "caller bailed" rather than "model didn't cite". - `StreamEvent` lives in `kebab-rag` and is re-exported via `kebab-app::StreamEvent` for CLI/TUI consumption. - `#[allow(clippy::large_enum_variant)]` on `StreamEvent` (Final.answer ~320B) — boxing would force every consumer to deref; channel is short-lived (one per ask) so the size cost is amortized. ## Files of interest - spec: `docs/superpowers/specs/2026-05-09-p9-fb-33-streaming-ask-design.md` - plan: `docs/superpowers/plans/2026-05-09-p9-fb-33-streaming-ask.md` - pipeline: `crates/kebab-rag/src/pipeline.rs` (StreamEvent + emit + cancel) - CLI: `crates/kebab-cli/src/main.rs` (Cmd::Ask `--stream` branch), `crates/kebab-cli/src/wire.rs` (`wire_answer_event`) - wire: `docs/wire-schema/v1/answer_event.schema.json` - TUI: `crates/kebab-tui/src/ask.rs` (drain_stream match) - tests: `crates/kebab-rag/tests/streaming_events.rs`, `crates/kebab-cli/tests/wire_ask_stream.rs`
altair823 added 9 commits 2026-05-09 06:25:40 +00:00
3-variant StreamEvent enum (RetrievalDone / Token / Final) 을 통해
RagPipeline 이 retrieval / per-token / final 단계를 sink 로 발사.
CLI `kebab ask --stream` 이 ndjson event 를 stderr 로 흘리고 final
stdout line 은 기존 answer.v1 그대로 (ingest_progress.v1 패턴).
Cancel = stdout 닫힘 → SendError → LLM stream break +
RefusalReason::LlmStreamAborted 로 partial answer 기록.
MCP streaming 은 v0.5+ 별도 검토 (scope out).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 tasks: StreamEvent enum + AskOpts switch (kebab-core), pipeline
emits + cancel branch (kebab-rag), kebab-app re-exports, TUI
worker adapt, wire schema answer_event.v1, CLI --stream flag +
ndjson stderr driver + BrokenPipe cancel, integration tests
(Ollama-gated), workspace+clippy gate, docs, smoke+PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3-variant discriminated enum (RetrievalDone / Token / Final).
AskOpts.stream_sink now carries StreamEvent. Other crates fail
to compile until subsequent tasks adapt their call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RetrievalDone after retrieve+stale-stamp, Token per LM chunk
(SendError → break, FinishReason::Cancelled, RefusalReason::
LlmStreamAborted), Final on success. answers row still persists
on cancel for audit. Adds FinishReason::Cancelled, re-exports
StreamEvent from kebab_rag, migrates two pre-fb-33 sink tests
in tests/pipeline.rs to the new StreamEvent type (the
"dropped receiver does not abort" test inverts to record cancel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Worker channel now carries kebab_app::StreamEvent. drain_stream
matches on Token { delta }; RetrievalDone and Final are ignored
(citations render from last_answer, Final is redundant with
worker join). app::AskState.rx type widened to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discriminated ndjson event for `kebab ask --stream`. Mirrors
the ingest_progress.v1 pattern (stderr stream + stdout final
answer.v1 for backwards compat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Background-thread driver runs ask_with_config; main thread
drains the receiver, serializes each StreamEvent to ndjson on
stderr. BrokenPipe → drop receiver → pipeline SendError →
cancel + LlmStreamAborted refusal. Final stdout line is the
existing answer.v1 (ingest_progress.v1 backwards-compat
pattern).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Ollama-gated integration tests covering:
- stderr lines parse as answer_event.v1 (retrieval_done first,
  final last, all carry RFC3339 ts).
- stdout final line is answer.v1 (backwards compat).
- non-stream path (--json without --stream) unchanged.
- BrokenPipe stderr → child terminates cleanly via cancel
  propagation through pipeline SendError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 requested changes 2026-05-09 06:32:43 +00:00
claude-reviewer-01 left a comment
Member

회차 1 — fb-33 streaming ask 전체 design 의 골격 (StreamEvent 3-variant, BrokenPipe → SendError → cancel, ingest_progress.v1 stderr+stdout 분리 패턴 답습) 은 명확하고 spec 과 일치. discriminated enum 의 wire 변환 (wire_answer_event) 도 ingest 쪽 helper 와 한결같이 정리됨. 다만 (a) pipeline.rs 모듈 docstring 이 fb-33 이전 cancel 동작을 그대로 남겨놓아 stale, (b) refusal path (score-gate / no-chunks) 가 retrieval_done 만 emit 하고 final 을 생략하는 사실이 wire schema description 에 기재 안 됨 — SKILL.md 에는 있는데 schema 가 외부 single source 인 통합도 있으니 필수, (c) wire_ask_stream.rs 의 ordering test 코멘트가 사실과 반대 — 정확히 그 이유 때문에 relax_score_gate helper 가 존재하는 것이라 코멘트와 helper 의도 둘 다 문서화 필요, (d) refusal path 자체의 wire 동작을 검증하는 단위 테스트 부재 — relax_score_gate 로 우회되어 회귀 보장 약함, (e) BrokenPipe cancel 통합 테스트가 첫 줄 직후 drop 만 다뤄 mid-stream drop 보장 약함. 그 외 internal API breaking 의 HOTFIXES 미기록 등 nit 포함 — 모두 round 1 에서 묶어 정리 권장.

회차 1 — fb-33 streaming ask 전체 design 의 골격 (StreamEvent 3-variant, BrokenPipe → SendError → cancel, ingest_progress.v1 stderr+stdout 분리 패턴 답습) 은 명확하고 spec 과 일치. discriminated enum 의 wire 변환 (`wire_answer_event`) 도 ingest 쪽 helper 와 한결같이 정리됨. 다만 (a) `pipeline.rs` 모듈 docstring 이 fb-33 이전 cancel 동작을 그대로 남겨놓아 stale, (b) refusal path (score-gate / no-chunks) 가 retrieval_done 만 emit 하고 final 을 생략하는 사실이 wire schema description 에 기재 안 됨 — SKILL.md 에는 있는데 schema 가 외부 single source 인 통합도 있으니 필수, (c) `wire_ask_stream.rs` 의 ordering test 코멘트가 사실과 반대 — 정확히 그 이유 때문에 `relax_score_gate` helper 가 존재하는 것이라 코멘트와 helper 의도 둘 다 문서화 필요, (d) refusal path 자체의 wire 동작을 검증하는 단위 테스트 부재 — `relax_score_gate` 로 우회되어 회귀 보장 약함, (e) BrokenPipe cancel 통합 테스트가 첫 줄 직후 drop 만 다뤄 mid-stream drop 보장 약함. 그 외 internal API breaking 의 HOTFIXES 미기록 등 nit 포함 — 모두 round 1 에서 묶어 정리 권장.
@@ -623,0 +640,4 @@
cancelled_pipe = true;
break;
}
return Err(e.into());

non-BrokenPipe IoError 발생 시 return Err(e.into()) 가 worker thread 를 join 하지 않고 함수 종료. 백그라운드 스레드는 LLM 응답이 끝날 때까지 살아 있다가 자연 소멸하긴 하지만, error path 에서 worker 의 partial answer 가 SQLite 에 기록되는 race 가 발생 가능. 최소한 drop(rx) + let _ = handle.join() 한 번 해주면 보장됨. nit.

non-BrokenPipe IoError 발생 시 `return Err(e.into())` 가 worker thread 를 join 하지 않고 함수 종료. 백그라운드 스레드는 LLM 응답이 끝날 때까지 살아 있다가 자연 소멸하긴 하지만, error path 에서 worker 의 partial answer 가 SQLite 에 기록되는 race 가 발생 가능. 최소한 `drop(rx)` + `let _ = handle.join()` 한 번 해주면 보장됨. nit.
@@ -0,0 +35,4 @@
let body = fs::read_to_string(cfg).expect("read config.toml");
let body = body.replace("score_gate = 0.30", "score_gate = 0.0");
fs::write(cfg, body).expect("write relaxed config.toml");
}

relax_score_gate 가 정당한 refusal path 를 일시 우회. 제안: refusal 경로 자체를 별도 테스트로 다루는 게 좋음 — stream_score_gate_refusal_emits_only_retrieval_done 같은 이름으로, 기본 score_gate 유지하고 kinds == ["retrieval_done"] 만 단언. 그러면 (a) helper 의 의도가 "happy-path 만 검증" 으로 명확해지고, (b) refusal path 의 wire 동작이 unit-level 에서도 한 번 회귀 보장됨. 현재는 skill notes 만 "refusal 은 retrieval_done 만" 이라고 적고 테스트가 없는 상태.

`relax_score_gate` 가 정당한 refusal path 를 일시 우회. 제안: refusal 경로 자체를 별도 테스트로 다루는 게 좋음 — `stream_score_gate_refusal_emits_only_retrieval_done` 같은 이름으로, 기본 score_gate 유지하고 `kinds == ["retrieval_done"]` 만 단언. 그러면 (a) helper 의 의도가 "happy-path 만 검증" 으로 명확해지고, (b) refusal path 의 wire 동작이 unit-level 에서도 한 번 회귀 보장됨. 현재는 skill notes 만 "refusal 은 retrieval_done 만" 이라고 적고 테스트가 없는 상태.
@@ -0,0 +74,4 @@
// First event must be retrieval_done. Last must be final.
// (If the LLM refused mid-stream we still expect the worker to
// emit a Final event — the pipeline always closes the stream.)

코멘트가 사실과 다름. "the pipeline always closes the stream" 는 score-gate / no-chunks refusal 에서 거짓 — Final 이 emit 되지 않는 정확한 이유가 곧 위 relax_score_gate() 호출의 존재 이유. 코멘트 표현을 "only the LLM-running path always closes with Final; score-gate / no-chunks refusal emits retrieval_done then nothing — relaxed via relax_score_gate above" 식으로 바꾸면 의도가 정확해짐.

코멘트가 사실과 다름. "the pipeline always closes the stream" 는 score-gate / no-chunks refusal 에서 거짓 — Final 이 emit 되지 않는 정확한 이유가 곧 위 `relax_score_gate()` 호출의 존재 이유. 코멘트 표현을 "only the LLM-running path always closes with Final; score-gate / no-chunks refusal emits retrieval_done then nothing — relaxed via relax_score_gate above" 식으로 바꾸면 의도가 정확해짐.
@@ -0,0 +166,4 @@
let stderr = child.stderr.take().expect("stderr piped");
let mut reader = BufReader::new(stderr);
let mut first = String::new();
reader

BrokenPipe cancel 테스트가 첫 줄 (retrieval_done) 직후 drop 만 다룸. 실제 사용 시나리오는 token 수십~수백 라인 흘러가다 중간 drop 도 흔한데, kernel pipe buffer (~64 KB) 가 차야 BrokenPipe 가 발생해서 "중간 drop → 빠른 cancel" 보장이 약함. 후속 테스트로 N 라인 (예: 30) 읽고 drop 하는 케이스 하나 더 추가 권장. 현 PR 머지 차단할 정도는 아님 — 같은 round 의 nit.

BrokenPipe cancel 테스트가 첫 줄 (retrieval_done) 직후 drop 만 다룸. 실제 사용 시나리오는 token 수십~수백 라인 흘러가다 중간 drop 도 흔한데, kernel pipe buffer (~64 KB) 가 차야 BrokenPipe 가 발생해서 "중간 drop → 빠른 cancel" 보장이 약함. 후속 테스트로 N 라인 (예: 30) 읽고 drop 하는 케이스 하나 더 추가 권장. 현 PR 머지 차단할 정도는 아님 — 같은 round 의 nit.

stale 모듈 docstring. fb-33 으로 동작이 바뀌었는데 여기엔 여전히 "dropped receiver does not abort generation" 으로 적혀 있음. ask 단계 5 설명을 새 cancel semantics 에 맞춰 갱신 필요 — 예: "a dropped receiver triggers cancel: SendError 시 LM loop break + RefusalReason::LlmStreamAborted". 모듈 진입자가 가장 먼저 읽는 docstring 이라 톤이 어긋나면 혼동 큼.

stale 모듈 docstring. fb-33 으로 동작이 바뀌었는데 여기엔 여전히 "dropped receiver does not abort generation" 으로 적혀 있음. ask 단계 5 설명을 새 cancel semantics 에 맞춰 갱신 필요 — 예: "a dropped receiver triggers cancel: SendError 시 LM loop break + RefusalReason::LlmStreamAborted". 모듈 진입자가 가장 먼저 읽는 docstring 이라 톤이 어긋나면 혼동 큼.
@@ -70,0 +81,4 @@
/// loop — see `RagPipeline::ask` cancel branch. In that case
/// `Final` is NOT emitted (the answer still gets persisted with
/// `RefusalReason::LlmStreamAborted`).
#[derive(Clone, Debug, serde::Serialize)]

(strength) #[serde(tag = "kind", rename_all = "snake_case")] 로 wire shape 와 in-process enum 을 한 번에 묶은 점 깔끔. wire_answer_event 가 serde_json::to_value 한 번에 dispatch 되는 패턴이 ingest_progress.v1 helper 와 일관 — schema_version + ts 를 외부 wrapper 가 주입하는 방식이 책임 분리도 명확.

(strength) `#[serde(tag = "kind", rename_all = "snake_case")]` 로 wire shape 와 in-process enum 을 한 번에 묶은 점 깔끔. wire_answer_event 가 `serde_json::to_value` 한 번에 dispatch 되는 패턴이 ingest_progress.v1 helper 와 일관 — schema_version + ts 를 외부 wrapper 가 주입하는 방식이 책임 분리도 명확.
@@ -70,0 +88,4 @@
// without boxing the wire-shape, which would force every consumer
// (TUI / CLI / future MCP) to deref. The sink is short-lived (one
// per ask) so the per-event overhead is not material.
#[allow(clippy::large_enum_variant)]

#[allow(clippy::large_enum_variant)] 의 정당화 코멘트가 RetrievalDone.hits: Vec<SearchHit> 의 비용에는 침묵. 실제로 Final.answer (~320 B) 보다 hits Vec 가 훨씬 크고 (k=10 default 면 SearchHit 가 ~1 KB × 10), 해당 cost 는 RetrievalDone 단 한 번 emit 이라 무시할 만하지만 코멘트가 "Final 이 가장 크다" 로 단정한 부분이 부정확. 한 줄 보강하거나 표현을 "largest persistent payload" 정도로 완화 권장.

`#[allow(clippy::large_enum_variant)]` 의 정당화 코멘트가 `RetrievalDone.hits: Vec<SearchHit>` 의 비용에는 침묵. 실제로 Final.answer (~320 B) 보다 hits Vec 가 훨씬 크고 (k=10 default 면 SearchHit 가 ~1 KB × 10), 해당 cost 는 RetrievalDone 단 한 번 emit 이라 무시할 만하지만 코멘트가 "Final 이 가장 크다" 로 단정한 부분이 부정확. 한 줄 보강하거나 표현을 "largest persistent payload" 정도로 완화 권장.
@@ -206,0 +241,4 @@
// ready (post stale-stamp so consumers see the same `stale`
// values the App-level wire path emits). Cancel is best-effort
// here — if the caller already dropped the receiver we just
// skip and let the LLM-loop SendError handle it consistently.

RetrievalDone send 결과가 silently dropped 되는 부분. 코멘트에서 "Cancel is best-effort here" 라고 했지만, receiver 가 이미 dropped 된 상태라면 LLM 호출 자체가 무의미함에도 그대로 generate_stream 이 시작됨. 실용적으로는 첫 Token send 에서 즉시 cancel 되니 손실은 작지만, 의도를 명확히 하려면 retrieval 직후 send 실패 시 score-gate refusal 과 비슷한 형태로 early-return 도 검토 가능. 일단 현재 동작은 spec 과 모순은 아니니 nit 수준.

RetrievalDone send 결과가 silently dropped 되는 부분. 코멘트에서 "Cancel is best-effort here" 라고 했지만, receiver 가 이미 dropped 된 상태라면 LLM 호출 자체가 무의미함에도 그대로 generate_stream 이 시작됨. 실용적으로는 첫 Token send 에서 즉시 cancel 되니 손실은 작지만, 의도를 명확히 하려면 retrieval 직후 send 실패 시 score-gate refusal 과 비슷한 형태로 early-return 도 검토 가능. 일단 현재 동작은 spec 과 모순은 아니니 nit 수준.
@@ -280,2 +288,3 @@
// ── 10. dropped receiver does NOT abort generation ────────────────────────
// ── 10. dropped receiver aborts generation, records LlmStreamAborted ──────
//

(strength) 기존 dropped_receiver_does_not_abort_generation 테스트를 새 의미 (dropped_receiver_aborts_with_llm_stream_aborted) 로 rename + assertion 갱신한 부분 정확. 위 코멘트 (line 256-262) 가 pre-fb-33 동작과 post-fb-33 동작 차이를 명시적으로 적어 둔 점도 좋음 — 미래에 누가 보더라도 의도 변화가 한눈에 들어옴.

(strength) 기존 `dropped_receiver_does_not_abort_generation` 테스트를 새 의미 (`dropped_receiver_aborts_with_llm_stream_aborted`) 로 rename + assertion 갱신한 부분 정확. 위 코멘트 (line 256-262) 가 pre-fb-33 동작과 post-fb-33 동작 차이를 명시적으로 적어 둔 점도 좋음 — 미래에 누가 보더라도 의도 변화가 한눈에 들어옴.
@@ -0,0 +127,4 @@
assert!(!ans.grounded);
assert_eq!(ans.refusal_reason, Some(RefusalReason::LlmStreamAborted));
// Persistence still happens on cancel — the row is the audit trail.
assert_eq!(env.count_answers(), 1, "answers row written on cancel");

cancel 테스트가 refusal_reason == LlmStreamAborted 까지는 검증하지만 "Final 이 emit 안 됐다" 는 invariant 는 미검증. let events: Vec<_> = rx2.iter().collect(); (별도 채널 X) 패턴이 어렵다면, 최소한 spec §Cancel-semantics 의 "cancel 경로는 0 event 또는 ndjson 흐름이 중간에 끊김" 부분을 테스트로 표현하면 회귀 방지 강함. 현재 테스트는 drop(rx) 직후라 events 수집 자체가 불가능하니, sink 의 try_recv 를 통한 partial 수집 또는 별도 mock 사용이 깔끔.

cancel 테스트가 `refusal_reason == LlmStreamAborted` 까지는 검증하지만 "Final 이 emit 안 됐다" 는 invariant 는 미검증. `let events: Vec<_> = rx2.iter().collect();` (별도 채널 X) 패턴이 어렵다면, 최소한 spec §Cancel-semantics 의 "cancel 경로는 0 event 또는 ndjson 흐름이 중간에 끊김" 부분을 테스트로 표현하면 회귀 방지 강함. 현재 테스트는 drop(rx) 직후라 events 수집 자체가 불가능하니, sink 의 try_recv 를 통한 partial 수집 또는 별도 mock 사용이 깔끔.
@@ -0,0 +2,4 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://kb.local/wire/v1/answer_event.schema.json",
"title": "AnswerEvent v1",
"description": "Streaming event emitted by `kebab ask --stream`. One event per line on stderr (ndjson). Discriminated by `kind`. Terminal: `final`. Final stdout line is `answer.v1` for backwards compat (see ingest_progress.v1 precedent).",

schema 의 description 이 "Terminal: final" 라고만 적혀 있는데, refusal path (score-gate / no-chunks) 에서는 retrieval_done 만 emit 되고 final 이 없음. 외부 consumer 가 schema 만 보고 "항상 final 이 마지막" 이라고 가정하면 hang 위험. description 에 "score-gate 또는 no-chunks refusal 에서는 retrieval_done 만 emit 되고 final 은 생략 — stdout 의 answer.v1 가 canonical refusal signal" 한 줄 추가 권장. SKILL.md 에는 이미 적혀 있는데 schema 가 single source of truth 인 외부 통합도 있으니 schema 자체에 명시.

schema 의 `description` 이 "Terminal: `final`" 라고만 적혀 있는데, refusal path (score-gate / no-chunks) 에서는 retrieval_done 만 emit 되고 final 이 없음. 외부 consumer 가 schema 만 보고 "항상 final 이 마지막" 이라고 가정하면 hang 위험. description 에 "score-gate 또는 no-chunks refusal 에서는 retrieval_done 만 emit 되고 final 은 생략 — stdout 의 answer.v1 가 canonical refusal signal" 한 줄 추가 권장. SKILL.md 에는 이미 적혀 있는데 schema 가 single source of truth 인 외부 통합도 있으니 schema 자체에 명시.

fb-33 의 internal API breaking (AskOpts.stream_sink: Sender<String>Sender<StreamEvent>) 가 spec 의 "Risks / notes" 에는 언급됐지만 HOTFIXES 에는 미기록. CLAUDE.md 의 가이드 ("Live deviations from the original contract go in tasks/HOTFIXES.md") 따라 한 줄 entry 권장 — 다음 fb-3X 에서 AskOpts 추가 field 작업할 때 churn 발생 시 검색 키 됨.

fb-33 의 internal API breaking (`AskOpts.stream_sink: Sender<String>` → `Sender<StreamEvent>`) 가 spec 의 "Risks / notes" 에는 언급됐지만 HOTFIXES 에는 미기록. CLAUDE.md 의 가이드 ("Live deviations from the original contract go in tasks/HOTFIXES.md") 따라 한 줄 entry 권장 — 다음 fb-3X 에서 AskOpts 추가 field 작업할 때 churn 발생 시 검색 키 됨.
altair823 added 1 commit 2026-05-09 06:46:50 +00:00
- pipeline: refresh module docstring step 5 to reflect new cancel
  semantics (RetrievalDone/Token/Final + LlmStreamAborted)
- wire schema: spell out refusal-path behavior in answer_event.v1
  description (only retrieval_done emitted; no final)
- test: factual comment on relax_score_gate-using test corrected
- test: new Ollama-gated stream_score_gate_refusal_emits_only_retrieval_done
- test: new ask_emits_no_final_when_cancelled_mid_stream pinning
  the no-Final invariant on cancel
- pipeline: large_enum_variant comment broadened to acknowledge
  RetrievalDone.hits as the dominant per-emit cost
- HOTFIXES: log AskOpts.stream_sink internal API break per spec
  contract policy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 requested changes 2026-05-09 06:52:23 +00:00
claude-reviewer-01 left a comment
Member

회차 2 — 회차 1 fix commit a082b78 의 7 개 항목은 모두 정확히 반영됐다. 특히 BlockingLm + std::sync::Barrier 패턴은 RetrievalDone 이 receiver buffer 에 도달한 뒤에야 LLM 이 unblock 되도록 hard-synchronize 하므로 제안된 단순 sleep 방식이 빠질 race 를 정공법으로 우회한 것이 인상적이고, score-gate refusal 테스트가 relax_score_gate 를 호출하지 않는 대비 구조도 깔끔하다. 다만 round-1 에서 새로 추가된 HOTFIXES entry 의 "spec §Domain API change" cross-reference 가 실재하지 않는 section 을 가리킨다 — tasks/p9/p9-fb-33-streaming-ask.mddocs/superpowers/specs/2026-04-27-kebab-final-form-design.md 둘 다 grep 해도 없는 heading 이라 future reader 가 헤맬 가능성이 있어 한 줄 정정이 필요하다. nit 한 건 외에는 회귀 / 신규 결함 없으니 이 줄만 다듬으면 R3 approve 갈 자리.

회차 2 — 회차 1 fix commit `a082b78` 의 7 개 항목은 모두 정확히 반영됐다. 특히 `BlockingLm` + `std::sync::Barrier` 패턴은 `RetrievalDone` 이 receiver buffer 에 도달한 뒤에야 LLM 이 unblock 되도록 hard-synchronize 하므로 제안된 단순 sleep 방식이 빠질 race 를 정공법으로 우회한 것이 인상적이고, score-gate refusal 테스트가 `relax_score_gate` 를 호출하지 않는 대비 구조도 깔끔하다. 다만 round-1 에서 새로 추가된 HOTFIXES entry 의 "spec §Domain API change" cross-reference 가 실재하지 않는 section 을 가리킨다 — `tasks/p9/p9-fb-33-streaming-ask.md` 와 `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 둘 다 grep 해도 없는 heading 이라 future reader 가 헤맬 가능성이 있어 한 줄 정정이 필요하다. nit 한 건 외에는 회귀 / 신규 결함 없으니 이 줄만 다듬으면 R3 approve 갈 자리.
@@ -17,0 +18,4 @@
**무엇이 바뀌었나**: `kebab_rag::AskOpts.stream_sink` 의 타입이 `Option<mpsc::Sender<String>>` 에서 `Option<mpsc::Sender<StreamEvent>>` 로 변경됨. `kebab_app::StreamEvent` 가 새 re-export.
**Spec contract 와의 관계**: spec §Domain API change 에서 명시한 internal API breaking. consumer = TUI worker 한 곳 (이번 PR 에서 같이 갱신). 외부 consumer 없음.

Nit (R2 신규): 'spec §Domain API change' 라는 cross-reference 는 실재하지 않는 section 이다. tasks/p9/p9-fb-33-streaming-ask.mddocs/superpowers/specs/2026-04-27-kebab-final-form-design.md 둘 다 grep 해도 'Domain API change' 라는 헤딩이 없음. 다른 HOTFIXES entry 들이 spec §X 를 인용할 때는 실재 section 을 가리키는데 이 줄만 fabricated reference 라 future reader 가 헤맬 위험이 있음.

제안: 둘 중 하나로 — (1) 단순히 'fb-33 spec 의 internal API breaking 항목' 처럼 모호하게 풀어쓰거나, (2) 실제 spec 텍스트인 tasks/p9/p9-fb-33-streaming-ask.md Goal 의 event shape 항목 ('event 종류 / 순서 invariant') 또는 design wire-schema answer.v1 같은 contract_section 으로 정정. 사실 이 변경은 wire schema additive 의 부산물 (single sink 로 3 stage 를 운반하려면 String 으로는 부족) 이라 'wire schema additive 가 단일 sink 운반을 강제 → 자연스럽게 internal type 폭이 넓어짐' 으로 정리하는 게 더 정확함.

Nit (R2 신규): 'spec §Domain API change' 라는 cross-reference 는 실재하지 않는 section 이다. `tasks/p9/p9-fb-33-streaming-ask.md` 와 `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 둘 다 grep 해도 'Domain API change' 라는 헤딩이 없음. 다른 HOTFIXES entry 들이 spec §X 를 인용할 때는 실재 section 을 가리키는데 이 줄만 fabricated reference 라 future reader 가 헤맬 위험이 있음. 제안: 둘 중 하나로 — (1) 단순히 'fb-33 spec 의 internal API breaking 항목' 처럼 모호하게 풀어쓰거나, (2) 실제 spec 텍스트인 `tasks/p9/p9-fb-33-streaming-ask.md` Goal 의 `event shape` 항목 ('event 종류 / 순서 invariant') 또는 design `wire-schema answer.v1` 같은 contract_section 으로 정정. 사실 이 변경은 wire schema additive 의 부산물 (single sink 로 3 stage 를 운반하려면 String 으로는 부족) 이라 'wire schema additive 가 단일 sink 운반을 강제 → 자연스럽게 internal type 폭이 넓어짐' 으로 정리하는 게 더 정확함.
altair823 added 1 commit 2026-05-09 06:53:09 +00:00
Pointed at the actual fb-33 design spec path + clarified that
the AskOpts type widening is a byproduct of the new wire schema
forcing single-sink 3-stage transport, not a stand-alone breaking
change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
claude-reviewer-01 approved these changes 2026-05-09 06:54:39 +00:00
claude-reviewer-01 left a comment
Member

회차 3 — 라운드 2 fix commit 225831f 검증 완료. HOTFIXES.md 한 줄 수정으로 cross-reference 가 실재 spec 파일 (docs/superpowers/specs/2026-05-09-p9-fb-33-streaming-ask-design.md) 의 "Domain API change" 절 (line 89, grep 으로 확인) 을 정확히 가리키며, AskOpts 타입 widening 이 answer_event.v1 의 single-sink 3-stage 전송 강제로 인한 부산물임을 명시한 prose 도 깔끔함. 추가 이슈 없음 — APPROVE.

회차 3 — 라운드 2 fix commit `225831f` 검증 완료. HOTFIXES.md 한 줄 수정으로 cross-reference 가 실재 spec 파일 (`docs/superpowers/specs/2026-05-09-p9-fb-33-streaming-ask-design.md`) 의 "Domain API change" 절 (line 89, grep 으로 확인) 을 정확히 가리키며, AskOpts 타입 widening 이 `answer_event.v1` 의 single-sink 3-stage 전송 강제로 인한 부산물임을 명시한 prose 도 깔끔함. 추가 이슈 없음 — APPROVE.
@@ -17,0 +18,4 @@
**무엇이 바뀌었나**: `kebab_rag::AskOpts.stream_sink` 의 타입이 `Option<mpsc::Sender<String>>` 에서 `Option<mpsc::Sender<StreamEvent>>` 로 변경됨. `kebab_app::StreamEvent` 가 새 re-export.
**Spec contract 와의 관계**: `answer_event.v1` (신규 wire schema) 가 단일 sink 로 3 stage (retrieval_done / token / final) 를 운반하도록 강제하면서 자연스럽게 in-process sink 의 type 폭이 넓어진 부산물. spec `docs/superpowers/specs/2026-05-09-p9-fb-33-streaming-ask-design.md` 의 "Domain API change" 절에서 미리 명시. consumer = TUI worker 한 곳 (이번 PR 에서 같이 갱신). 외부 consumer 없음.

Cross-reference 깔끔히 수정됨. 실제 파일 경로 (docs/superpowers/specs/2026-05-09-p9-fb-33-streaming-ask-design.md) + 실재하는 "Domain API change" 절 (line 89) 둘 다 검증 완료. 후속 reader 가 spec 으로 곧장 jump 가능.

Cross-reference 깔끔히 수정됨. 실제 파일 경로 (`docs/superpowers/specs/2026-05-09-p9-fb-33-streaming-ask-design.md`) + 실재하는 "Domain API change" 절 (line 89) 둘 다 검증 완료. 후속 reader 가 spec 으로 곧장 jump 가능.

AskOpts 타입 widening 을 answer_event.v1 단일 sink 3-stage 운반의 "부산물" 로 framing 한 부분 — 인과관계가 명확해져서 standalone breaking change 처럼 읽히던 round 1 의 모호함이 해소됨. 좋은 prose 정정.

AskOpts 타입 widening 을 `answer_event.v1` 단일 sink 3-stage 운반의 "부산물" 로 framing 한 부분 — 인과관계가 명확해져서 standalone breaking change 처럼 읽히던 round 1 의 모호함이 해소됨. 좋은 prose 정정.
altair823 merged commit a9ff122ab2 into main 2026-05-09 07:34:08 +00:00
altair823 deleted branch feat/fb-33-streaming-ask 2026-05-09 07:34:31 +00:00
Sign in to join this conversation.
No Reviewers
No Label
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#124