Skip to content

Test-session resilience: turn-stall watchdog, idle-TTL reaper, lifecycle logs#444

Open
LikiosSedo wants to merge 2 commits into
mainfrom
fix/kb-test-session-resilience
Open

Test-session resilience: turn-stall watchdog, idle-TTL reaper, lifecycle logs#444
LikiosSedo wants to merge 2 commits into
mainfrom
fix/kb-test-session-resilience

Conversation

@LikiosSedo

Copy link
Copy Markdown
Collaborator

Summary

Fixes three production-confirmed defects in the read-only test-session path (kbc/platform/pod/compile_box.py), all box-side. Each was verified against the code before fixing.

1. Test turns hung on "thinking" forever (no stall watchdog)

test_session_driver ran a bare async for msg in client.receive_messages(): await _emit_message(...) with no model-stall watchdog — unlike the compile path, which runs _consume_turn_stream under _model_stall_watchdog (armed in _run_wrapper). When the gateway black-holed a model request (connection accepted, no response), the turn stayed active and the UI spun indefinitely with no recovery.

Fix: _test_stall_watchdog + _consume_test_turn_stream reuse the compile idle-latency semantics (any SDK event == alive; a pending read tool relaxes to the longer tool bound so a slow Read/Grep is never false-killed). On a stall the turn is reaped once, without retry: interrupt the SDK stream, emit a turn-level error + turn_done so the UI goes idle, and keep the session live for the next question (the receive loop discards the torn-down ResultMessage). Idle bound defaults to the compile bound, overridable via KBC_TEST_MODEL_IDLE_TIMEOUT_S. Test sessions keep faithful streaming (no de-stream shim), so this watchdog is their only stall guard.

2. Orphaned sessions exhausted the concurrency cap (no idle TTL / list endpoint)

TEST_SESSIONS had no idle TTL and no enumeration. A server-side timeout or a persistence failure left box-side sessions nobody closed, filling KBC_MAX_TEST_SESSIONS (default 3) until the pod was recreated.

Fix: TestRun now tracks created_at / last_activity_at / a monotonic idle marker (touched on open, each turn, and event consumption). A periodic sweep (_test_session_reaper, KBC_TEST_SESSION_IDLE_TTL_S default 1800s) tears down idle sessions and emits a close event. New endpoint GET /test-sessions returns {"sessions":[{"tid","parent_run_id","created_at","last_activity_at","done"}]} — wire contract agreed with the consumer (field names are load-bearing).

3. Zero lifecycle observability

The box only printed its startup line; pod logs showed nothing for test-session activity.

Fix: _print_test_lifecycle emits one stdout line per lifecycle event (test.open / test.close / turn.start / turn.done / turn.stalled / ttl.reap), carrying tid + parent_run_id only — never user content.

Constraints held

  • CompileRun behavior untouched.
  • No changes to destream or unrelated areas; changes are confined to compile_box.py (+ tests).
  • Python style matches the file.

Tests

test_compile_box.py (+3, full suite 69 passing):

  • stall reaper frees a wedged turn, emits error + turn_done, and the same session answers a follow-up question; asserts stdout lines carry tid/parent and leak no question text;
  • idle-TTL sweep drops orphans (snapshot dir + slot freed) and spares an active session;
  • GET /test-sessions returns the agreed wire shape.

…fecycle logs

Three production-confirmed defects in the read-only test-session path
(kbc/platform/pod/compile_box.py), all box-side:

1. Test turns hung on "thinking" forever. The test-session receive loop
   (test_session_driver) was a bare `async for msg in receive_messages()`
   with no model-stall watchdog — unlike the compile path, which runs
   _consume_turn_stream under _model_stall_watchdog. A black-holed model
   request (gateway accepts but never responds) left the turn active and the
   UI spinning indefinitely, with no recovery.

   Fix: _test_stall_watchdog + _consume_test_turn_stream reuse the compile
   idle-latency semantics (any SDK event == alive; a pending read tool relaxes
   to the tool bound). On a stall the turn is reaped once (no retry):
   interrupt the SDK stream, emit a turn-level error + turn_done so the UI goes
   idle, and keep the session live for the next question. The receive loop
   discards the torn-down ResultMessage. Bound defaults to the compile idle
   bound, overridable via KBC_TEST_MODEL_IDLE_TIMEOUT_S.

2. Orphaned sessions exhausted the concurrency cap. TEST_SESSIONS had no idle
   TTL and no way to enumerate sessions, so a server timeout or a persistence
   failure left box-side sessions that nobody closed, filling
   KBC_MAX_TEST_SESSIONS (3) until pod recreation.

   Fix: TestRun now tracks created_at / last_activity_at / a monotonic idle
   marker (touched on open, each turn, and event consumption). A periodic
   sweep (_test_session_reaper, KBC_TEST_SESSION_IDLE_TTL_S default 1800s)
   tears down idle sessions and emits a close event. New GET /test-sessions
   returns {"sessions":[{tid,parent_run_id,created_at,last_activity_at,done}]}
   (wire contract agreed with the consumer).

3. Zero lifecycle observability. The box only printed its startup line, so pod
   logs showed nothing for test-session open/close/turn activity.

   Fix: _print_test_lifecycle emits one stdout line per lifecycle event
   (test.open / test.close / turn.start / turn.done / turn.stalled / ttl.reap),
   carrying tid + parent_run_id only — never user content.

Constraints held: CompileRun behavior untouched; destream/unrelated areas not
touched; test sessions keep faithful streaming (no de-stream shim), so the new
watchdog is their only stall guard.

Tests (test_compile_box.py, +3): stall reaper frees a wedged turn and keeps the
session live for a follow-up question; idle-TTL sweep drops orphans and spares
active sessions; GET /test-sessions returns the agreed wire shape. Full suite
green (69 passing).
…session limit

Wire the box-side test-session resilience through the TS gateway so the consumer
can reach it, aligned with the sicore side.

1. capability.testSessions (src/gateway/server.ts): new RPC method proxying the
   box's GET /test-sessions. Read-only reconciliation — mirrors testClose's box
   discovery and NEVER spawns/rehydrates a box just to list (absent/dead box →
   empty list). Box rows pass through verbatim; the `tid` wire field is
   load-bearing for the consumer and is not renamed. Response:
   {run_id, sessions:[{tid,parent_run_id,created_at,last_activity_at,done}]}.

2. Stable 429 error code (kbc/platform/pod/compile_box.py): the box's
   "too many concurrent test sessions" refusal now returns the structured
   {error:{code,message,retriable:false}} shape (same convention as
   handle_test_recommendation) with code "test_session_limit". The agentbox
   error mapping (agentBoxResponseError → wrapRpcError) passes the code +
   retriable=false through unchanged. Without this a bare 429 mapped to the
   generic TOO_MANY_REQUESTS with retriable=true — indistinguishable from a
   model rate-limit 429 and wrongly retriable. Not retriable by design: the
   caller must close an idle session, not auto-retry.

3. Wire contract (src/gateway/capability/contract.ts): CAPABILITY_TEST_SESSIONS,
   the request/response/summary interfaces, and TEST_SESSION_LIMIT_ERROR_CODE
   documented so the box↔runtime↔consumer contract is single-sourced.

Tests: new src/gateway/server-capability-test-sessions.test.ts (passthrough with
tid preserved / empty on absent box / run_id required); box test asserts the
structured 429 body. tsc --noEmit clean; gateway vitest green; box suite 69
passing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant