feat(fb-33): streaming ask (ndjson delta) #124
Reference in New Issue
Block a user
Delete Branch "feat/fb-33-streaming-ask"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
kebab ask --streamemittinganswer_event.v1ndjson events on stderr (retrieval_done→token* →final); final stdout line staysanswer.v1for backwards compat (mirrorsingest_progress.v1pattern)AskOpts.stream_sinknow carries discriminatedStreamEventinstead of bareString; TUI worker adapted (token concat behavior unchanged)BrokenPipe→ drop receiver → pipelineSendError→ LLM loop break +RefusalReason::LlmStreamAborted; partial answer still persisted toanswerstable for auditRagPipeline::askaddsRetrievalDone(after retrieve+stale-stamp) +Tokenper chunk +Finalon success; refusal logic lifts cancel above LlmSelfJudgekebab__askstreaming deferred to v0.5+ (rmcp progress notifications need verification first)Test plan
cargo test --workspace --no-fail-fast -j 1— greencargo clippy --workspace --all-targets -- -D warnings— cleanstreaming_events(kebab-rag): order invariant + cancel propagation (2 tests)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)docs/SMOKE.md"Streaming ask" walkthroughArchitectural notes
RetrievalDone.hitsincludes the post-stale-stamp values so consumers see the samestaledata the App-level wire path emits.Finalmirrors the canonical Answer; TUI worker ignores it (worker join already delivers Answer inpoll_worker).refuse_score_gate,refuse_no_chunks) emitretrieval_donethen notoken/final— agents read stdoutanswer.v1for the canonical refusal signal.compute_staleon cancel-aware refusal logic:Cancelledtakes priority over LlmSelfJudge so telemetry reflects "caller bailed" rather than "model didn't cite".StreamEventlives inkebab-ragand is re-exported viakebab-app::StreamEventfor CLI/TUI consumption.#[allow(clippy::large_enum_variant)]onStreamEvent(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
docs/superpowers/specs/2026-05-09-p9-fb-33-streaming-ask-design.mddocs/superpowers/plans/2026-05-09-p9-fb-33-streaming-ask.mdcrates/kebab-rag/src/pipeline.rs(StreamEvent + emit + cancel)crates/kebab-cli/src/main.rs(Cmd::Ask--streambranch),crates/kebab-cli/src/wire.rs(wire_answer_event)docs/wire-schema/v1/answer_event.schema.jsoncrates/kebab-tui/src/ask.rs(drain_stream match)crates/kebab-rag/tests/streaming_events.rs,crates/kebab-cli/tests/wire_ask_stream.rsWorker 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>회차 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_gatehelper 가 존재하는 것이라 코멘트와 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.@@ -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 만" 이라고 적고 테스트가 없는 상태.@@ -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" 식으로 바꾸면 의도가 정확해짐.@@ -0,0 +166,4 @@let stderr = child.stderr.take().expect("stderr piped");let mut reader = BufReader::new(stderr);let mut first = String::new();readerBrokenPipe 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 이라 톤이 어긋나면 혼동 큼.
@@ -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 가 주입하는 방식이 책임 분리도 명확.@@ -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" 정도로 완화 권장.@@ -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 수준.
@@ -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 동작 차이를 명시적으로 적어 둔 점도 좋음 — 미래에 누가 보더라도 의도 변화가 한눈에 들어옴.@@ -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 사용이 깔끔.@@ -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 자체에 명시.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 발생 시 검색 키 됨.회차 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.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.mdGoal 의event shape항목 ('event 종류 / 순서 invariant') 또는 designwire-schema answer.v1같은 contract_section 으로 정정. 사실 이 변경은 wire schema additive 의 부산물 (single sink 로 3 stage 를 운반하려면 String 으로는 부족) 이라 'wire schema additive 가 단일 sink 운반을 강제 → 자연스럽게 internal type 폭이 넓어짐' 으로 정리하는 게 더 정확함.회차 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 가능.AskOpts 타입 widening 을
answer_event.v1단일 sink 3-stage 운반의 "부산물" 로 framing 한 부분 — 인과관계가 명확해져서 standalone breaking change 처럼 읽히던 round 1 의 모호함이 해소됨. 좋은 prose 정정.