feat(session-control): add session_close to archive a peer session - #7160
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound reuse of the ✕ path, but the enforced scope is any workspace peer while every description says "a session you created." Watch
Suggestions
[DESIGN-REVIEWED] 1747253 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All the load-bearing claims check out: First-Principles-Verdict: CONCERNS The point-of-no-return re-check — including a new flag that skips part of What this change shipsIntent: let an agent dismiss peer sessions it opened, instead of leaving dead tabs for the human. ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 1747253 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
|
Dispositions for head GPT 5.6 (BLOCKING) - close authorization becomes stale before archival --> FIXED. Design Review / Opus 4.8 / First Principles (spec not updated in same commit) --> FIXED. First Principles residual - one sibling copy of the close sequence in the bulk stale-archive pass --> ACCEPTED-AND-DEFERRED. |
UX Review (Fable 5) — ⏭️ skippedRevision |
|
Dispositions for head BLOCKING 1 -- reauthorization can synchronously reload config on the event loop (no-blocking-call-on-event-loop) --> FIXED. BLOCKING 2 -- reauthorization does not verify the original slot identity (crash/data-loss) --> FIXED. Tests added: |
7214b1c to
f891968
Compare
f891968 to
8dec00f
Compare
|
Disposition for head BLOCKING -- await reopens the retired-loop race before removal (crash/data-loss) --> FIXED. |
d75b2c5 to
2bfb944
Compare
2bfb944 to
36d8f85
Compare
Mirror of session_create on the session-control MCP surface: an agent can close (archive) a peer session it opened, the tool-side equivalent of the tab close button. Reuses the dashboard's own close path via a new shared close_slot + typed SlotCloseError; close_target re-authorizes at the point of no return (async off-loop config prewarm, then authorize_target, with a slot-identity comparison) so a target that gains a channel mirror/link or is re-minted during the close is refused rather than archived. Non-destructive: the conversation is archived to history and can be reopened. Adds POST /api/session-control/close, SESSION_CLOSE_SCHEMA, the session_close tool + dispatch, channel-agent containment, a 500 branch in the route refusal map, spec update, and tests.
36d8f85 to
1747253
Compare
buluoray
left a comment
There was a problem hiding this comment.
Reviewed at head 17472535e. 0 blocking, 5 yellow, 6 blue.
The design is right and the extraction is honest. Almost every finding below is prose that no longer matches the code, plus one real coverage gap on the PR's own central claim. I verified behaviour against source rather than against the description; what I checked and what I could not check is listed at the end.
Y1 — the line this whole PR is about has no test pinning its position
The pre_pop_check() call site inside close_slot must sit after the nudge-retirement and app-close-hook awaits and immediately before the pop. That positioning is the entire outcome of four blocking review rounds — round 3's awaited form was blocked because an await reopened the retired-loop window, and round 4's because a late retirement's await let a mirror land after re-authorization.
I moved pre_pop_check() to before the app-close-hook await — reopening exactly that window — and all 170 relevant tests stayed green.
The cause is visible in the test itself. test_close_reauthorizes_at_the_point_of_no_return monkeypatches close_slot away entirely and calls pre_pop_check() from its own fake:
async def _fake_close(_state, _slot, _name, *, pre_pop_check=None):
state.sessions.set_mirror_link(slot_history_key(target), "C123", "T1")
assert pre_pop_check is not None, "close_target must arm a pre-pop re-check"
pre_pop_check() # re-runs authorize_target -> raises for the new mirrorSo it proves the check is armed and can abort — never that the real close_slot invokes it at the point where staleness could have been introduced. That falsifies the PR body's claim of "the prewarm-before-gate / gate-adjacent-to-act adjacency contract (mutation-guarded, mirroring the stop tests)": the arming is guarded, the adjacency is not.
The shipped position is correct. What is missing is the guard against a future refactor moving it. A test driving the real close_slot with an app close hook that acquires a channel mirror during its await would pin it — that mutation is the one that currently passes silently.
Y2 — the commit message describes the design that was rejected
close_targetre-authorizes at the point of no return (async off-loop config prewarm, then authorize_target, with a slot-identity comparison)
The shipped _reassert_closeable is a plain def with skip_enabled_check=True and no prewarm. The async-prewarm form is the round-3 design that was blocked, and a comment in this same commit says why: "an async prewarm-then-check would put an await back before the pop and reopen the very window this closes."
After the squash this text is permanent on main, advertising the unsafe shape as the implementation.
Y3 — the .py and the .md contradict each other inside one commit
src/kiro_crew/dashboard/session_control.py module docstring says authorization is checked "for the three operations that take a target — stop, close and read". Four call it: send_to_target passes operation="send" (session_control.py:1396). The docs/system-specs/modules/session-control.md shipped in this same commit correctly says "(stop, send, close, read)", so the .py is the wrong one.
It also undercuts its own adjacent safety claim — "a guard cannot be present on one verb and missing on another" — by hiding that send is guarded.
Y4 — the conductor invariant comment claims exhaustiveness it no longer has
The comment above _CONDUCTOR_DASHBOARD_GRANTS in src/kiro_crew/agent.py opens "Every verb this server exposes, against that rule:" and enumerates eight. session_close is now a ninth and is absent. It belongs as WITHHELD next to session_stop, whose entry reasons from exactly the property close has more of ("ends another session's in-flight turn and DISCARDS its work").
No security regression — the grant tuple was correctly left unchanged, so session_close is mounted-but-approval-gated. The gap is documentation only, but it is the enumeration a future reader trusts to be complete.
Y5 — enforced scope is wider than every description (seconding Design Review)
The commit message says "a peer session it opened"; the tool description says "a peer session you created and are done with". authorize_target enforces no creator containment. Any persistent, non-app, non-channel, same-workspace peer is reachable — including the tab the human is actively typing in.
I do not read this as blocking, and I would not add creator containment: session_stop already reaches the identical set and discards the identical turn, and the close archives recoverably. The right resolution is for the spec to state the uniform scope outright instead of letting the prose imply ownership scoping.
Blue
src/kiro_crew/dashboard/server.py:413— comment says "the three routes behind thesession_create/session_stop/session_read_messageMCP tools" and now sits directly above five. Already stale in base (missingsend); this PR inserted the close route under it untouched.- Same module docstring headline says "Four operations" — there are five (
session_sendomitted). The next sentence namessendexplicitly, so it reads as a miscount rather than a claim. - The config prewarm's position is unguarded: moving
await prewarm_enabled_check()below the gate passes clean. The adjacency test recordsselandauthorizebut never the config prewarm, soassert order[:1] == ["sel"]pins only the SEL prewarm. skip_enabled_check=Trueis unguarded — flipping it toFalseis invisible to the suite.- ~80 lines (≈9–10% of the diff) are optional
blackreformat of three baselined files. The baseline prune was required only as a consequence of choosing to reformat; the gate's own docstring says nothing demands a baselined file be reformatted. AGENTS.md wants that in its own commit, which the one-commit rule makes impossible — so the clean option was simply not to reformat. Both advisory bots raised this. docs/system-specs/modules/README.md:24index text still says "opening, stopping, and reading another".
What I verified against source
- Extraction fidelity.
close_slotis behaviour-preserving for the existing DELETE endpoint: identical ordering, await points, SEL logging, and_sync_dashboard_slots/push_slots_update/push_refreshcalls; all fourstatus=500returns map to byte-identical{error, code}bodies; App Kit ownership checks correctly stayed in the handler;pre_pop_checkis inert there (defaultsNone). - Rollback symmetry. The pre-pop abort restores exactly what its peer paths restore that applies at its position — {nudge loop, app notification}, minus the
_slotsre-insert since the pop has not happened. The_RECENT_CLOSEStombstone is left set on every failure path, identical toorigin/main, TTL self-healing — not a regression. skip_enabled_checkis a safe minimal widening. Keyword-only (so no positional-truthy leak), one call site, bypasses 1 of 18 refusal branches — the globalsession_control_enabled()read — with all 17 containment/identity guards still running. The rationale is real rather than rationalization:KiroCrewConfig.load()'s fingerprintstat()s config.json on every call, so even a cache hit is on-loop disk I/O.- The "no blocking I/O" docstring claim holds even on refusal.
deny()dispatches its SEL write through_sel_off_loop→run_in_executor, andauthorize_targetis a synchronousdefthat structurally cannot await. - Blast radius is complete on every enumeration that gates authority; no
SCOPE_CATALOG, policy, agent-config, or frontend list names these tools at all. - Local gates all pass: diff-scoped black, subprocess-encoding, isort, flake8 (11 files),
mypy --platform linux(1210 files clean), harness parity, docs-lint (247 md). - Tests: 304 pass across the close-path invariant suites; the PR body's green claim reproduces. Mutations a/b/d/g are pinned. One flake seen once —
test_session_control_boundaries.py::TestRefusalStatusMapping::test_a_mapped_status_is_forwarded[500]— passes alone (24/24) and on back-to-back repeats; it smells like the pre-existing shared-event-loop isolation issue inconftest.py, not this diff. - The
.mdtable fixed a real pre-existing gap: base omittedsession_sendentirely despite it shipping as both a tool and a route.
What I could not verify
Runtime behaviour under real concurrency — I confirmed the code structure and rollback ordering, not the live timing of the mirror/nudge races. Whether the conductor should wield session_close is a charter decision, not a code fact.
Summary. Y1 is the one I would ask for before merge: it is the only guard protecting a correctness property that took four blocking rounds to establish, and it currently does not exist. Y2 matters because the text becomes permanent on main. Y3, Y4 and the first server.py blue are one-line edits in files already open in this change. Y5 is a spec-wording ask, not a change request. None of it is a reason to block.
buluoray
left a comment
There was a problem hiding this comment.
Approving at head 17472535e — 67 checks SUCCESS, 4 skipped, none failing, and both blocking bots report no findings.
My detailed review is above: 0 blocking, 5 yellow, 6 blue. Every lane that examined behaviour came back clean — the close_slot extraction is byte-faithful for the existing DELETE path, the rollback set at the pre-pop abort is symmetric with its peers, skip_enabled_check bypasses exactly one of eighteen refusal branches with all seventeen containment/identity guards intact, and the conductor grant tuple was correctly left alone so session_close stays mounted-but-approval-gated.
I am approving rather than holding on Y1 because the shipped pre_pop_check() position is correct — what is missing is only the regression guard that would keep it correct. Two follow-ups I would still like to see land, neither of which needs to gate this merge:
- The adjacency test driving the real
close_slotwith an app close hook that acquires a channel mirror during its await. Movingpre_pop_check()above that await today leaves 170 tests green, so the property four blocking rounds established is currently unguarded, and the PR body's "mutation-guarded" claim does not hold for the adjacency (only for the arming). - The commit message, which still describes the async off-loop prewarm form — the round-3 design that was blocked, and which a comment in this same commit explains is unsafe. That text becomes permanent on
mainafter the squash, so it is worth fixing before merge even though it changes no behaviour.
The Y3/Y4 prose fixes (the .py "three operations" count, the conductor WITHHELD entry for session_close) are one-line edits in files already open in this change. Y5 is a spec-wording ask: state the uniform scope outright rather than implying creator containment that authorize_target does not enforce.
|
First Principles CONCERNS (advisory, non-blocking) on
Neither blocks this PR. CI is green (GPT/Opus green post-rebase; override not needed). |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
What is the problem?
The session-control MCP surface lets one chat session create, stop, send to, and read a peer session -- but there is no way to close one. An agent can stand up a workstream with
session_createand drive it, but when that workstream is finished the only thing it can do issession_stop(cancel the turn), which leaves the tab open. Cleaning up is left entirely to the human clicking the X. The natural mirror of "open a session" was simply missing.Why this issue matters to the user
Session control exists so an agent can manage a fleet of sessions on the user's behalf (a watcher it spun up, a refactor it handed off). Without a close verb, every session the agent opens is a tab the user has to remember to dismiss -- the agent can make the mess but not tidy it. Over a long orchestration this accumulates dead tabs in the sidebar, which is exactly the friction the create/stop/send/read set was built to remove.
How our fix solves it
Adds
session_close-- the tool-side equivalent of pressing the tab X.api_chat_slot_deletecarried a large, invariant-heavy close sequence (synchronous tombstone, auto-nudge-loop retirement before the awaits so no nudge resurrects the tab, app close-hook notification with rollback, persist-as-closed, per-tab session teardown). That sequence is extracted verbatim into a sharedclose_slot(state, slot, name)coroutine that raises a typedSlotCloseErroron its three failure paths; the DELETE endpoint andsession_closenow share it, so the two can never diverge on the ordering that keeps a dismissed tab from waking back up.request, unreachable from the session-control layer. The fix makes the close sequence a callable the layer can authorize and invoke, rather than copying its logic.close_targetroutes through the existing deny-by-defaultauthorize_target(operation="close"), so every guard already covering stop/read applies unchanged (self-target, unattended, incognito/temporary, app-scoped, channel-linked, channel-mirrored, crew-mode, cross-workspace). It is added toSESSION_CONTROL_TOOLS(strict caller-identity gate) and toCHANNEL_AGENT_BLOCKED_TOOLS.closed=True) -- it can be reopened later. It is not a permanent delete, matching the X button. An in-flight turn is cancelled first (its work discarded), so the tool description tells the caller this is heavier thansession_stopand to read the session first.SessionControlError(nudge_retire_failed/app_close_hook_failed/history_save_failed) at HTTP 500, so a caller can tell "the app refused the dismissal" from "history could not be saved". This required adding a 500 branch to the handler's_refusalstatus map (a close failure is a server error, not a client 400).What tests we did
close_targetunit tests: real end-to-end archive (slot leaves_slots, per-tab session torn down), self-target refusal throughauthorize_target,SlotCloseError->SessionControlErrorcode/status mapping with a denied audit line, and the prewarm-before-gate / gate-adjacent-to-act adjacency contract (mutation-guarded, mirroring the stop tests).kirocrew-dashboardregistration ratchet, the advertised-set test, and the refusal-status-mapping test (500 is now mapped; the degrade-to-400 case uses a genuinely unmapped status). The channel-containment ratchet derives fromSESSION_CONTROL_TOOLS, so it coveredsession_closeautomatically.test_slot_close_nudge_race.py,test_autonudge_dashboard_fire.py,test_unattended_slot_guardrails.py,test_ephemeral_sessions.py) to confirm theclose_slotextraction preserved the DELETE endpoint's behavior -- all green (session-control + boundaries + folders + registration + channel: 277 passed; close-behavior suite: 198 passed).Any other suggestions on the work
_refusalstatus map is now{403, 404, 409, 429, 500}; if more server-side failure shapes appear it may be worth deriving the set from the errors that can actually be raised rather than listing statuses.