fix(chat): stop the session a linked slot's turn actually runs on - #2594
Conversation
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe candidate concerns the SEL audit Falsifying it:
Furthermore this is a deliberate, coherent design split: the functional cancel ( No self-originated finding grounds to the bar either — the core routing ( No findings. [OPUS-REVIEWED] 2b39d17 |
Design Review (Fable 5, fork) — ✅ PASSAdvisory design-level review of All key claims verified against the base tree: Design-Verdict: PASS Root-cause fix at the documented seam, with a turn-owned identity that correctly survives mid-turn rebinds the naive re-resolve would mis-target. [DESIGN-REVIEWED] 2b39d17 |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
030971e to
c8101eb
Compare
c8101eb to
e9833d0
Compare
e9833d0 to
2f5a96e
Compare
2f5a96e to
51c46b0
Compare
f0704dc to
eecb4f5
Compare
|
Both findings taken. The second one is correct and broader than stated, so the fix is broader too — Verified before acting. Why not the suggested fix. Retaining the first soft stop's key for the escalation is insufficient in two ways: a rebind can land before the first Stop, leaving a single press wrong, and it does nothing for What landed instead — the running turn owns a stable identity.
Each cancel route derives one target and passes the same value to authorization and to Tests — fail-before 13 failed / 22 passed against
Focused suites 734 passed / 1 skipped. The wider One adjacent thing I checked and am not touching here: |
eecb4f5 to
80018c9
Compare
|
The shard-4 red on Exact node: It is a source-inspection test — it reads Found by reproducing the CI shape rather than guessing. Local The fix keeps both invariants rather than trading one for the other: the expiry goes back to its pinned position, and the identity is installed immediately after it. That await is the only thing between the local-command boundary and the Nothing else changed. Verification: focused suites (now including |
Dashboard Stop on a channel-linked slot addressed `_history_key_for(name)`
instead of the session its turn runs on, so `stop_turn` named a session no
running turn owns: the cancel was a no-op while the handler still inserted
the stop card and answered `{"ok": true}`.
Resolving that at cancel time is not enough, because the field it resolves
from is mutable on a live slot: `inject_cron_result_to_dashboard` binds an
already-running slot to `cron:<id>` with no `running` gate. A cancel that
re-derives the key then addresses wherever the slot routes the NEXT turn,
which after a mid-turn rebind is not the turn being stopped.
Give the running turn its own identity instead. `_run_chat` already
captures one session key and uses it to acquire, audit and release for the
whole turn; it now publishes it as `_ChatSlot._active_turn_session_key`.
Runtime only — never persisted, never serialized, empty after a restart —
and `_run_chat` is its sole lifecycle owner. Installed at the boundary
below every local-command return, not on `_prompt_depth == 0`, because
`/prompts get` re-enters at depth 1 and it is that inner call which reaches
the turn machinery. Retired inside the same `finally` that releases the
session: the reset there can be cancelled and CancelledError derives from
BaseException, so a later clear is skipped, and it must also land before
`_start_next_queued_turn` installs a successor's key.
Each cancel route derives one target and hands the same value to both
authorization and `stop_turn`, so the two cannot disagree across the
request-body await in /interrupt or across two presses of Stop. The SEL
records stay on the slot-derived key, which identifies the tab the operator
pressed; `api_chat_slot_continue` already splits session-vs-audit that way.
Addressing the real session widens what a cancel can reach, so both routes
take the App Kit 5.2 ownership guard through one `_app_cancel_denied`
helper. It tests the key about to be cancelled rather than re-reading the
slot: re-reading would also DENY an app its own running turn once mutable
routing moved. Denials and a genuinely missing slot return one
byte-identical 404 so the response cannot enumerate foreign slot names.
80018c9 to
2b39d17
Compare
|
Rebased onto current What main already landed. What is still only here.
Two semantic collisions the test suite caught, which a clean textual rebase would have shipped silently:
After that, 57 passed across Happy to retitle/re-body this around items 1–3 if you'd prefer it framed as a follow-up to what already landed. |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSAdvisory premise-level review of First-Principles-Verdict: CONCERNS The titled fix already sits on main (f9db38a, #3074); the real delta is an auth guard, a turn-identity snapshot, and an undeclared audit-key reversal. What this change shipsIntent: make Stop on a channel-linked tab actually cancel the running turn — framed as a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 2b39d17 |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: small-fix (7 files, fork). Criteria: no conflict, no requested changes, no security surface, AI reviewers green. Category: stop the session a linked slot's turn runs on — ensures correct session lifecycle teardown for linked chat slots.
…rodotdev#2594) Dashboard Stop on a channel-linked slot addressed `_history_key_for(name)` instead of the session its turn runs on, so `stop_turn` named a session no running turn owns: the cancel was a no-op while the handler still inserted the stop card and answered `{"ok": true}`. Resolving that at cancel time is not enough, because the field it resolves from is mutable on a live slot: `inject_cron_result_to_dashboard` binds an already-running slot to `cron:<id>` with no `running` gate. A cancel that re-derives the key then addresses wherever the slot routes the NEXT turn, which after a mid-turn rebind is not the turn being stopped. Give the running turn its own identity instead. `_run_chat` already captures one session key and uses it to acquire, audit and release for the whole turn; it now publishes it as `_ChatSlot._active_turn_session_key`. Runtime only — never persisted, never serialized, empty after a restart — and `_run_chat` is its sole lifecycle owner. Installed at the boundary below every local-command return, not on `_prompt_depth == 0`, because `/prompts get` re-enters at depth 1 and it is that inner call which reaches the turn machinery. Retired inside the same `finally` that releases the session: the reset there can be cancelled and CancelledError derives from BaseException, so a later clear is skipped, and it must also land before `_start_next_queued_turn` installs a successor's key. Each cancel route derives one target and hands the same value to both authorization and `stop_turn`, so the two cannot disagree across the request-body await in /interrupt or across two presses of Stop. The SEL records stay on the slot-derived key, which identifies the tab the operator pressed; `api_chat_slot_continue` already splits session-vs-audit that way. Addressing the real session widens what a cancel can reach, so both routes take the App Kit 5.2 ownership guard through one `_app_cancel_denied` helper. It tests the key about to be cancelled rather than re-reading the slot: re-reading would also DENY an app its own running turn once mutable routing moved. Denials and a genuinely missing slot return one byte-identical 404 so the response cannot enumerate foreign slot names.
Problem / Motivation
Pressing Stop on a channel-linked chat slot (a Slack/Discord-linked session)
reports success and cancels nothing.
A channel-linked slot's turns run under its
linked_session_key(slack:<ts>),which is what
chat_utils.effective_session_keyresolves. All three stop paths inchat_handlers.pyinstead addressed the session as_history_key_for(slot)—dashboard:<slot>— a key no running session owns:stop_turnapi_chat_slot_stop_history_key_for(name)api_chat_slot_stop_history_key_for(name)api_chat_slot_interrupt_history_key_for(name)stop_turntherefore had nothing to cancel, while the handler still inserted thestop_eventcard and returned{"ok": true}.Why it matters
Stop is the control a user reaches for when something is going wrong, and this is
a silent failure: the UI shows a clean stop, so the user stops watching. The
blast radius is whatever the turn does next — file writes, pushes, tool calls. A
stop that errors is recoverable; one that lies is not.
The escalation path is affected too, which removes the natural fallback: a user
whose first press did nothing presses again, and the hard kill mis-addresses the
same way.
What changed (motivation → approach → change)
Root cause. The slot's TRANSCRIPT key was used where its SESSION key was
required. Those coincide for an ordinary dashboard slot and diverge for a linked
one, so the bug is invisible until a channel link exists.
Change. The three
stop_turncalls now resolve the session througheffective_session_key(slot). This is not a new convention — it is the onealready written down:
effective_session_key's own docstring: "Use this anywhere a slot's SESSION isaddressed — resolving the session its turns run on, mirroring its links. For the
slot's TRANSCRIPT use
slot_history_key."api_chat_slot_continue, in this same module, already resolves its session witheffective_session_key(slot).The SEL records deliberately keep the slot-derived key. They answer which tab
the operator pressed, not which session was cancelled, and
api_chat_slot_continuealready splits the two exactly this way(
effective_session_keyfor the session,_history_key_for(name)for the audit).TestAuditKeepsTheSlotIdentitypins that split so a later cleanup does not"tidy" the audit key along with the session key.
Unlinked slots are unaffected by construction: with no
linked_session_key,effective_session_keyfalls back to_history_key_for(slot.key)— byte for bytethe previous value.
Authorization: two conditions, because ownership is not enough
/stopand/interruptnever applied the App Kit §5.2 ownership check thatapi_chatandapi_chat_slot_continueapply. That gap is onmaintoday, notintroduced here — the ownership tests below fail against pristine
origin/mainas well. What resolving the real session key changes is exploitability: while
the handlers addressed
dashboard:<slot>, a cross-tenant cancel hit a key nosession owned and did nothing. Reverting the key resolution would reopen #2462
and leave the gap in place, so the guard ships here.
Ownership alone is insufficient, and this is the substantive finding. An app
caller must satisfy both:
effective_session_key(slot) == _history_key_for(slot.key).Condition 2 is load-bearing because an app can legitimately come to own a
channel-linked slot.
POST /api/chatpasses a caller-supplied slot namestraight into
get_or_create_slot(slot_name, app=request.get("app", "")), andthat function sets
_appand then, for a name shaped like a channel sessionstem, resolves the binding itself:
So an app that names a live Slack thread owns a slot bound to a conversation it
has no claim on, and an ownership-only guard authorizes cancelling that
channel's turn — a slot binding becomes capability escalation. A dashboard
caller has no app scope and may cancel either kind.
An earlier revision of this PR checked ownership only. The three
test_an_owned_*_channel_linked_slot_*cases fail against it.Indistinguishable 404, actually indistinguishable. Refusals and genuinely
missing slots both return
_slot_not_found()—{"error": "not found", "code": "slot_not_found"}, matchingapi_chat_slot_continue. Previously a refusal carriedcodeand a missing slotdid not, so an app could tell "not mine" from "not there" and enumerate foreign
slot names.
test_a_missing_slot_answers_exactly_like_a_refusalasserts the twobodies are equal, and every denial test compares the whole body rather than the
status.
The guard runs before every side effect. The escalation branch clears the
queue and drops pending steers, and both handlers write a stop card and claim
_stop_state, all beforestop_turn.The running turn owns a stable session identity
Resolving the session at cancel time is not safe, because the field it resolves
from changes underneath a live turn:
linked_session_keysays where the slot routes a new turn, and it ismutable on a running one —
inject_cron_result_to_dashboard(
dashboard/cron_inject.py:29) binds an already-live slot tocron:<id>anddoes not gate on
slot.running.api_cron_to_chatdoes the same on a click._run_chatcaptures its key once, at the boundary below everylocal-command return, and uses that one key to acquire, audit and release for
the whole turn.
So after a mid-turn rebind the two disagree, and a cancel that re-derives the key
stops a session this turn never ran on while the real turn keeps executing. On
origin/mainthe cancel used_history_key_for(name), which in that scenariohappens to be correct — so an earlier revision of this PR, by switching to
effective_session_key, regressed it. Fixing that is part of making this PRcorrect, not a separate concern.
_ChatSlot._active_turn_session_keypublishes the running turn's own identity:_run_chatis its sole lifecycle owner, installing it at the local-commandboundary and retiring it in the same
finallythat releases the session;Two placement details are load-bearing and each has a test:
_prompt_depth == 0./prompts getre-enters_run_chatat_prompt_depth=1, and the depth-0 invocation is a local wrapper that returnsbefore the turn machinery. Keying on depth would put the identity on the
wrapper; keying on the boundary puts it on the invocation that actually runs
the turn.
finally, not at the end of teardown. Thereset there can be cancelled and
CancelledErrorderives fromBaseException, so a clear placed after that block is skipped and the slotkeeps advertising a turn that is gone. It also has to land before
_start_next_queued_turn, which runs later in the same teardown and installsthe successor's key — compare-and-clear so only the turn that installed a key
may retire it.
Each cancel route now derives one target and hands the same value to both
authorization and
stop_turn:Authorization tests
cancel_keyrather than re-reading the slot. That is notonly a TOCTOU guard: for a turn that started on the app's own session and was
rebound mid-flight, re-reading would deny the app its own running turn,
because the routing now points somewhere it does not own.
Disposition of the automated review findings
Both came from GPT 5.6 on the fork pipeline and both were verified against the
production mutation path before being acted on.
request-body await in
/interruptis a real window.broader than stated. Its suggested fix, retaining the first soft stop's key
for the escalation, is insufficient: a rebind can land before the first
Stop, so that leaves a single press wrong, and it cannot help
/interrupteither. The turn-owned identity fixes soft stop, hard escalation and interrupt
uniformly, and needs no state carried between two requests.
The send/continue capability escalation that the ownership guard below refers to
is fixed at its own root in #2783 rather than by widening this PR.
Tests
New file
test/test_stop_addresses_linked_session.py, asserting the key thehandler hands to
stop_turn, because that is the entire defect — the card andstate bookkeeping around it were already correct.
26 tests in
test/test_stop_addresses_linked_session.py, in five groups, plus 9 lifecycle tests in a new file. Thefailure classes are deliberately distinguished, because they have different
provenance:
Class 1 — the #2462 session-key defect (5; fail on
origin/main, pass fromthis PR's first head onward):
test_soft_stop_on_a_linked_slot_cancels_the_channel_sessiontest_hard_kill_on_a_linked_slot_cancels_the_channel_sessiontest_interrupt_on_a_linked_slot_cancels_the_channel_sessiontest_dashboard_user_is_not_treated_as_an_apptest_sel_record_is_keyed_on_the_slot_not_the_link— also pins theaudit/session split going forward.
Class 2 — the authorization gap (fail on
origin/maintoo, which is theevidence that it pre-dates this PR):
test_app_token_cannot_stop_a_dashboard_owned_linked_slottest_app_a_cannot_stop_app_bs_slottest_the_hard_kill_path_cannot_bypass_the_guardtest_app_token_cannot_interrupt_a_foreign_slotClass 3 — linked-session escalation and the existence oracle (fail against
the ownership-only revision of this PR):
test_an_owned_but_channel_linked_slot_is_still_refusedtest_an_owned_channel_linked_slot_is_refused_on_interrupt_tootest_an_owned_channel_linked_slot_is_refused_on_hard_kill_tootest_a_missing_slot_answers_exactly_like_a_refusalEach asserts the 404 body, that
stop_turnwas never awaited, and that no stopcard,
_stop_statechange, queue clear or steer drop occurred.Class 4 — the running turn owns the target (
TestTheRunningTurnOwnsTheTarget,7; fail against the previous head
f0704dc6d). Each starts a turn on onesession and rebinds the slot underneath it:
test_soft_stop_after_a_mid_turn_rebind_stops_the_running_turntest_the_hard_escalation_follows_the_same_turntest_interrupt_after_a_mid_turn_rebind_stops_the_running_turntest_a_turn_that_started_on_the_link_is_still_stopped_there— the Stop on a channel-linked session addresses the wrong session key and cancels nothing #2462 fixrestated on the stronger rule.
test_an_app_may_still_stop_its_own_turn_after_a_rebind— mutable routingmust not lock an app out of a turn it legitimately started.
test_an_app_is_still_refused_a_turn_running_on_a_foreign_session— and thesecurity boundary is unchanged.
test_an_idle_slot_falls_back_to_its_routingLifecycle — new file
test/test_active_turn_session_key.py(9), driving thereal
_run_chatthrough the harnesstest_turn_teardown_release.pyuses:identity published for a plain turn, for a linked turn, and unmoved by a
mid-turn rebind; retired after normal completion, after a provider error, after
a cancellation delivered into the teardown, and when the session was never
acquired; the clear observed to land before
_start_next_queued_turnand toleave a successor's identity intact; and the
/prompts getre-entry proving thedepth-1 invocation owns the key while its depth-0 wrapper owns nothing.
Preservation guards (pass throughout) — an unlinked slot's stop, hard kill
and interrupt still address
dashboard:test-slot; a dashboard user is nottreated as an app; and the owning app may still cancel its own unlinked slot,
so the guard costs apps nothing they legitimately had.
Manual verification
test_stop_addresses_linked_session.py,test_stop_handler_idempotent.py,test_chat_slot_interrupt.py,test_chat_slot_end_wait.py,test_dashboard_chat.py,test_dashboard_chat_pins.py,test_dashboard_chat_rewind.py,test_error_code_contract.py,test_active_turn_session_key.py,test_turn_teardown_release.py→734 passed, 1 skipped, 0 failed (Windows 11 26200 / py3.10.6).
f0704dc6d; 35 passed after.pytest test/ -k "chat or slot or stop or interrupt or turn or cron or session or app"→ 11012 passed, 212 skipped, 4 xfailed, 21 failed. All 21 are pre-existing
on this machine and unrelated to the diff — symlink-creation tests hitting
WinError 1314without elevation, two CloudFormation-template assertions, twoapp-backend env-allowlist tests and a cookie-expiry parse test — and every one
reproduces with the three changed source files reverted to the previous head.
test_stop_kill_cancel.pyfails 8 on the same machine(
asyncio.start_unix_server, POSIX-onlyosattributes), likewisepre-existing and platform-only.
test_stop_handler_idempotent.pygained an explicit absentapp header. A bare
MagicMockanswers.get("app")with a truthy mock, whichthe guard correctly reads as an app token; those cases are dashboard-user
presses, so the fixture was modelling the request wrong. No assertion weakened.
flake8,isort,mypy src/kiro_crew/dashboard/chat_handlers.py,git diff --checkandBRAND_BASE_REF=origin/main scripts/check_brand_name.pyall clean.
docs/system-specs/modules/session.mddocumentsstop_turn(key, ...)as the shared orchestration layer without prescribing which key a callerpasses, and this restores the contract
effective_session_key's docstringalready states.
error-code-baseline.jsonregenerated on the current base. This branch wasrebased onto
24a6f8ee5after #2300 landed; the baseline was regenerated thererather than carried across from the old base, so these numbers are against
current
main:_totals.missing_codedashboard/chat_handlers.pymissing_code_compliantThe
missing_codedrop is this PR: the two 404s in these handlers now carry acode, andtest_baseline_is_not_stalerequires an improved count to berecorded. Regenerated with the documented
python test/test_error_code_contract.py --updaterather than hand-edited._compliantneeds a word, because +15 looks larger than a two-response change.Regenerating on a pristine
mainalready yields 720, so 14 of that delta ispre-existing drift in the committed file and only +1 is attributable to this
PR.
_compliantis also the one field the contract tests do not assert — theratchet is
missing_codeper file,_totalsmatching the per-file map, and thestaleness check.
Scope
The three
stop_turncall sites plus the ownership guard the cancel routes weremissing. Not touched: the SEL
session_keyarguments (see above), the Slack-side stop paths in
slack/interactions.py(theyalready receive a real session key), and
spec_builder's own stop, which is adifferent surface with its own slot model.
Note for scheduling: #2435 is open against the same file. It is not an overlap
— that PR refuses channel-linked targets outright rather than reaching this path,
and its description says this was left deliberately for its own change and its own
verification. Whichever lands second needs a trivial rebase.
Related Issues
Fixes #2462
Checklist