Repository navigation
feat(chat): resume every interrupted session from the sidebar - #21031
jeeshofone wants to merge 2 commits into
Conversation
|
Intent: One owner press in the chat sidebar resumes every idle session whose last turn ended without a reply, through the existing Continue path, paced and with a per-session outcome. |
b5ab884 to
9b4462f
Compare
|
Disposition of the independent review on b5ab884, against head 9b4462f (one commit, rebased onto current main). M1, a candidate that finished before its wave gets Continue: fixed. Each slot now goes through S1 + S2, one long request and a half-queued wave on cancel: fixed with one structural change.
S3, non-owners get a button that always 403s: mapped. The SPA has no view of whether the viewer is the owner (only the server's N1, the sidebar and the server counted sub-agent slots differently: fixed on the server. N2, a push failure reported a dispatched slot as failed: fixed. N3, the inter-wave comment: fixed. It now says the pause is fixed pacing and does not wait for any signal. N4, GET/DELETE reaching the per-slot handlers: no change, as ruled. That behaviour is correct and is what keeps a slot named Also on this head: the screenshots were re-captured for pending, outcome, already running and owner-only (both themes), the PR body was rewritten for the new API shape, and Local gates on this head:
First-pass check: R1-R8 OK. |
9b4462f to
51e106e
Compare
|
Disposition for the delta review of 9b4462f (APPROVE-WITH-FIXES), against head 51e106e. F1 (frame before the 202 is dropped): fixed. S1 (shield at shutdown): fixed by rewording, with no shutdown hook. The module docstring, the N1 (guard freed before a cancelled run's shielded wave finishes): no change. It is safe under the per-slot lock and the N2 (exception outside the per-slot work): fixed. The run and wave tasks now take N3 (reserved-admission parity): fixed with a one-line predicate change. N4 (adopted 409 used the bare ceiling): fixed. The 409 body carries the in-flight run's N5 (act warnings in Local on 51e106e: pytest for the resume-all suite, slot-running readers, the route table and registry, and the chat-handlers, slot-facade, client-route and server composition contracts at |
51e106e to
9f625bb
Compare
Design Review (Opus 5.5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Nothing blocks: the bulk route reuses Notes
[DESIGN-REVIEWED] 3aacf7a Description read[DESCRIPTION-READ] f6fb32d0841ae2f072426ffa7d3c5b24e403ecd5e98c3c9b7d3b5e988ec3e433 That sha256 names everything this verdict read: the PR description, plus the 1 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with |
UX Review (Opus 5.5, fork) — ✅ PASSUX-level review of UX-Verdict: PASS Nothing blocks. The screenshots cover every new state, but no first-time reader has seen them, because fork PRs get a single review pass. Pushing the branch to this repository would run the cold-read pass. Notes
[UX-REVIEWED] 3aacf7a Description read[DESCRIPTION-READ] b8a05f3c30849a177077c2bff38493a18aac9797ab8f03956b5eda60f3d5c616 That sha256 names everything this verdict read: the PR description, plus the 29 evidence file(s) the workflow collected from it and handed this review. It covers that evidence because the media strip replaces every attachment URL with the same placeholder, so an edit swapping one attachment for another would leave the description's captured text identical while what this review judged changed. Recompute it with |
First Principles Review (Opus 5.5, fork) — ✅ PASSPremise-level review of First-Principles-Verdict: PASS Nothing blocks. The one-press bulk resume removes a stated chore, and it reuses Notes
What this change shipsInventory (8 items) — 6 justifiedIntent: after a restart, resume every interrupted idle session with one press instead of one press per tab. ADDITION.
[FIRST-PRINCIPLES-REVIEWED] 3aacf7a Description read[DESCRIPTION-READ] 87c26e55115e2fe265e8a642407154c299abce0302dc73f47d02296184ab92bb That sha256 names the PR description this verdict read. Recompute it with |
Opus 5.5 Review (fork) — ✅ no blocking findingsReviewed |
GPT 6.1 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
9f625bb to
0f376b3
Compare
0f376b3 to
e32755c
Compare
8d6053c to
1691f97
Compare
|
|
|
|
|
|
1691f97 to
c5b35b9
Compare
|
|
|
|
|
HOLD at c5b35b9 for human review. I found no code defect. The owner gate runs before anything is collected, one run at a time holds with no await between check and start, and each session goes through the existing What still holds it:
— crew-pr-reviewer (r133) |
c5b35b9 to
1973320
Compare
A gateway restart, a laptop sleep or a run of backend errors leaves many
sessions in the same state: the last turn ended without a reply, the
composer offers Resume, and the sidebar flags the row as interrupted.
Recovering them meant opening each tab and pressing Resume, one by one.
On a dashboard with thirty open sessions that is thirty clicks after
every restart.
The sidebar's search dock now leads its shelf with one status row while
any session is interrupted: "N interrupted sessions" and a "Resume N
sessions" action. The press calls the new POST /api/chat/slots/resume-all,
which runs the Continue path (continue_slot_turn) on every candidate.
Candidates are the facts the slots projection serializes for the row:
idle, no live sub-agents, not under construction, not a relay archive,
and is_turn_interrupted on the transcript, so the count on the button is
the total the server answers.
The press is not one long request. The handler collects the candidates
and answers 202 {ok, total, run_id} at once; the waves run as a
gateway-owned task held in state._background_tasks. One run at a time:
a press while a run is in flight answers 409 resume_all_running with
that run's id and total and dispatches nothing. The outcome {run_id,
total, resumed, skipped, failed} reaches owner sockets as one
resume_all_done frame, sent from a finally however the run ends; the
row stays pending until it arrives, then shows it for six seconds. The
frame can beat the 202, so the hook keeps the last few outcomes it
heard and settles from them when the run id is read. A press that got
the 409 follows the running run, with that run's total for its wait,
and says one is already running.
Launches go out four at a time with a fixed 1.5 s pause between waves.
Each wave runs as its own tracked task behind asyncio.shield, so a
cancellation of the run task stops it between waves only: no slot is
left with a queued recovery entry and no runner. At gateway shutdown
loop teardown cancels the wave too; a slot mid-dispatch keeps its
queued entry in memory only, and a restart reloads the queue. Each slot is
continued with require_interrupted=True, so a candidate whose turn ended
cleanly before its wave is skipped as turn_not_failed and never gets the
carry-on body. Every other per-slot Continue refusal is reported in
skipped by code, and an exception from one slot lands in failed without
stopping the wave. A slot counts as resumed once its dispatch succeeded:
a failed slots push afterwards is logged by continue_slot_turn, not
reported.
Owner-only. The single-slot Continue route gates per slot through
deny_app_slot_access; a route that dispatches a turn on every slot is a
different blast radius and takes require_owner_dashboard_request. An
app token and a non-owner dashboard session are both refused before
anything is dispatched; the row maps that owner_only refusal to its own
message without the agent hand-off, which cannot help.
The row sits in the shelf, not as a third button in the search row's
trailing cluster: AUTOSDE max-two-buttons-per-row grandfathers that
cluster but forbids growing it. The route is a literal registered beside
cleanup/model, ahead of every {slot} POST.
Tests: the candidate set (idle + interrupted; running, finished, empty,
relay-archive, sub-agent and under-construction slots out), the pick-up
body on every resumed slot, the wave bound and pause, a slot that
finished between waves and a stale second press both skipped as
turn_not_failed, refusal and failure isolation, a failed push still
counted as resumed, the 202 with the outcome frame, one run per press
(409 for the second, over HTTP too), cancellation mid-wave leaving no
slot half-queued, the answer arriving while every start is still held,
an exception outside the per-slot work still sending the outcome, the
owner gate, and the route order. Frontend: the count rule, the
pending row until the matching frame, a frame that arrives before the
202, the 409 already-running state with its total,
the owner-only message without hand-off, the error notice, the offline
hold, and the frame router trace.
Closes kirodotdev#21021
Resume-all dispatched its candidates four at a time behind asyncio.shield,
with a fixed 1.5 s pause between waves. The agent starts those dispatches
lead to already queue at SessionManager._start_sem, which
agent.cold_start_concurrency sizes and which makes a start beyond its
width wait for a permit rather than refusing it, so the pacer was a second
limit on the same starts that ignored that setting. Every candidate is
dispatched in one gather; the waves, the pause, its _pause seam and the
shield are gone, and the sidebar row's comments no longer describe them.
A burst test sends 40 candidates through a real SessionManager: every
dispatch is in flight at once, four starts run inside the semaphore while
36 wait, and none is refused.
Nothing limits how many resumed turns run at once after their starts,
and the module docstring says so: a run over N candidates can leave N
turns running together, the same load as pressing Resume in each
session, since no turn a person starts waits on a concurrency cap.
continue_turn.py stays as on the base: a failed slots push after a
dispatch raises out of continue_slot_turn as it does for the single-slot
route, and resume-all's _one records it as that slot's failure while the
other slots carry on.
start_resume_all is the module's one entry point. resume_all_interrupted,
which only the tests called and which skipped the one-run guard and the
outcome frame, is gone, and its tests press through start_resume_all and
read the run's resume_all_done frame.
The button's count and the run's total come from one golden fixture,
test/fixtures/resume_all_candidates.json, which both suites read. pytest
builds each slot from its facts, checks the fixture rows against
serialize_slots and the expected keys against collect_interrupted_idle
and start_resume_all's total. vitest feeds the same rows through the chat
page's surface filter into the sidebar and checks the count it renders.
The sidebar hook keeps the outcome frames it hears while its request is
open, one per run id, and empties them once the 202 or 409 is read. The
server's one-run guard ends when a run's dispatch is done, not when its
202 reaches the tab, so another tab's run can start and finish before a
slow 202 is read, and the 202 then settles from its own run's frame.
Nothing is kept between presses, so the eight-frame Map with its trim
loop and age prune is gone.
The event name has one export, in lib/resumeAllEvent, which the row, the
frame's emitter and both tests import. The useWebSocket facade and
browserEvents re-exported it for the tests alone, so those re-exports are
gone and the facade test's name list matches the base again.
Offline, the action's title and accessible name give the reason through
offlineProps and the existing utils.offline.resume_sessions phrase, as the
header's New button does. Online the title is the button's label.
The owner-only refusal keeps the notice's Ask the agent hand-off.
errors-use-error-notice turns the hand-off off only beside an unsaved
draft or when the agent itself cannot run, and neither holds for a
non-owner whose press saved nothing, so the ownerOnly flag that switched
it off is gone and the notice's message alone names the owner gate.
The sidebar row's tests that assert on the first render drain the
providers' pending updates inside act first, so the file emits no act()
warnings.
The resume-all tests await the run task, the gathered background tasks
and the cancelled run through kiro_crew.testing.wait.await_until instead
of asyncio.wait_for with a 10 s ceiling.
The problems list's box holds exactly six lines. leading-4 makes its
lines 16 px, so the edge of its 96 px max-h-24 box falls between two
lines. The inherited 17.05 px lines fit 5.6 to the box, so its edge cut
the sixth. While useScrollEdgesY reports lines below that edge, the box
takes MORE_BELOW_MASK, the fade the model list uses, and the mask comes
off at the end of the list. ErrorNotice takes a messageRef prop, which
hands the row the message span it measures.
With the Crew Mode preview on, a worker whose root is listed stays out of
the run, as it stays out of the button's count: the sidebar counts from
crewModeView, which drops such a worker only while the browser-side
preview flag is on. The press now sends {"crew_mode": true} when the flag
is on, and collect_interrupted_idle then skips a slot whose live crew_root
stamp (crew_mode.live_stamps over the frame's parents) names a listed
chat-page slot. With the flag off, or no body, workers count as plain
sessions on both sides. A worker whose root is not listed still counts.
The shared fixture gains a listed-root case (flag on and off) and a
root-not-listed case; the vitest side sets the flag, counts through
crewModeView and checks what the press sends.
|
Disposition of the Opus BLOCK on 1973320 (
|
|
HOLD at 3aacf7a for human review. I found no code defect. The owner gate still runs before the body is read or anything is collected, and one run at a time holds with no await between check and start. The new What still holds it:
Please also refresh the description. The route table still says "owner-only, no body", but the route now reads an optional The other current-head lane notes do not block. The fade/ — crew-pr-reviewer (r137) |
Problem / Motivation
Goal: After a restart or a run of backend errors, one press in the chat sidebar resumes every idle session whose last turn ended without a reply.
A gateway restart, a laptop sleep or a run of backend errors leaves many sessions with a turn that ended without a reply: the composer offers Resume and the sidebar row says "Turn interrupted". Today each needs its tab opened and its own Resume press.
Why it matters
With thirty open sessions that is thirty switches and thirty clicks after every restart, and a missed session sits idle until someone notices.
Not a goal
continue_slot_turn.agent.cold_start_concurrencysizes.What changed (motivation → approach → change)
A new sidebar row resumes every interrupted idle session in the background with one press, then says what resumed and what did not.
POST /api/chat/slots/resume-all(owner-only, no body) answers202 {ok, total, run_id}state._background_tasks200 {ok, total: 0, run_id: ""}409 {code: "resume_all_running", run_id, total}403 owner_onlyThe outcome
{ok, run_id, total, resumed, skipped: [{slot, code}], failed: [{slot, error}], cancelled?}reaches owner sockets as oneresume_all_doneframe, sent from afinallyhowever the run ends.start_resume_allis the one entry point: the one-run check and the task start have no await between them.Candidates (
collect_interrupted_idle) are the facts the slots row shows: a chat-page session (CHAT_PAGE_MODES) that is not under construction, not a relay archive, not running (its own turn or a linked channel turn), has no live sub-agents, and ends interrupted on the tail the sidebar reads (summary_rows), so nothing is read from disk on the loop. The sidebar counts the same facts, and a fixture both suites read pins its number to the run'stotal.Per slot, the window loads off the loop, then
continue_slot_turn(require_interrupted=True, via="resume_all")runs. A turn that finished cleanly before its dispatch is skipped asturn_not_failed, every other refusal lands inskippedby code, any other exception (a failed slots push included) lands infailed, and the rest carry on.Pacing. Every candidate is dispatched at once, in one
gather. Each cold start waits for aSessionManager._start_sempermit, whichagent.cold_start_concurrencysizes, and is never refused for want of one. Only loop teardown at shutdown cancels the run, and its frame sayscancelled: true.The row leads the search dock's shelf: "N interrupted sessions" and a "Resume N sessions" action, held while offline with the reason in its title and accessible name. A press reads "Resuming…" until the frame with its
run_idarrives (the frame can beat the 202, so the hook holds each run's frame until the answer is read), then shows the outcome for six seconds. A 409 says "A resume is already running" and follows that run. Sessions the owner must act on (a repeated start failure, a pending approval, an error) get a "Some sessions were not resumed" notice naming each one and why. Its list scrolls in a six-line box, faded at the bottom while lines sit below. Sessions that moved on by themselves stay in the status line. A failed request, the owner gate included, renders throughErrorNoticewith the agent hand-off. The wait is bounded (60 s + 1 s per session) because frames are one-shot.Why not one long request? The run grows with the session count, and a proxy timeout would show an error for a working run. Why not one Continue per row from the browser? No one-run check across tabs, and no single outcome. Why no status read? Each tab learns the outcome from the frame or the 409, so
GET /api/chat/slots/resume-allstill reads the session namedresume-all.src/kiro_crew/dashboard/chat_resume_all.py,chat.py,routes/chat.py,ws_event_scope.py{slot}POST) and the owner-onlyresume_all_doneeventwebsite/src/pages/chat-sidebar/resumeAll.tsx,SearchDock.tsx,scope.ts,pages/ChatSidebar.tsx,components/ErrorNotice.tsxmessageRefprop the problems list's fade measures throughwebsite/src/api/client/chat.ts,lib/resumeAllEvent.ts,hooks/useWebSocket.ts,hooks/websocket/browserEvents.tschatSlotsResumeAll, and the frame re-broadcast as a window eventwebsite/src/i18n/locales/*.json,i18n/pluralKeys.json,docs/feature-map/README.md,docs/system-specs/modules/history.mdresumeAll.tsxowner rowHow this fits #20750
Complementary. #20750 is the automatic, budgeted path, calling
continue_slot_turnwith a new optionalbody. This PR calls it by keyword with nobody. They share onlydocs/feature-map/README.mdanddocs/system-specs/modules/history.md, where each adds its own row.Backwards compatibility
Compatible: a new route, module, owner frame and sidebar row. The single-slot Continue route is unchanged.
Removes nothing: every change adds a route, an event, a row, copy or a doc row; no existing reader loses anything.
Tests
test/test_chat_resume_all.py(30), every run started throughstart_resume_alland read from itsresume_all_doneframe: the candidate set (running, finished, empty, relay-archive, sub-agent, under-construction, member DM and channel-turn slots out; a reserved slot counted as the sidebar counts it, then skipped); a restored row judged from its tail with no read, its window loaded off the loop, and a failed read failing that slot only; the pick-up body andvia; 40 candidates dispatched in onegatherwhose cold starts go through a realSessionManager._start_sem(four inside, 36 waiting, none refused); a slot that finished before its dispatch and a stale second press, bothturn_not_failed; refusal and failure isolation, a failed slots push included; 202 then the frame; one run per press with the 409 and its total, direct and over HTTP; a cancelled run still sends one frame; an exception outside the per-slot work still sends the outcome; the owner gate; the route order.test/fixtures/resume_all_candidates.jsonis the count both sides are held to. pytest builds each slot from its listed facts, checks the fixture's rows againstserialize_slots, and checks the expected keys againstcollect_interrupted_idleandstart_resume_all'stotal. vitest (ChatSidebar.resumeAll.test.tsx) feeds the same rows through the chat page's surface filter into the sidebar and checks the count it renders.test/test_slot_running_readers.pylists the collector as aturn_runningreader.website/src/test/ChatSidebar.resumeAll.test.tsx(16): the count rule, the outcome text, no row at zero, pending until the matching frame, nothing to resume, the 409 state, the owner-only message with the hand-off, a failed request throughErrorNotice, the problems notice in its scrolling box, that box's six 16 px lines and its bottom fade, which is on only while lines sit below the edge, race-only skips with no notice, the offline hold, the offline reason in the action's title and accessible name, and the two fixture cases.website/src/test/resumeAll.hook.test.tsx(7): a frame before the 202 settles the row, its own run's frame settles it after another run's frame, a 202 read after a second run finished settles from its own run's frame, nothing is kept once the answer is read, another run's early frame does not settle it, a 409 waits with its total, and a 409 whose run already finished settles from the kept frame. The hook keeps frames only while its request is open, one per run id, and empties them when the 202 or 409 is read. That is not the eight-frame buffer removed ondc24629fa5, which kept frames between presses and pruned them by age.ApiClient.refactor.surface.test.ts,ChatSidebar.loopWaiting.test.tsx,ChatSidebar.ownerComposition.test.tsanduseWebSocket.frameTrace.test.ts.ErrorNotice.messagePlacement.test.tsxgains two cases:messageRefreaches the message span in the block and the inline variant.Mutation pins: dropping
require_interrupted, the 409 check, a candidate filter or thefinallybroadcast turns a test red, and a run that paces its own dispatches hangs the 40-candidate test at its barrier. Each of these turns its own side's fixture test red: on the server, dropping the chat-page mode filter, the sub-agent filter, the channel-turn filter or the relay-archive filter, or the slots row no longer reporting a linked channel turn as running; in the browser, dropping the sub-agent or relay-archive filter, or the chat page admitting member threads. Dropping the browser'srunningfilter changes nothing, because the slots row never reports a running session as interrupted. In the hook, one kept frame in place of the map turns the two-runs test red, and keeping frames while no request is open turns the nothing-kept test red. The fade test fails at its six-line check on the code before the problems-list change, and at its fade check when the box is kept but the fade is removed.Determinism on
c5b35b96f7:test/test_chat_resume_all.pypasses 20 runs at-n0, 10 at-n 4 --dist loadgroupbesidetest_slot_running_readers.pyandtest_chat_slot_continue.py, and 3 seeded shuffled orders (seeds 4127, 23011, 9876).ChatSidebar.resumeAll.test.tsx,resumeAll.hook.test.tsxandErrorNotice.messagePlacement.test.tsxpass 3 runs with no act() warnings.Gates on this head (
c5b35b96f7, on main atfd2f83c414): every static gate in the prepare-pr profile passes, among them Black, isort, flake8, mypy (0 issues), comment-history, harness parity, feature-map, docs lint, file-line cap, the website build,gen:ui --check,tsc, eslint,i18n:check,i18n:render, phantom classes, restyle ratchet, jscpd and the bundle-size and chunk-cycle checks. The flake-pattern ratchet passes, and semgrep with the repo rules finds nothing. The frontend related set, all four of its batches (1,871 files), passed: 35,964 passed, 2 skipped and 1 expected failure. pytest passes on the 37 backend files that exercise the changed modules (1,474 passed), among them the ws-scoping and error-code contract tests andtest_state_parts_dashboard_state.py, whose module main changed under this branch. This head changes no backend file since8d6053cd30other than by the rebase. The backend related set last ran on6d258afb45(2,412 files then), and none of its failures there was in a file this diff touches.Manual verification
Built the SPA and drove it against the gateway-free API stub at 1512×862, 2× scale, in both themes: pressed the row, pushed the slots frame and then the outcome event the way the gateway orders them, answered the press with 200, 403, 409 and 500, and refused the socket for the offline state. On
1691f972bethe problems list was captured the same way, and the harness read its line height (16 px), its box (96 px) and its fade from the page, at the top of the list and at its end.c5b35b96f7changes only how the hook keeps early frames, which draws nothing. Not run against a live gateway restart.Screenshots / video
Twelve states in both themes, captured from the built SPA with placeholder sessions only. The problems list changed on this head, so its before and after come first. The details block holds every other state, the full success and nothing-to-resume lines among them.
The problems list before. The box cut its sixth line ("approval.") and had no edge fade.
After, on
1691f972be. Six whole lines, with the bottom edge faded while lines sit below it. Scrolled to its end, the list shows its last line whole and unfaded.The other states the UX lane listed as missing:
Every other state, both themes (18 images)
Related Issues
Closes #21021
Checklist