14 KiB
| handoff_version | handoff_status | created_at | target | local_source_revision | observed_dev_revision |
|---|---|---|---|---|---|
| 1 | implementation-not-started | 2026-08-13 | dev OpenAI-compatible ornith:35b streaming session stall | b205433a32 |
fd32abb4b6 |
Handoff: ornith:35b streaming session stall과 terminal 지연
현재 타겟
- 증상: Chronos
agent-task에서 Piiop/ornith:35bworker가failed:session-stall:143으로 세 번 연속 종료된다. - 범위: IOP dev OpenAI-compatible Chat Completions provider tunnel, Stream Evidence Gate, Node/provider liveness timeout 정합성.
- 상태: 진단만 수행했다. 코드, 설정, 배포는 변경하지 않았다.
- 다음 세션 첫 진입점:
apps/edge/internal/openai/stream_gate_runtime.goapps/edge/internal/openai/stream_gate_tunnel_codec.goapps/node/internal/adapters/openai_compat/provider_tunnel.gopackages/go/execution/liveness.go
환경 정정
초기 조사에서 dev-corp를 열어 DGX Spark 설정을 본 것은 잘못된 환경 선택이었다. Spark 관련 해석은 이 장애의 근거로 사용하지 않는다.
실제 Pi가 호출한 dev Edge 런타임을 기준으로 다시 확인했다.
- dev에는
gx10-vllm-node,onexplayer-lemonade-node,rtx5090-lemonade-node가 등록돼 있다. - 현재 배포 설정에서
gx10-vllm은laguna-s:2.1에 연결돼 있다. - 현재
ornith:35b는onexplayer-lemonade와rtx5090-lemonade에 연결돼 있다. - 이번 세 실패의 마지막 요청은 모두 실제 provider
onexplayer-lemonade로 dispatch됐다. - 제품 의도가
ornith:35b도 GX10에서 실행되는 것이라면, 현재 배포 route/catalog가 그 의도와 다르다. 구현 전에 운영 의도와 현재 binding을 먼저 확정해야 한다.
확정된 관측
Chronos dispatcher의 세 attempt는 모두 native session이 active이고 pending tool call이 없는 상태에서 약 180초 동안 새 출력이 없어 dispatcher가 SIGTERM을 보냈다. Unix exit 143은 이 SIGTERM의 결과이며 provider가 직접 반환한 오류 코드가 아니다.
| Attempt | 마지막 IOP 요청 | StreamGate 관측 | Chronos 종료 |
|---|---|---|---|
| 21 | 입력 추정 40,665 tokens, onexplayer-lemonade |
네 번 release 후 terminal 없음 | silence 180.048s, dispatcher SIGTERM |
| 22 | 입력 추정 40,830 tokens, onexplayer-lemonade |
두 번 release 후 terminal 없음 | silence 180.063s, dispatcher SIGTERM |
| 23 | 40,995-token turn은 약 224.7s 뒤 정상 terminal; 다음 41,080-token turn도 같은 provider |
다음 turn은 response_start만 staging되고 delta/terminal 없음 |
silence 180.070s, dispatcher SIGTERM |
Chronos 측 마지막 출력 시각과 IOP의 마지막 release_committed 시각이 밀리초 수준으로 일치한다. IOP 이후 Pi/dispatcher 구간에서 출력이 별도로 유실된 정황은 없다.
관련 Chronos evidence:
- attempt 21:
/config/workspace/chronos-s0/.git/agent-task-dispatcher/runs/20260813T074422+0900__m-chronos-product-delivery-install-update-lifecycle__01_release_metadata__p2__worker__a21/ - attempt 22:
/config/workspace/chronos-s0/.git/agent-task-dispatcher/runs/20260813T075124+0900__m-chronos-product-delivery-install-update-lifecycle__01_release_metadata__p2__worker__a22/ - attempt 23:
/config/workspace/chronos-s0/.git/agent-task-dispatcher/runs/20260813T075511+0900__m-chronos-product-delivery-install-update-lifecycle__01_release_metadata__p2__worker__a23/
각 디렉터리의 locator.json과 stream.log만 보면 dispatcher 종료 사유와 마지막 native output을 재확인할 수 있다. 이 경로들은 Chronos worktree-local evidence이므로 정리 전에 필요한 요약을 별도 보존해야 한다.
Stream Evidence Gate 판정
dev Edge에서 stream_evidence_gate.enabled=true이며 다음 정책이 ornith:35b에만 selector로 적용된다.
- filter:
repeat_guard - enforcement:
blocking - hold:
500Unicode runes - filter timeout:
5000ms
그러나 실패 시간대의 raw-free filter observation은 모두 다음과 같다.
openai.repeat_guard:pass,repeat_rolling_clear- attempt 23의 정상 완료 turn:
openai.repeat_guard.action도pass,repeat_action_clear violation,fatal,recover, continuation dispatch는 없음
따라서 반복 검증 판정이 요청을 차단하거나 recovery loop에 넣은 것이 직접 원인은 아니다.
다만 500-rune rolling hold는 증상을 확대한다. 이 값은 항상 마지막 500 runes를 보류한다는 뜻이 아니다. pending이 500 runes 미만이면 평가와 release를 기다리고, 500 이상이 되면 filter를 평가해 pass 시 그 epoch의 pending 전체를 즉시 release한다. terminal trigger가 오면 500 미만이어도 평가한다. 따라서 provider가 다음 delta나 terminal trigger를 보내지 않으면 현재 sub-threshold pending tail만 caller에게 release되지 않는다. caller가 180초 후 종료되면 그 tail도 정상 terminal 없이 폐기된다.
종료 신호 경계와 현재 관측 공백
OpenAI-compatible stream에는 서로 다른 계층의 종료 신호가 있다.
choices[].finish_reason: 모델 생성이 끝났다는 논리적 완료 신호- SSE
data: [DONE]: OpenAI stream framing의 완료 신호 - upstream response EOF와 Node tunnel
END: 실제 transport 완료 신호 - StreamGate Core terminal: codec/runtime이 위 신호를 normalized terminal로 변환한 결과
현재 codec은 finish_reason chunk를 terminal trigger로 만들지 않고 wire-only frame으로 staging한다. [DONE] 또는 tunnel END가 와야 Core terminal 경로가 실행된다. 그러므로 finish_reason은 도착했지만 [DONE]/EOF/END가 지연되거나 누락되면, 500-rune 미만 pending tail과 staged finish frame이 함께 보류될 수 있다.
기존 raw-free observation으로 확인된 것은 실패 요청에서 Core terminal 및 tunnel END가 관측되지 않았다는 사실까지다. 실패 요청에 finish_reason이 먼저 도착했는지, [DONE]이 codec에 도착했지만 변환 중 유실됐는지는 현재 로그만으로 확정할 수 없다. 따라서 이 경계는 확정 원인이 아니라 코드상 유력한 버그 후보로 취급한다.
현재 원인 판정
확인됨
- 요청은 Edge admission을 통과했고 provider tunnel의 response start와 일부 body를 받았다.
- 실패한 turn에서
repeat_guard는 모두 pass했다. - 마지막 release 뒤 Node tunnel의
END, provider error terminal 또는 Core terminal이 오지 않았다. - attempt 23의 다음 turn은 response start 뒤 body 자체가 오지 않았다.
- 세 요청 모두 약 40.7k~41.1k input-token 장문맥이고 실제 provider는
onexplayer-lemonade였다.
유력하지만 추가 분리가 필요함
onexplayer-lemonade backend가 장문맥 생성 중 진행을 멈췄거나, Node의 upstream HTTP reader/tunnel relay가 body 또는 EOF/END를 전달하지 못했을 가능성이 높다. 현재 Edge observation만으로는 provider backend와 Node relay 중 정확한 소유자를 단정할 수 없다.
timeout 정합성 결함
- Chronos dispatcher session silence timeout:
180s - IOP Node
response_stall_timeout_ms: 배포 설정에서 생략됨 - 생략 시 IOP 기본 response stall timeout:
300000ms - dev Edge OpenAI request timeout:
3600s
따라서 Chronos가 180초에 Pi를 종료해 버리고, IOP Node의 300초 liveness watchdog은 typed response_stalled terminal을 만들 기회를 얻지 못한다. 이 순서 때문에 IOP의 terminal/error/recovery 경계가 동작하기 전에 외부에서 143으로 끝난다.
부분 출력이 이미 caller에게 commit된 attempt 21/22는 stall을 감지하더라도 exact replay 대상이 아니다. 그래도 IOP가 dispatcher보다 먼저 명시적 error terminal을 보내야 truncated open stream 대신 원인을 관측할 수 있다. attempt 23의 두 번째 turn처럼 caller-visible commit 전 stall은 alternate provider exact replay 후보가 될 수 있다.
Pi TUI가 정상인 이유
Pi TUI가 Stream Evidence Gate를 우회하는 것은 아니다. 같은 OpenAI-compatible 경로를 사용한다.
- TUI에는 Chronos dispatcher의 180초 silence watchdog이 없다.
- 보통 fresh 또는 더 짧은 context에서 호출된다.
- 이번 stall은 결정적이지 않다. attempt 23의 첫 40,995-token turn도 같은 provider에서 정상 terminal까지 완료했다.
따라서 짧은 TUI 호출의 정상 완료는 filter 우회 증거가 아니며, 같은 약 41k context와 provider binding으로 재현해야 비교가 성립한다.
관련 구현 경계
apps/node/internal/adapters/openai_compat/provider_tunnel.go- upstream response body를 읽는 동안
BODYframe을 보내고 EOF 뒤ENDframe을 보낸다.
- upstream response body를 읽는 동안
apps/edge/internal/openai/stream_gate_runtime.go- tunnel frame을 normalized event로 변환하고
END에서 transport finish를 만든다. - event source wait timeout은 OpenAI request timeout 기반이므로 현재 dev에서는 dispatcher보다 훨씬 늦다.
- tunnel frame을 normalized event로 변환하고
apps/edge/internal/openai/stream_gate_tunnel_codec.go- semantic delta에 provider wire를 연결한다.
finish_reasonchunk는 protocol state/wire-only frame으로 staging할 뿐 terminal trigger로 만들지 않는다.[DONE]은 terminal 경로를 실행하며, transportEND도 runtime을 통해 terminal을 만든다.
apps/edge/internal/openai/stream_gate_release_sink.go- release payload를 flush하고 성공 terminal에서 staged terminal payload를 쓴다.
packages/go/execution/liveness.go- provider별 값이 없을 때 response stall timeout을
300000ms로 정규화한다.
- provider별 값이 없을 때 response stall timeout을
다음 작업 순서
-
route 의도 확정
- dev에서
ornith:35b의 의도된 실제 장비/provider가 GX10인지 확인한다. - GX10이 맞다면 현재 model catalog/provider binding drift를 먼저 수정 대상으로 잡는다.
- dev에서
-
provider와 Node relay 분리 재현
- 동일한 약 41k-token Chat Completions 요청을
stream=true로 준비한다. - provider 직접 호출과 IOP Node tunnel 호출을 각각 수행한다.
- raw prompt/output은 저장하지 않고 아래 이벤트를 request correlation과 monotonic timestamp로 각각 기록한다.
response_start, body/delta progress, 마지막 releasefinish_reason_observed와 모든 active choice가 finished됐는지 여부done_marker_observedupstream_eof_observedtunnel_end_emittedcore_terminal_emitted와 terminal commit- 각 종료 신호 시점의
pending_runes, staged finish/usage frame 수
- provider 직접 응답, Node ingress, tunnel emit, Edge codec, Core terminal의 시각을 대조해 어느 경계에서 신호가 멈췄는지 분리한다.
- 동일한 약 41k-token Chat Completions 요청을
-
timeout ownership 정렬
- provider의
response_stall_timeout_ms를 Chronos dispatcher보다 짧게 설정하는 방안을 검토한다. 예: IOP120s, dispatcher는 IOP terminal/recovery가 끝날 margin을 포함해 더 길게 둔다. - 수치는 장문맥에서 정상적으로 180초 이상 무출력 후 완료되는 사례가 있는지 측정한 뒤 확정한다.
- post-commit stall은 명시적 terminal error, pre-commit stall은 현재 ExactReplay gate에 따라 alternate provider recovery가 가능한지 각각 검증한다.
- provider의
-
회귀 테스트 추가
- active
repeat_guard에서 partialBODY뒤END가 오지 않는 fixture - response start만 온 pre-commit stall과 alternate-provider recovery
- stream-open stall의 단일 SSE error terminal
- pending 500-rune tail이 있는 상태의 stall/cancel 정리
- 정상 finish chunk와
[DONE]/transportEND의 exact-wire 단일 terminal finish_reason은 왔지만[DONE]/EOF/END가 오지 않는 fixture에서 pending release와 종료 grace 동작finish_reason뒤 usage chunk와 정상[DONE]이 오는 fixture에서 wire 순서 보존 및 terminal exactly-once
- active
-
종료 처리 수정 방향 검토
- 모든 active choice에 유효한
finish_reason이 확인되는 순간을 filter의 safe release trigger로 사용할지 검토한다. - filter가 pass하면 sub-threshold pending content와 staged finish chunk를 release한다.
- trailing usage와 정상
[DONE]을 받을 수 있도록 짧고 bounded된 terminal grace를 둔다. - grace 안에
[DONE]/END가 오지 않으면 terminal을 exactly-once로 finalize하고, 뒤늦은 중복 종료 신호는 무시한다. - exact-wire passthrough 및 trailing usage 호환성 때문에 filter release trigger와 transport terminal commit을 같은 상태로 단순 합치지 않는다.
- 모든 active choice에 유효한
-
dev live smoke
- Pi TUI와 headless dispatcher를 같은 model, context 크기, provider binding으로 비교한다.
- terminal 도착 시간, 마지막 progress부터 terminal까지의 idle, 선택 provider만 기록한다.
- secret, request 원문, response 원문은 evidence에 남기지 않는다.
이번 세션에서 수행한 검증
환경:
/config/.local/bin/go -> /config/opt/go/bin/go
go version go1.26.2 linux/arm64
GOROOT=/config/opt/go
실행 결과:
go test -count=1 ./apps/edge/internal/openai \
-run 'Test(StreamGateChatConfiguredOutputFiltersCleanStreamSingleTerminal|OpenAITunnelCodecTerminalWire|StreamGateTunnelBuildRuntimeNoopPassRelaysRawBytes|DevRepeatGuardRequestDeadline)$'
PASS
go test -count=1 ./packages/go/streamgate \
-run 'Test(StreamReleaserPassThroughAndTerminal|StreamReleaserTerminalAfterFullReleaseSkipsSecondaryRelease|CommitBoundaryCommitsEmptySuccessAndTerminalOnce)$'
PASS
이 테스트들은 정상 terminal/release 경로가 현재 소스에서 동작함을 확인한다. 실제 장문맥 provider stall을 재현하거나 timeout 정합성을 검증한 것은 아니다.
작업 시 주의사항
dev-corpSpark evidence를 이 장애에 재사용하지 않는다.onexplayer-lemonade같은 runtime alias만 보고 물리 장비를 단정하지 않는다.- filter가 원인이라고 전제하지 않는다. 현재 증거상 filter 판정은 전부 pass다.
- 반대로 filter의 500-rune hold가 보이는 truncation을 확대한다는 사실도 누락하지 않는다.
- 단순히 Chronos dispatcher timeout만 늘리면 open stream을 오래 유지할 뿐이다. IOP liveness terminal과의 순서를 함께 설계한다.
- live 설정 변경, provider 재시작, route 변경, 배포는 이번 handoff 범위에서 수행하지 않았다.