fix(recovery): do not reattach cancelling runs - #7096
allenliang2022 wants to merge 2 commits into
Conversation
|
| Filename | Overview |
|---|---|
| api/background_process.py | Centralizes session run lookup, excludes cancelling runs from recovery, and retains fresh cancellations for lifecycle-busy checks. |
| api/config.py | Adds shared predicates for run attachability and cancellation staleness. |
| api/routes.py | Reuses the shared stale-cancellation predicate for persisted stream-state cleanup. |
| api/session_ops.py | Prevents hidden-tab status polling from reporting cancelling streams as live UI work. |
| docs/rfcs/webui-run-state-consistency-contract.md | Documents the distinction between worker lifecycle, browser attachability, and bounded stale cancellation reclamation. |
| tests/test_cancelling_run_not_attachable.py | Covers attachability, lifecycle-busy behavior, staleness anchors, live-channel protection, and stale-row cleanup. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[ACTIVE_RUNS entry] --> B{phase is cancelling?}
B -- No --> C[Attachable and lifecycle-busy]
B -- Yes --> D[Not browser-attachable]
D --> E{Within unwind window or live STREAMS channel?}
E -- Yes --> F[Remain lifecycle-busy]
E -- No --> G[Remove ACTIVE_RUNS row and stream owner]
Reviews (2): Last reviewed commit: "docs(rfc): document cancellation attach/..." | Re-trigger Greptile
| return ch, q | ||
|
|
||
|
|
||
| # Bounded window a cancelling worker may stay lifecycle-busy before an entry | ||
| # with no live SSE channel is treated as an orphan. Matches the unwind ceiling | ||
| # used by the chat-start successor guard in ``api.routes``. | ||
| _ACTIVE_RUN_CANCEL_UNWIND_SECONDS = 180.0 |
There was a problem hiding this comment.
Document cancellation lifecycle semantics
This introduces a bounded cancellation-unwind window and stale-run reclamation alongside new attachability semantics, but the project documentation is not updated to describe the changed runtime contract, increasing the maintenance cost of future recovery and cancellation changes.
Context Used: AGENTS.md (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
ACTIVE_RUNS tracks worker lifecycle, which is deliberately broader than "a turn a browser may attach to". cancel_stream() keeps the row as phase="cancelling" while the worker unwinds so a successor turn cannot start on top of it, but the client has already reached a terminal state for that stream: its run journal ends in a terminal event. The recovery lookups treated every same-session ACTIVE_RUNS row as attachable. An idle session holding a cancelling row therefore received a recovered server_turn_started on every /api/session/stream subscription: the client attached, replayed the terminal event, tore the renderer down, resubscribed, and the server replayed the same frame again. The result is an endless attach/replay loop that rebuilds the transcript repeatedly. Separate the two meanings instead of narrowing one call site: - api/config.py gains active_run_is_attachable() and active_run_cancel_is_stale() as the shared predicates. - active_stream_id_for_session() (browser recovery) returns attachable rows only; _session_has_active_turn() (busy check) keeps counting a fresh cancellation so a successor cannot overlap the unwinding worker. - _live_active_stream_id() applies the same rule to the hidden-tab status poller, on both the STREAMS and ACTIVE_RUNS paths. - routes._cancelled_run_is_stale() now delegates to the shared predicate rather than keeping a parallel copy of the staleness rule. - A cancelling row past a bounded unwind window with no live STREAMS channel is reclaimed from ACTIVE_RUNS and its stream owner released, so a wedged worker cannot suppress background wakeups forever. Age alone does not reclaim a row that still owns a live channel. Staleness anchors on cancelled_at, falling back to started_at, so a long-running turn cancelled moments ago is never treated as an orphan. tests/test_cancelling_run_not_attachable.py covers both directions of each rule. The six behavioral tests fail on the unpatched tree and pass with the fix; two targeted mutations (forcing the attachability predicate true, and disabling the staleness reaper) each turn the suite red.
Records the runtime contract introduced by the recovery fix in the WebUI run-state consistency RFC, so the distinction is discoverable instead of living only in code comments. - Adds ACTIVE_RUNS to the State Layers table as the worker-lifecycle registry, explicitly not the set of runs a browser may attach to. - Adds invariant 9: lifecycle-busy is not client-attachable. Cancellation splits the two meanings, recovery paths must exclude cancelling rows, and admission checks must keep counting them. - Documents the bounded cancellation-unwind window: reclamation needs both age and the absence of a live STREAMS channel, and staleness is anchored on the cancellation timestamp. - Extends the review checklist with the admission-vs-attachment question and the evidence required when changing a reclamation window.
e93ec7e to
27e7683
Compare
|
Good catch — documented. I added the contract to
Also rebased onto the current |
….6 max reasoning (#7083) + test order-independence (#7101) (#7102) * fix(tests): make mtime_invalidation and glm_5_3 tests order-independent (#7100) * fix: expose max reasoning for GPT-5.6 models * fix(recovery): do not reattach cancelling runs ACTIVE_RUNS tracks worker lifecycle, which is deliberately broader than "a turn a browser may attach to". cancel_stream() keeps the row as phase="cancelling" while the worker unwinds so a successor turn cannot start on top of it, but the client has already reached a terminal state for that stream: its run journal ends in a terminal event. The recovery lookups treated every same-session ACTIVE_RUNS row as attachable. An idle session holding a cancelling row therefore received a recovered server_turn_started on every /api/session/stream subscription: the client attached, replayed the terminal event, tore the renderer down, resubscribed, and the server replayed the same frame again. The result is an endless attach/replay loop that rebuilds the transcript repeatedly. Separate the two meanings instead of narrowing one call site: - api/config.py gains active_run_is_attachable() and active_run_cancel_is_stale() as the shared predicates. - active_stream_id_for_session() (browser recovery) returns attachable rows only; _session_has_active_turn() (busy check) keeps counting a fresh cancellation so a successor cannot overlap the unwinding worker. - _live_active_stream_id() applies the same rule to the hidden-tab status poller, on both the STREAMS and ACTIVE_RUNS paths. - routes._cancelled_run_is_stale() now delegates to the shared predicate rather than keeping a parallel copy of the staleness rule. - A cancelling row past a bounded unwind window with no live STREAMS channel is reclaimed from ACTIVE_RUNS and its stream owner released, so a wedged worker cannot suppress background wakeups forever. Age alone does not reclaim a row that still owns a live channel. Staleness anchors on cancelled_at, falling back to started_at, so a long-running turn cancelled moments ago is never treated as an orphan. tests/test_cancelling_run_not_attachable.py covers both directions of each rule. The six behavioral tests fail on the unpatched tree and pass with the fix; two targeted mutations (forcing the attachability predicate true, and disabling the staleness reaper) each turn the suite red. * docs(rfc): document cancellation attach/admission split Records the runtime contract introduced by the recovery fix in the WebUI run-state consistency RFC, so the distinction is discoverable instead of living only in code comments. - Adds ACTIVE_RUNS to the State Layers table as the worker-lifecycle registry, explicitly not the set of runs a browser may attach to. - Adds invariant 9: lifecycle-busy is not client-attachable. Cancellation splits the two meanings, recovery paths must exclude cancelling rows, and admission checks must keep counting them. - Documents the bounded cancellation-unwind window: reclamation needs both age and the absence of a live STREAMS channel, and staleness is anchored on the cancellation timestamp. - Extends the review checklist with the admission-vs-attachment question and the evidence required when changing a reclamation window. * docs(changelog): note #7096 cancelling-run reattach, #7083 GPT-5.6 max reasoning, #7101 test order-independence --------- Co-authored-by: webtecnica <webtecnica@gmail.com> Co-authored-by: Abdulrahman Elkenany <boudy.elkenany123@gmail.com> Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com> Co-authored-by: n <a@n>
|
Shipped in experimental release exp-v0.52.235. Recovery paths now exclude runs whose phase is |
… + GPT-5.6 max reasoning (nesquena#7083) + test order-independence (nesquena#7101) (nesquena#7102) * fix(tests): make mtime_invalidation and glm_5_3 tests order-independent (nesquena#7100) * fix: expose max reasoning for GPT-5.6 models * fix(recovery): do not reattach cancelling runs ACTIVE_RUNS tracks worker lifecycle, which is deliberately broader than "a turn a browser may attach to". cancel_stream() keeps the row as phase="cancelling" while the worker unwinds so a successor turn cannot start on top of it, but the client has already reached a terminal state for that stream: its run journal ends in a terminal event. The recovery lookups treated every same-session ACTIVE_RUNS row as attachable. An idle session holding a cancelling row therefore received a recovered server_turn_started on every /api/session/stream subscription: the client attached, replayed the terminal event, tore the renderer down, resubscribed, and the server replayed the same frame again. The result is an endless attach/replay loop that rebuilds the transcript repeatedly. Separate the two meanings instead of narrowing one call site: - api/config.py gains active_run_is_attachable() and active_run_cancel_is_stale() as the shared predicates. - active_stream_id_for_session() (browser recovery) returns attachable rows only; _session_has_active_turn() (busy check) keeps counting a fresh cancellation so a successor cannot overlap the unwinding worker. - _live_active_stream_id() applies the same rule to the hidden-tab status poller, on both the STREAMS and ACTIVE_RUNS paths. - routes._cancelled_run_is_stale() now delegates to the shared predicate rather than keeping a parallel copy of the staleness rule. - A cancelling row past a bounded unwind window with no live STREAMS channel is reclaimed from ACTIVE_RUNS and its stream owner released, so a wedged worker cannot suppress background wakeups forever. Age alone does not reclaim a row that still owns a live channel. Staleness anchors on cancelled_at, falling back to started_at, so a long-running turn cancelled moments ago is never treated as an orphan. tests/test_cancelling_run_not_attachable.py covers both directions of each rule. The six behavioral tests fail on the unpatched tree and pass with the fix; two targeted mutations (forcing the attachability predicate true, and disabling the staleness reaper) each turn the suite red. * docs(rfc): document cancellation attach/admission split Records the runtime contract introduced by the recovery fix in the WebUI run-state consistency RFC, so the distinction is discoverable instead of living only in code comments. - Adds ACTIVE_RUNS to the State Layers table as the worker-lifecycle registry, explicitly not the set of runs a browser may attach to. - Adds invariant 9: lifecycle-busy is not client-attachable. Cancellation splits the two meanings, recovery paths must exclude cancelling rows, and admission checks must keep counting them. - Documents the bounded cancellation-unwind window: reclamation needs both age and the absence of a live STREAMS channel, and staleness is anchored on the cancellation timestamp. - Extends the review checklist with the admission-vs-attachment question and the evidence required when changing a reclamation window. * docs(changelog): note nesquena#7096 cancelling-run reattach, nesquena#7083 GPT-5.6 max reasoning, nesquena#7101 test order-independence --------- Co-authored-by: webtecnica <webtecnica@gmail.com> Co-authored-by: Abdulrahman Elkenany <boudy.elkenany123@gmail.com> Co-authored-by: allenliang2022 <allenliang2022@users.noreply.github.com> Co-authored-by: n <a@n>
Summary
ACTIVE_RUNStracks worker lifecycle, which is deliberately broader than "a turn a browser may attach to".cancel_stream()keeps the row asphase="cancelling"while the worker unwinds, so a successor turn cannot start on top of it. But the client has already reached a terminal state for that stream — its run journal ends in a terminal event.The recovery lookups treated every same-session
ACTIVE_RUNSrow as attachable. An idle session holding a cancelling row therefore received a recoveredserver_turn_startedon every/api/session/streamsubscription:server_turn_startedfor the cancelled streamThe user-visible symptom is a transcript that rebuilds and jumps repeatedly on an idle session, with a steady trickle of replay requests. Because the row never leaves
ACTIVE_RUNSon its own, the loop persists indefinitely.The fix
The two meanings are separated at a shared chokepoint rather than by narrowing one call site:
api/config.pygainsactive_run_is_attachable()andactive_run_cancel_is_stale()as the shared predicates.active_stream_id_for_session()(browser recovery) returns attachable rows only._session_has_active_turn()(busy check) still counts a fresh cancellation, so a successor cannot overlap the unwinding worker. This is the direction that must not change._live_active_stream_id()applies the same rule to the hidden-tab status poller, on both theSTREAMSandACTIVE_RUNSpaths.routes._cancelled_run_is_stale()now delegates to the shared predicate instead of keeping a parallel copy of the staleness rule.STREAMSchannel is reclaimed fromACTIVE_RUNSand its stream owner released, so a wedged worker cannot suppress background wakeups forever. Age alone does not reclaim a row that still owns a live channel.Staleness anchors on
cancelled_at, falling back tostarted_at, so a long-running turn cancelled moments ago is never mistaken for an orphan.State layer being mutated
ACTIVE_RUNS(worker-lifecycle registry) and its companion stream-owner map. The invariant being restored: a row is lifecycle-busy and client-attachable independently; cancellation ends the second while the first is still unwinding.Siblings found and fixed
The same "any
ACTIVE_RUNSrow for this session is live UI work" assumption appeared in four places — session SSE recovery, the busy check, the hidden-tab status poller, and a duplicated staleness rule inroutes.py. All four now route through the shared predicates. The busy check is intentionally the one caller that keeps counting cancelling rows.Testing
tests/test_cancelling_run_not_attachable.py— 8 tests covering both directions of each rule, including a running-turn control, a live-channel reverse control, thecancelled_atvsstarted_atanchor, and predicate edge cases (blank/whitespace phase, non-dict entries stay attachable).Test fails before, passes after:
Mutation checks (each turns the suite red, so the tests genuinely bite):
active_run_is_attachable()to always returnTruestale_cancelnever true)Neighboring suites — cancel / recovery / wakeup / hidden-tab / restore families:
covering
test_cancel_interrupt,test_cancel_stream_owner_guard,test_cancelled_turn_status,test_issue6623_cancel_owner_race,test_evict_session_agent_active_run_guard,test_background_process_restart_recovery,test_background_process_wakeup_format,test_bg_task_complete_wakeup,test_bg_task_complete_loadsession_stream_restart,test_5428_stale_client_recovery,test_hidden_tab_server_initiated_turn,test_hard_refresh_session_restore.Lint:
scripts/ruff_lint.py --diff origin/master→no new violations on added/modified lines. OK.Relationship to #6623 / #6636
#6636 fixed the cancel owner race — stuck sessions, cancel write-back clobbering a successor, and cancel-worker timeout reclamation. This is a different layer: the row remains legitimately present in the lifecycle registry, and the defect is that the recovery/attach path reads that registry as "renderable live work." Both are needed; neither subsumes the other.
Not verified