feat(server): composite session state and long-poll wait endpoint#50
Conversation
Session JSON and /session/status now surface `state`: idle | busy |
goal-running. goal-running wins whenever a goal is active, regardless
of the momentary busy/idle status value — resolving the ambiguity
orchestrators hit inferring progress from status=idle plus goal.active
during the goal loop's between-turn gap. The existing `status` field
is unchanged for backward compat.
Add GET /session/{id}/wait?until=idle|goal-done&timeout_s=N: a
long-poll that returns immediately if the condition already holds,
otherwise blocks until it holds or timeout_s elapses (default 30s,
capped 300s), responding {state, goal}. until=goal-done resolves as
soon as the goal becomes inactive (achieved or cleared, distinguished
via goal.achieved) or immediately if no goal was ever set. It is woken
by the server's existing durable-event fanout (notifyWaitersLocked,
hooked into emitDurableLocked) — no server-side polling — and the
waiter is always unregistered via defer, so a client disconnect never
leaks one.
Tests (red-verified: fail to compile against the pre-change code):
composite state during an active goal with a blocked worker; wait
returns immediately when the condition pre-holds; wait unblocks on
goal completion via the notify path (gateProvider, no sleeps); timeout
returns cleanly on fake time (testing/synctest, blocking-stream
pattern from goal_test.go); disconnect does not leak a waiter
(waiter-count assertion).
… openapi
Session.state (new, required) and the /session/status wrapper's state
field get their own SessionState enum with the precedence rule spelled
out (goal-running beats momentary busy/idle). status/SessionStatus
gains a cross-reference. GET /session/{id}/wait is documented in full:
until=idle|goal-done, timeout_s semantics (default 30s, capped 300s),
the {state, goal} response shape, and how a caller distinguishes
"condition met" from "timed out" from the response alone.
TestWaitUnblocksOnGoalCleared exercises the other until=goal-done terminal (DELETE /goal, not achievement) so both branches called out in the deliverable — achieved vs. cleared, distinguished via goal.achieved — are covered, not just the achieved path.
Replace the inline anonymous 200 schema on /session/{id}/wait with a
named WaitResponse component (state + optional goal), matching the
rest of the spec's convention of $ref'ing named schemas rather than
inlining response shapes. Documents precisely when goal is present vs.
absent and how a caller distinguishes "condition met" from "timed out"
from the response alone.
Replace /session/status's inline anonymous {type, state} map schema
with named StatusEntry and StatusResponse components, consistent with
WaitResponse — no more ad hoc inline response shapes in the spec.
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 4m 28s —— View job Review: composite session state + long-poll wait endpointI read
VerdictThis is a strong, carefully-reasoned PR. The wait machinery is correct on the hard parts, and the doc comments make the invariants auditable. No blocking issues found — one minor efficiency nit (inline). Concurrency & missed-wakeup analysis (the part that matters most here):
Testing (per AGENTS.md): exemplary. Timer path is inside a OpenAPI: thorough — named Notes (all non-blocking)
Startup-speed checkNo init-time network/disk/subprocess work introduced. The wait path's only disk touch is note (1), which is per-request, not boot. ✅ |
handleWait used s.lookup(id) to check whether a session exists, which for a non-resident session fully deserializes it via LoadSession just to discard the result. Replace with the resident-map + sessionOnDisk stat that handleAbort and handleGoalDelete already use, preserving identical 404 semantics (malformed ids are already rejected earlier by sessionIDOrNotFound; well-formed but unknown ids still 404).
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 3m 12s —— View job Review: composite session state + long-poll wait endpointRead
VerdictSolid PR — no blocking issues. The prior round's one inline nit (the long-poll existence check fully deserializing an evicted session) is resolved in Concurrency / missed-wakeup (the part that matters here):
Testing (per AGENTS.md): exemplary. Timer path is inside a OpenAPI: thorough — named Non-blocking notes (optional)
Startup-speed checkNo init-time network/disk/subprocess work. The only disk touch on the wait path is |
WaitResponse's schema description and the GET /session/{id}/wait 200
response description only mentioned two outcomes (condition met, or
timeout_s elapsed). Note the third: a server drain/shutdown begun
while the wait is blocked also returns 200 early with the current
(possibly unmet) snapshot, distinguished the same way as a timeout —
by checking state/goal against what was asked for. Extend handleWait's
doc comment to say the same about its s.closing branch.
TestWaitUnblocksOnPromptIdle covers the plain-prompt symmetry TestWaitUnblocksOnGoalCompletion/TestWaitUnblocksOnGoalCleared left untested: a wait registered while an ordinary prompt_async (no goal) is in flight must be woken by the durable-event fanout when the prompt completes, not just when a goal terminates. Follows the existing gateProvider + waitForWaiterCount pattern; an elapsed-time bound is required because waitSnapshot re-derives state fresh regardless of wake source, so a broken notify can still eventually report the correct (by-then genuinely idle) state via the 30s timeout_s fallback — state alone can't distinguish 'woken by notify' from 'survived to the deadline'. Mutation check: temporarily neutering runPrompt's terminal session.status=idle emitDurable call did NOT reliably fail this test, because syncMessages also journals a message event for the completing turn just before st.running flips to false, and that event's notify usually wins the race to re-check state after the flip already landed (waitSnapshot re-derives fresh state from every event, by design, not just the terminal one). Disabling notifyWaitersLocked's fan-out entirely (the actual mechanism under test, not one call site) is what reliably reproduces red: with a 3s timeout_s and a 1s elapsed bound, the test failed deterministically (wait took the full 3.00s, resolved only via the timeout fallback). Restored, the real test (timeout_s=30, elapsed bound 5s) passes near-instantly. gofmt, go vet ./..., and go test -race ./... all clean.
📦 Previous Review (superseded by newer review)Claude finished @andybons's task in 4m 9s —— View job Review: composite session state + long-poll wait endpointRead
VerdictStrong PR. The prior two rounds' notes are all now addressed: the cold-session existence check ( Concurrency / missed-wakeup (the part that matters here):
Testing (per AGENTS.md): exemplary. Timer path is inside a Finding (non-blocking)
OpenAPIThorough — named Startup-speed checkNo init-time network/disk/subprocess work. The only wait-path disk touch is |
…rflow
parseWaitTimeout applied the 300s cap AFTER multiplying seconds by
time.Second: for timeout_s >~ 9.2e9 (e.g. 10000000000), n*time.Second
overflows int64 and wraps to a negative Duration, which is < the
maxWaitTimeout comparison and so slips past the cap uncaught.
time.NewTimer(negative) fires immediately, giving the 'generous
client asking for the moon' an effectively-zero wait instead of the
documented 300s-capped bounded wait — the exact opposite of the
contract.
Red-first: added TestParseWaitTimeout, a table test for the
previously-untested pure function, including a
timeout_s=10000000000 row. Confirmed it failed against the old code:
parseWaitTimeout("10000000000") = -2346317h47m53.709551616s, want 5m0s
parseWaitTimeout("10000000000") = -2346317h47m53.709551616s, want non-negative
Fix caps the integer seconds against maxWaitTimeout/time.Second BEFORE
the multiply (matches the reviewer's suggested shape on PR #50's
wait.go review thread, verified independently), so the Duration
conversion can never overflow. All table rows now pass; gofmt, go vet
./..., and go test -race ./server/ are clean.
|
Claude finished @andybons's task in 2m 39s —— View job Review: composite session state + long-poll wait endpoint
Read VerdictStrong PR, and the one real bug from the prior round — the Concurrency / missed-wakeup (the part that matters most here):
Testing (per AGENTS.md): exemplary. Timer path is inside a OpenAPI: complete — named Startup-speed checkNo init-time network/disk/subprocess work introduced. The only wait-path disk touch is All three prior rounds' notes are now resolved (cold-session check |
Summary
Closes the orchestration ambiguity that caused real monitor false-fires: sessions now surface a composite
state(idle | busy | goal-running— goal-active wins over momentary worker idle; oldstatuskept for compat), andGET /session/{id}/wait?until=idle|goal-done&timeout_s=Nlong-polls on the existing subscriber infrastructure — immediate return when the condition pre-holds, terminal kind (achieved/cleared) in the response, context-clean, no waiter leak on disconnect. OpenAPI updated with named schemas.Design lineage:
docker wait/kubectl wait --for=condition— sessions as orchestrated resources.Fifth attempt at this feature; the prior four deaths yielded the goal-loop hardening (#38, #42, #43, #49) that this run certified by completing on it. Box session
ses_01kx3zjvx1e6nr6vh14gt4gnx9, box-pushed, origin-verified.🤖 Generated with Claude Code