Skip to content

feat(chat): resume interrupted turns on their own, within a budget - #20750

Open
hickoxd wants to merge 2 commits into
kirodotdev:mainfrom
hickoxd:feat/restart-resume
Open

hickoxd wants to merge 2 commits into
kirodotdev:mainfrom
hickoxd:feat/restart-resume

Conversation

@hickoxd

@hickoxd hickoxd commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem / Motivation

Goal: A chat whose turn the app cut off — by a restart, or by an error a retry can fix — picks that turn back up on its own, within a budget the person sets, and a chat that came back with different permissions says so. One defect on main is fixed on the way because every automatic resume runs through it: the turn a natively resumed session is handed back quotes the gateway's own mid-turn notices as the model's words (shown below with a test that fails on main).

Today a gateway restart ends every in-flight turn and leaves each chat on "This turn was interrupted when the app restarted. Review the partial output above, then resume to continue." Nothing presses Resume: _reconcile_local_turn_marker lands that row and the only continuation is a person's click (or the session_retry / session_continue tools). The in-turn retry ladder — transient 5xx retries, the 300 s connection-recovery window, the stall recovery — ends at "Connection unstable — please try again." and stops there too. A person who starts a long task, leaves, and comes back after a restart or a dropped connection finds the chat exactly where it died, hours ago, waiting for a click.

Permissions make the same situation quieter. A Trust picked in the composer lives in memory (deliberately: test_trust_flags_not_restored). After a restart the chat is back on Ask every time, and the first thing the person learns about it is a stalled approval card — or nothing, if nobody is watching. #12275 describes the stall; rfc-crash-only-session-trust / #15894 are the maintainers' direction on which grants should survive which restarts. Nothing in that direction tells the chat it changed.

Why it matters

Unattended work is the point of a gateway that runs while the person is away: armed loops, conductors, a long refactor started before lunch. Every restart (an update, a crash, a kirocrew restart) turns each of those into a chat that needs a human click, and the person pays twice — the lost hours, and the search through the sidebar for which chats stopped. A per-chat budget with notices costs model turns while nobody is watching, so it is off by default and bounded; the permissions notice sits behind the same switch, so a gateway that never opts in sees nothing new at a restart.

Not a goal

  • No approval mode is restored, re-seeded or persisted across a restart. The repository rule stands and Per-chat trust dies on gateway restart; armed loops and workers stall #12275 / fix: keep owner-granted session trust across a gateway crash #15894 keep that decision; this PR only records what the person last saw and, with the switch on, tells them when it differs.
  • No resume after the turn time limit (Turn ceiling strands unattended work: auto-continue a turn cut mid-progress instead of waiting for a human #4756 is that decision), after an auth / entitlement / usage-limit / context-overflow end, after a Stop card, or for a chat that was closed.
  • No change to the in-turn retry ladder, its windows or its cards; this starts where that ladder gives up. One row of the ladder's is touched after the fact, and only by this feature's own act: a give-up row the error pass has resumed is stated as settled under the Resumed automatically card (see "Frontend"). A row a person resumed, or nobody did, reads exactly as it does today — with the switch off, no transcript changes.
  • No change to the body the manual Resume button or the session_retry / session_continue tools send. (One shared change on the natively-resumed path, for every continuation that gets the restored-turn quote, the manual Resume included: the quote leaves the gateway's own system notices out. That fixes a defect main has today — a compaction notice the ladder writes mid-turn is quoted back to the model as its own partial answer — and the test that shows it fails on main; see "What a natively resumed session is shown" under Tests.)
  • No new rows for a resume that was refused (a running or queued chat, a pending approval) and no retry of a refusal.
  • No per-mate or per-chat override of the switch; the setting is dashboard-wide.
  • No automatic resume of a chat an app owns: that is the app's to continue.
  • No notification on the first error a turn dies with (No notification when a turn dies (provider 5xx / throttle / content filter) -- only the inline Resume error #13853); the notification here is for the budget being spent.

Backwards compatibility

Compatible. Three new optional config keys with defaults; a gateway that never writes them behaves as before. The two new SYSTEM_NOTICE_KINDS entries (approval_mode_dropped, auto_resume_paused) are read by every is_system_notice caller the same way the existing compaction / session_reload kinds are — the last-real-message scans (is_turn_interrupted, _newest_error_row, _pause_notice_stands), the crewmate-chat row filter, interrupted_turn_rows, and NoticeCard on the frontend — so a notice is looked through, never mistaken for a reply. TURN_FAILED_META moved to chat_utils_injected_rows.py (read through the chat_utils facade); chat_turn/queue_cycle.py, which writes the row, imports it from there and the runner reads it through that owner as on main, so its one existing reader (the OpenAI-compatible endpoint's done handling, per learn-cron-dashboard.md) is unchanged. Two new slot-owned metadata-line keys (auto_resume_stamps, approval_mode_seen): an older line reads back as an empty budget and no record (no notice); a full line write for a chat with no automatic resume and no grant omits both keys, so that line is byte-for-byte as today, while the metadata merge path (a window-less save, golden empty_window_merge) always writes "auto_resume_stamps": [] and "approval_mode_seen": "normal" -- a merge cannot delete a key, and a revoke must overwrite the trust an earlier line carries (Design review rev 41); and a new line read by older code is ignored, both keys included. The runner's terminal "please retry" rows now carry meta.kind = retry_invited; no frontend reader keys on an error row's kind except the two interruption kinds and transient_retry, so a tagged row renders exactly as before, and an untagged one an older gateway persisted simply is not auto-retried. continue_slot_turn(body=None) and parseRecoveryMessage(content, meta?) keep their existing call shapes. Nothing is renamed, removed or rejected that main accepts today.

Decision for the owner

The RFC is draft; a maintainer accepting it (its §9) or overriding is part of the merge decision, and nothing here pre-empts that. Two choices a reviewer may want to overrule, stated here so they are not read as oversights. (1) The boot pass resumes a chat whose Trust ended at the restart. Its first tool call then parks at an approval card, and one budget stamp is spent — but the alternative, skipping that chat, leaves the person the same approval card one Resume press later with none of the pre-approval work done; resuming is strictly more progress, so the chat is resumed and the permissions notice says why the card appeared. (2) The retryable list is closed on structural kinds: the runner's own "please retry" rows are tagged retry_invited at their writers, and an untagged terminal error is not retried until someone tags it. The rows retried today are unchanged; what changes is that a writer added later is not retried by accident.

The RFC ships in this PR as draft rather than landing first (one PR was the request). Nothing here is on by default — the switch gates both passes and the permissions notice alike, so a gateway that does not opt in behaves as it does today; what remains for the First Principles lane is the RFC's acceptance (its §9), yours to record, or I split the document out. One more call is yours: the UX lane asks for an inline Restore Trust action on the permissions notice. The RFC's §4.6 is "tell, do not restore" — the notice names the composer picker because that control carries the grant's own confirmation, and a one-press re-grant from a system row would be a second place a Trust is handed out; I have not added it. The First Principles lane's remaining ask has narrowed to the window select alone (resume_retry_window_minutes; it now counts the limit as justified) — the same call, smaller. The Design lane asks that the error pass keeping a chat's held Trust be stated as an accepted trade-off or removed: it is now stated as one (RFC §4.6, with the alternative — skipping, or resuming in Ask, a Trusted chat — rejected in §6, and a pin test holding the pass to changing a chat's mode in neither direction). The reasoning is the person's: a Trust picked in the composer is their standing instruction for that chat, and a dropped connection says nothing about whether they still mean it; the flip, if you want it, is one condition in the error pass.

What changed (motivation → approach → change)

Decision record. docs/request-for-change/rfc-restart-resume.md (status draft) records the design and travels in this PR as asked. The switch is off by default and gates everything here, the permissions notice included (RFC §4.1, §4.6): a gateway that does not opt in gets no automatic resume and no new row at restart. Whether that notice should reach every gateway — a chat silently losing its Trust at a restart is a defect whether or not anyone pays for automatic resumes — is a product-shape decision this PR leaves to the maintainers (RFC §9 Q6); the record it reads (approval_mode_seen) is written either way — deliberately not gated by the switch, since a record of what the person last saw is honest only if it tracks every pick and revoke: a writer paused while the switch was off would leave the last grant on the line through the merge form (a merge cannot delete a key), and the first boot after the switch came back on would report a Trust dropped that the person had revoked themselves — and its cost is one short key on the slot lines of a gateway that never opts in — on every line the merge form saves (normal where no grant was held, since a merge cannot omit a key) and, on a full-line save, only for a chat that held a Trust — read by nothing but the notice and reversible by deleting it (RFC §9 Q6 states it; Design review, rev 42), so taking that decision later needs no migration.

Approach: press the button the person would press, nothing more. Both passes dispatch through continue_slot_turn (chat_api/continue_turn.py, behind the chat_handlers facade), which gains an optional body and the existing via; every automatic dispatch passes require_interrupted=True. So every state the Resume button refuses (running, queued, approval pending, children running, two failed starts, a turn that finished meanwhile) is refused here too, the row lands as the same inject the button lands, the turn runs with no human-origin flag and no app or cron identity, and the SEL dashboard_continue line carries via=restart_resume or via=error_resume so the audit trail tells an automatic resume from a press. The inject's first line is its own — [Continue — automatic after a restart] / [Continue — automatic after an error] — so the model does not read a press that never happened as an answer, and the card does not say "requested by the user". Both bodies join _INTERRUPTED_TURN_RESUME_MSGS (chat_utils_turn_recovery.py, read through the chat_utils facade), the one set _run_chat gates its interrupted-turn restore on: on a session the turn natively resumed, kiro-cli's log lacks the turn the process died on, and the runner re-injects it from the slot window for an automatic resume exactly as it does for a press; the rows it hands the builder come from chat_utils.interrupted_turn_rows, which leaves the gateway's own system notices out, since they sit in the span quoted as the model's partial answer. That span already carries such rows on main: chat_turn.recovery writes the will compact after this turn notice into a running turn (kind: compaction), so a process that dies on that turn leaves it between the request and the resume row, and a plain Resume press today quotes it to the model as what the model had said.

Backend: dashboard/restart_resume.py (new). Off the gateway boot path (AUTOSDE no-new-work-on-gateway-boot-path, GPT review rev 27): neither facade (server, chat_handlers) imports the dispatcher at module level; the four request-time callers (a send, a Resume, a Stop, a close) import it on use, after the socket is bound; the restore imports it only with the switch on, right where it schedules the boot pass (both passes return at once without the switch, so the gate changes nothing they did); and the per-slot metadata read validates the budget list through dashboard/auto_resume_stamps.py, a validator beside the codec (re-exported by the dispatcher), so a default-off launch with sessions to restore loads nothing of it. Proven by a subprocess test that imports both facades and applies a tampered line through the codec, red before the gate ("a facade imports the dispatcher"), and by a restore test with the switch off that reaches no dispatcher call.

  • Boot pass (resume_after_restart): run by the one background task start_boot_pass creates from _restore_dashboard_sessions after both restores and the counter reseed — the permissions notice pass first, then this — so the server's listen and KIROCREW_READY wait on neither (each loads transcripts the restore deferred, one per chat concerned). The restore keeps the turn-in-flight marker's presence on the slot for this process only (_restart_cut_turn, set from the metadata-line read and confirmed by settle); the pass looks only at flagged slots, loads each one's deferred window off the loop, and resumes those whose newest turn reads as interrupted and ended in no error row or in the gateway_restart_interruption row (restart_candidate). That rule is what makes a turn cut before any reply ([user], [user, tool…]) or a cut recovery turn resume, and what keeps an interruption row an earlier boot left from being resumed on a later one; the in-turn retry ladder's pending row (transient_retry, a recovery queued, not an ending) is looked through, so a restart during the ladder's wait — the common case of a restart in a provider outage — is resumed too. Neither pass touches a chat an app owns (slot._app): its app re-grants its own trust and resumes on its own terms. No other driver needs excluding (Design review, rev 25): every continuation of a dashboard chat — the button, an app's Continue, an agent's session_retry / session_continue, the two passes — goes through continue_slot_turn, which refuses a running or queued chat and a turn that is no longer interrupted, and the error pass re-reads the tail right before it dispatches and takes its stamp back on a refusal, so of two drivers aiming at the same cut-off turn the second is refused and the work runs once; a cron or monitor loop queues its next turn rather than re-sending the failed one, and sub-agent runs are not dashboard turns (they run in subagent_manager, never reach _finish_queue_cycle, and are not restored slots), so neither pass can reach a conductor's worker. It waits for the cautious-boot pause and a 5 s settle before the first dispatch, 2 s between chats, re-reads the switch from the live snapshot before each dispatch (turning it off during the pause ends the pass), logs and skips one chat's failure rather than ending the pass, and skips a chat the person touched first — a send or Resume clears the marker flag through reset_budget, and a Stop or a close clears it through cancel_armed_retry without touching the spent budget, so a Stop on the idle restored chat, which writes no Stop card (the route's no-op branch), still ends the resume (GPT review rev 40: the earlier cancel_armed_retry only moved the epoch, which the pass captured after the Stop had bumped it). The classifier reads turn openers the way is_turn_interrupted does — a user or nudge row, or an inject whose injectKind dispatched a turn — so a held note the runner flushes after an error (an untagged inject) is looked through rather than read as a new turn that swallowed the error.
  • Error pass (schedule_after_error): called once per local turn end from _finish_queue_cycle (chat_turn/queue_cycle.py, run on the runner's namespace like every owner) when the slot is idle. One accepted gap, recorded in RFC §4.4 and pinned by test_a_restart_during_the_backoff_loses_the_armed_retry (Design review, rev 29): the armed retry is a task in the running process, so a restart inside the backoff drops it — the chat comes back on its error row with no marker, the boot pass does not look at it, and the person finds "please retry" exactly as without the switch; no stamp was spent (it is written at dispatch). Closing it would need a persisted pending-retry record whose only consumer is that coincidence and whose stale-record failure mode is worse than the gap. A turn whose newest row is a retryable terminal error — transient_give_up, session_start_failed, or retry_invited — arms one retry after min(30 s · 2^(n−1), 10 min) + 25 % jitter; nothing is armed while the switch is off in the process's config snapshot, and the switch is read again after the backoff so turning it off during the wait stops the retry. The retryable set is a closed allowlist of structural kinds (RETRYABLE_ERROR_KINDS) with no "everything else" entry: the runner's own "please retry" rows (connection lost, session busy, turn or tool stalled, a shared agent process that kept restarting) are stamped retry_invited by their writers in chat_turn/turn_reply_seams.py, chat_turn/acp_recovery.py and chat_turn/recovery.py, and an error row with no kind or a kind off the list — an approval prompt nobody answered, "Session stuck", an unclassified AcpError, a writer added later without the tag — is never retried, nothing being read off the row's words; meta.turn_failed rows are excluded too; and a Stop card anywhere in the newest turn — including the give-up row the ladder writes when a Stop lands during its backoff — is the person's recorded intent that nothing continues on its own. The pass also measures the session's Stop count it reads at arming against the one the failed turn read at its entry (_turn_open leaves it on the slot as _turn_entry_stop_count): the pass is scheduled from the cycle end, several awaits after the turn released its session permit, and a linked-channel !stop landing in that gap moves the counter while the session is still live and writes nothing on the slot — read only at arming it passed as the baseline, so the stopped turn was resumed (GPT review, rev 65). When the count moved, nothing is armed.
  • Budget: resume_retry_limit per resume_retry_window_minutes per chat, restart and error resumes counted together, stamps persisted on the metadata line (auto_resume_stamps) so a gateway that restarts, resumes, dies on the resumed turn and restarts again still counts the earlier ones. The stamp a resume spends is committed first: the dispatcher appends it and awaits a fenced metadata merge of the list (_commit_budget, the turn marker's own merge-and-guard, registered with the close's wait), and only a completed write lets the dispatch reach continue_slot_turn — no log, a closing chat, a missing line, a refused guard or a failed write refuses the resume and takes the stamp back in memory, so no automatic provider work runs on a budget that exists only in memory. On exhaustion: one auto_resume_paused system notice (idempotent per budget — nothing is added while a pause notice stands with no automatic resume after it in the transcript, so neither a crash loop nor an unattended turn that keeps failing stacks rows), one state.notify, SEL restart_resume:budget_exhausted, and the Resume button, which was never removed. A person's send (api_chat, not an app's or a cron's) or Resume press (continue_slot_turn with human origin and no body) resets the count and drops an armed retry; an agent's session_retry / session_continue and an app's Continue leave the budget alone, since it bounds exactly what no person asked for. A Stop (api_chat_stop) and a close (slot_lifecycle) drop an armed retry too. Each of those moves a per-slot epoch that the dispatch re-reads under the slot lock right before its queue insert (before_dispatch), so a cancel that lands after the backoff but before the insert refuses the dispatch instead of leaving a gateway-authored entry at the head of the queue; a refusal there, or any of continue_slot_turn's own, takes the stamp back in memory, and the line keeps it until the next save, so a crash in that gap counts one resume that never ran (fail closed). A person's reset is carried by their own turn's marker as the empty list (_auto_resume_reset_pending), so a crash before the periodic flush does not read the spent budget back off the line.
  • Fail closed on the writable line: read_persisted_stamps re-checks every element; a list that is not a list of finite, non-negative, non-future numbers within MAX_STAMPS reads as FULL (64 stamps) — the automation refuses, the manual Resume is untouched, a warning names the chat, and the pause notice states only the pause (is paused here) and leads to the remedy — the cause is the operator's, in the codec's warning, not something the person can act on (UX review, rev 26), and no duration is named, since the window aging out re-allows automatic resumes only for later interruptions and nothing re-arms this chat's turn, so for 1h read as a promise to pick it up in an hour (UX review, rev 29) — rather than quoting the sentinel count; the sentinel is told apart by its size (the gateway never appends past the limit, whose ceiling of 20 is far below 64), so a limit the person lowered below the stamps already in the window is quoted as it is.
  • Turn-start marker: _begin_local_turn_marker carries the budget list on its awaited merge when there is one, and its fallback full save — taken only when the metadata line is missing — is no longer best-effort, for every chat (First Principles review, rev 64, undeclared until now): the verdict that marks a person's reset as on disk needs a truthful answer, and a best-effort save answers true to a write that failed. For a chat that carries no budget the outcome is what it was — a failure is logged, re-arms the flush and still admits the turn (the caller does what the best-effort save did) — so a gateway that never opts in sees a different log line and nothing else; TestMarkerFallbackSave drives it with no switch set.

Backend: tell about permissions, do not restore them. The metadata codec gains approval_mode_seen (trust | trust_reads) — named for what it is, a record of what the person last saw, never a setting — written by every save from the live flags via one pure derivation (approval_mode_record.live_approval_mode, a sibling module beside the codec's facade like the stamp validator — the codec was already 1,700 lines and these readers are a job of their own (Opus review, rev 33); the codec re-exports them for its line writer): the full-line save omits the default, so a chat nobody picked a mode for carries nothing extra and the existing metadata goldens are byte-identical, while the merge (which cannot delete a key) writes normal so a revoke overwrites an earlier grant. It is read back into _approval_mode_recorded only, by the read and by nothing else — no save writer touches that field and no reader touches _trust / _trust_reads. In the boot task, ahead of the resume pass and behind the listen — and, like the resume pass, only while the switch is on — notice_dropped_approval_modes compares each slot's recorded mode with the one it holds and, when a grant is gone, lands one approval_mode_dropped system notice (🔒 This chat's permissions changed when the app restarted: it was on Trust … and is now on Ask every time …), before the boot pass so no automatic resume runs under a mode the person has not been told about — and as a live append, like the pause notice's, so a tab that reconnected before the boot task ran sees it on the wire ahead of the resumed turn, not on its next transcript reload (Opus review, rev 36: the notice had used the replay-mode append, so a connected dashboard saw the "Resumed automatically" card and its approval card without the row saying why; proven red on the old append, which reached no connected client). Nothing is said where the grant comes back by its own mechanism with no person to act: a mate thread whose recorded mode is the one its profile declares (an unset mode declares Trust), because _seed_member_approval re-applies it when the thread is opened — unless the boot pass may resume that thread first (switch on, turn cut), since the resumed turn would run under the mode the thread holds now; a thread that held a grant its profile does not declare (an approval card's "trust this session"), or whose mate record is gone, is always told, with a remedy that names the Mates page and the profile rather than the Approval mode control the Mates chat does not have (the ordinary chat's remedy names that control as the screen labels it — To turn it back on, choose Trust in Approval mode under the message box. — not the codebase's "composer"; UX review, rev 29); an app-owned chat, whose app owns the grant and which neither pass resumes; and any chat under the process-global agent.dangerously_skip_permissions, where nothing asks. Two details found in review of this PR's own code: the interruption row makes a cut-off chat dirty, so the 5 s flush can save it during a long restore — the save must therefore not refresh the in-memory record, or the notice compares against the wrong mode (pinned end to end through the real save); and a Trust pick on an idle chat now dirties the chats whose mode moved (approval_modes / mark_changed_approval_modes in apply_approval_mode), because a pick appends no row and the flush skips a slot that is not dirty. The mate seed is left alone: re-opening a bound thread is a no-op by contract, and the seeded mode reaches the line with the thread's next save.

Config. DashboardConfig gains resume_interrupted_turns (bool, default false), resume_retry_limit (1–20, default 5) and resume_retry_window_minutes (5–1440, default 60); RESUME_RETRY_* in config/sections_limits.py are the bounds the loader and PUT /api/dashboard/config clamp to (type-checked; 400 with a code on a non-boolean, a non-integer, a fraction or a JSON Infinity), mirrored by the Settings controls. config-baseline.json regenerated.

Frontend. Settings → Chat → Sessions: the switch under Restore Sessions, with the per-chat limit (Max automatic resumes per chat, stepper 1–20) and the window (Resume limit window, select 5m … 24h) shown while it is on; the window row's always-visible description states the two as one rule with the live values — At most 5 automatic resumes per chat in any 1 hour. — since a "5" and a "1h" read apart say nothing (UX review, rev 25), spelling the window out while the select keeps its 1h (UX review, rev 34: inside prose the abbreviation reads like a typo; resumeWindowProse, Intl long units, so each locale gets its own word and plural), opening with the rule rather than Together with the limit above: (UX review, rev 33: a reader landing on the row from the palette met the pointer before the rule); it is a plural catalog key (resume_retry_limit_summary, registered in pluralKeys.json) and names the window with the select's own label (resumeWindowLabel, falling back to a localized number of minutes for a hand-edited value the select does not offer); the window row carries no ⓘ tip, since the one it had only restated that sentence (UX review, rev 26); its second sentence — At the limit the chat pauses and asks you to press Resume, which resets the count. — moved there from the limit stepper's ⓘ tip (UX review, rev 42: a person choosing the number needs it before choosing, and a tip is read after), so the tip keeps only what the sentence does not carry, that restart and error resumes are counted together. RecoveryCard draws the two new kinds as Resumed automatically · after the app restarted / after an error ended the turn · attempt n of limit, reading the count the dispatcher stamped on meta.autoResume and dropping it when the stamp is absent or malformed; parseRecoveryMessage gains an optional meta argument, so the callers that have no row to hand over are unchanged. The two notice kinds join SYSTEM_NOTICE_KINDS on both sides so every last-real-message scan looks through them; they render through the existing NoticeCard. ErrorCard gains resumed, which the two error renderers set from the rows BELOW the row (automaticResumeFollows in RecoveryCard: the next row that bears on the turn is an auto_restart / auto_error continuation — never inferred from the missing Resume button, which a row the person typed past lacks too): the restart row and a resumed give-up or "please retry" row keep their cause and drop the instruction ("This turn was interrupted when the app restarted. Its partial output is above.", "The connection was unstable." — the restart row alone keeps the pointer its wire text had, since that output may be work the resumed turn repeats; UX review, rev 42) and lose the danger tint, since the Resumed automatically card directly under them says the rest — one fact, said once. A person's own Resume press is deliberately not a case — the row it follows keeps the rendering it has always had — and every other row keeps its wire text. New i18n keys under pages.chat.recoveryCard, pages.chat.errorCard and pages.settings.chatPanel, fanned out to every locale; the command-palette settings registry is regenerated.

Docs. docs/system-specs/modules/history.md (restore gains the two passes, the metadata line gains the two keys), docs/system-specs/modules/config.md (the three keys), docs/system-specs/modules/session.md (the interrupted-turn restore names the shared set), docs/system-specs/common/error-handling.md (the closed retry list and the tag-at-the-writer rule, so a later "please retry" row is not left out of retries unknowingly -- Design, rev 44), docs/feature-map/README.md (one row).

Rebased onto 3d09514228. Upstream split chat_handlers.py into chat_api/ owners and ChatPanel.tsx into chatPanelOptions.ts / useChatPanelDashboard.ts meanwhile; the hooks live in the owners now (chat_send, continue_turn, turn_stop, slot_approval, slot_lifecycle), read restart_resume through the facade binding the composition contract prescribes, and the pinned continue_slot_turn signature in test_chat_handlers_composition_contract.py carries body.

Tests

Backend (test/test_restart_resume.py, 111 tests, test_restart_resume_boot_path.py, 3, and test_restart_resume_budget.py, plus test_slot_metadata_codec.py golden and the two composition contracts):

  • Classifier: every turn-error arm in test_chat_runner_turn_error_arms.py now declares whether the error pass resumes its last row, and a pin holds each declaration to RETRYABLE_ERROR_KINDS, so a new arm has to choose and a kind moved into or out of the set shows up as the arms it moves (Design review, rev 64); the restart row is a restart candidate and not an error one; a cut turn the marker names qualifies whatever its tail ([user], [user, tool], a cut recovery turn); an interruption row an earlier boot left is not this boot's; a marked turn that ended in another error was failing before the restart; each retryable tail (transient_give_up, session_start_failed, retry_invited) is retried; each never-retried kind (turn timeout, auth, entitlement, usage limit, context overflow, app-agent refusal, chat-close interruption, and a kind that does not exist yet) is not; a row off the list is never retried whatever its words — approval timeout, approval no-budget, session stuck, turn_failed, an untagged "please retry" row an older gateway persisted, a "please retry" row under an unknown kind, an unclassified error (proven red on the old code, which retried the untagged ones) — and the list is pinned to name structural kinds only; test_chat_runner_turn_error_arms.py pins that the shared-process-exhausted and nested-prompt-busy arms stamp retry_invited on their terminal row; a clean completion, a Stop card, a give-up row written after a Stop -- including one the ladder marked stopped with no Stop card on the slot, the shape a Stop from a linked channel leaves -- an older error behind a finished turn are left alone; a restricted (incognito/temporary) chat is never eligible; a held note flushed after the error does not hide it, an untagged inject after an old error does not make a restart candidate (proven red on the old opener rule), a dispatching inject does open a turn; a restart during the ladder's wait is this restart's (the pending transient_retry row, bare and after a user_replay; proven red); an app-owned chat is never eligible.
  • Budget: the stamp is written to the line before the resume is queued (the write is awaited, carries the stamp, requires an existing line, and is fenced to the slot incarnation), a write that returned false or raised refuses the resume and spends nothing (proven red on the old code, which queued the resume without any write and ran it past a failing one), a write the log skipped is not a write — a legacy row-first transcript keeps its first line and the merge reports that no-op as success unless asked to require a write, so the commit requires one (proven red on the old code against a real conversation log: the commit answered true and the resume ran on a stamp that existed nowhere on disk), no conversation log means no automatic resume, a refusal under the lock takes the stamp back in memory, a person's reset is pending until a save carries the empty list and the next turn marker writes [] (a chat that never had an automatic resume still writes its old bytes); a reset that lands while a stamp's write is in flight survives it — the write is fenced to the list it carries, decided under the line's lock, so a reset that landed first refuses the write and the resume, and a reset that landed after it leaves the flag pending for the person's turn marker to carry, in _commit_budget and in the marker alike (four tests, proven red on the old code, where the older write cleared the flag and the person's turn never wrote the empty list); the marker clears the flag only after a confirmed write — when the merge found no line, its forced-save fallback is judged by its result, not best-effort, and a fallback that failed or was refused leaves the reset pending and re-arms the flush (proven red on the old code, which cleared the flag over a best-effort save that had swallowed its failure); stamps are written to the line and read back; a malformed, oversize, non-finite or future list reads as FULL and invalid; an integer too large for a float reads as FULL instead of raising, and a tampered line still lets the chat open through the real codec read (proven red: OverflowError at the conversion on the old code); a malformed line is named in the log; the window prunes and a stamp exactly one window old still counts; a person's reset clears stamps, cancels the armed retry and moves the epoch; the backoff curve; settings floor at the config bounds.
  • Boot pass: off by default dispatches nothing; only the chats the restart cut off are resumed and only flagged windows are loaded (a stale row and a clean chat are never touched); the body is not the manual one; a refusal costs nothing; one chat's failure does not end the pass; the budget survives a restart and pauses at the limit (one notice, one notification, no stacking); the notification body opens with what happened (Example session: automatic resume used all 5 resumes in 1 hour. Press Resume in the chat to continue., and the lowered-limit and unreadable variants) rather than with the row's its (UX review, rev 31; proven red: the old body read Example session: its limit of 5 resumes in 1h is spent); a malformed persisted list refuses the automation only and the notice states the pause and what the Resume press above also does (⚠️ Automatic resume is paused here. Pressing Resume also resets this chat's automatic-resume count. — no cause, no "record", no count, no "for this chat for" (UX review, rev 28), and no second "press Resume" (UX review, rev 32: the error row it sits under already carries the button and its own "try again", so the two read as two problems with one shared button; proven red: the old row ended Press Resume to continue now; that also resets the count.) and no duration (UX review, rev 29: the hour re-allows later resumes, it does not re-arm this one); proven red on each previous wording); a lowered limit is quoted, not mistaken for a broken record; the pause notice is one per budget, not one per failed unattended turn, and a new one is warranted once the automation has spent the budget again; remote, closing and restricted slots are skipped; a chat the person touched first is skipped (the pass held inside its cautious-boot pause, no settle timing); turning the switch off during the pause ends the pass and spends this boot's reading for the chats it left. The dispatch asks continue_slot_turn for an interrupted turn.
  • Error pass: a channel Stop recorded between the turn's entry and the scheduling arms nothing — the key is armed to read the count and disarmed again, no task, no stamp (proven red on the rev-65 code: assert True is False, the retry was armed); an unchanged count arms as before, and a turn that never reached its entry snapshot (no reading on the slot) compares nothing and arms as before; every turn-error arm leaves the entry reading on the slot (proven red on the rev-65 code: None == 0); arms one backed-off retry (held inside its backoff by a seam, no wall-clock sleep) and dispatches it; switch off in the snapshot arms nothing; switch off on disk arms but dispatches nothing; switching off during the backoff stops the retry; a never-retry kind arms nothing; a send during the backoff cancels the retry; a person's action after the backoff refuses the dispatch under the lock; the switch turned off while the budget write was in flight refuses the insert under the lock too and takes the stamp back (GPT review, rev 45: the pre-insert hook read only the epoch, which a setting change does not move, so a resume could start after the operator had switched the feature off -- proven red on the old hook); a Stop issued on a linked channel during the ladder's backoff ends the matter: the give-up writers mark the row stopped when the stop signal caused it and the pass refuses the marked row (Opus review, rev 46: such a Stop moves the session's stop counter alone -- no Stop card on the slot -- so the pass saw a plain give-up row and resumed the turn the person had just stopped; the mark is proven on the pre-token replay arm, the post-token continue arm and the Stop-already-active path for a dashboard Stop, a resolved Stop and a channel Stop, and absent for a steer or a follow-up; the classifier's refusal proven red on the unmarked row); a channel Stop against the idle chat while a resume is pending drops it: both passes record the session's Stop count when the resume is armed (per candidate, for the boot pass), drop it when the count moved by the end of the wait, and the pre-insert hook compares it a last time under the lock (GPT review, rev 47: Slack's !stop on an idle chat moves that count and nothing on the slot -- no Stop card, no epoch, no cancel -- so the armed retry resumed the work the person had just stopped; three tests proven red: the backoff, the under-lock hook, the boot settle); a Stop that lands while a restored chat's window is still loading is kept: the codec's second reading of the restart marker combines with the flag a Stop cleared instead of assigning over it, and the boot pass reads the session's Stop count before that load (GPT review, rev 48: the deferred settle wrote _restart_cut_turn = had_marker after cancel_armed_retry had cleared it, so a Stop during the worker-thread load was undone and the chat resumed; both proven red); and the session manager now records a Stop for a chat with an automatic resume armed even though it has no live session -- SessionManager.arm_resume / disarm_resume (counted, canonical key), called by both passes around their waits; note_stop treats an armed key like an open replay gap (GPT review, rev 49: a restored chat has no session until its first turn, and a failed turn may have torn its session down, so Slack's !stop on the idle thread recorded nothing, replied "Nothing running.", and the stop-count compare saw no change -- the resume ran; proven red with the real manager on both passes, plus a stop-gate test of the rule); and the restore arms a cut chat's key the moment the open tabs exist (arm_cut_chats, synchronous, once per chat, the reading kept on the slot), with start_boot_pass arming before the boot task exists and disarming when it ends however it ends (GPT review, rev 50: the boot task runs the approval-mode notice pass first, which awaits deferred transcript loads, and the restore itself awaits after the slots exist -- a Slack !stop landing in either window came before any arming and was dropped; proven red through start_boot_pass with the real manager, plus the restore-order pin); the stamp is spent under the lock before the insert (what the turn-start save sees is already 1); at the limit the pass pauses instead. The shared dispatcher stand-in runs before_dispatch and lands the row the drain would, so a hook that refused every dispatch would fail these tests; another driver landing first during the backoff (an agent's session_retry, a cron's next delivery) leaves nothing for the retry — it re-reads the tail, dispatches nothing and spends nothing (the test fails if that re-read is removed).
  • Continue body: an automatic body is queued verbatim, keeps the budget and writes via to the audit line; a person's Resume resets the budget; an agent's Continue leaves it alone; a superseded dispatch is refused under the lock. Who counts as a person for the budget is the send route's rule on the Continue endpoint too — no app token and no attested cron creator — carried as continue_slot_turn's own resets_budget argument, separate from the queue entry's provenance (directive_user_origin stays the send route's not request_app on both routes), so a cron's Continue dispatches exactly as its send does and, like it, leaves the budget alone (Opus review, rev 36: the endpoint derived the reset from the app token alone, so an attested internal caller with no app could reset the budget that caps unattended resumes; First Principles review, rev 37: the first fix narrowed the provenance flag instead, which a cron's send does not — the test now pins both halves, proven red on the old route for each: the stamps cleared, and the provenance downgraded).
  • A person's actions (route level): a person's send resets the budget while an app's send of the same message does not; a send the route refuses — the member pin's 409, the agent-mismatch 409, the empty-message 400 — leaves the budget, the armed retry and the restart flag exactly as it found them (proven red on the old code, which reset the budget above those refusals), while a send queued behind a running turn still resets; a Stop and a close drop the armed retry; a Stop on the idle restored chat while the boot pass waits in its pause skips the chat — the route answers on its no-op branch, writes no card, the pass dispatches nothing and the spent budget stands (proven red on the old code, which dispatched the resume after the Stop). The settled row keeps one DOM node across the settle and carries the colour transition (proven red on the old card, which had no transition).
  • Approval-mode notice: the recorded mode is never applied to the flags; the save writes a grant and omits the default, and the writers leave the record alone; a dropped Trust gets one notice (idempotent); nothing to say for "" / normal; no notice when the mode came back; no notice under the global override; the wrapper tells only the chats that changed and loads only their windows; the pass is inert while the switch is off — nothing told, no window loaded, the record kept (proven red on the previous code, which told the chat regardless); a mate thread whose declared mode comes back is not told (unset, declared, and no config alike), one the boot pass may resume first is not told either — turn admission re-applies the declared mode before the resumed turn claims a provider (Opus review, rev 56; proven red on the previous code, which told it "now on Ask every time"), and the exemption is decided from the chat's execution record the way the admission seed decides it, so a mate's worker and a conductor chat outside the thread are not told either while a chat whose record cannot be read is (Opus review, rev 57; proven red on the previous code, which told the worker), one that held a grant its profile does not declare is told, one whose mate record is gone is told, an app-owned chat is not told; both notices write their api_access audit lines (caller, operation, outcome pinned); a pick dirties the chats whose mode moved; the picker route dirties the chat.
  • Settings: loader defaults and clamps; the dashboard-config endpoint round-trips and rejects a non-boolean, a non-integer limit, a non-integer window, Infinity, NaN and a fraction, each with its code.
  • Through the real persistence path: a trusted chat cut mid-turn comes back flagged, told and resumed, its stamp survives the next boot and the second boot (no marker) resumes nothing; a flush landing mid-restore cannot erase what the person saw (proven red when the save writer refreshes the record: 'normal' == 'trust'); the stamp rides the turn-start save. The two changes that reach a gateway with the switch off are tested with it off (Design review, rev 60): TestMarkerFallbackSave drives the real _begin_local_turn_marker with no switch set — a forced save that raised or answered false keeps the reset pending, marks the slot dirty and still returns (the turn is admitted), a confirmed one clears the reset — and the codec's test_both_save_forms_write_every_slot_owned_key pins approval_mode_seen among the keys both the full save and the merge write.
  • What a natively resumed session is shown: both automatic bodies are in _INTERRUPTED_TURN_RESUME_MSGS and the prompt phase's body (_turn_build_prompt, which _run_chat awaits) reads that name rather than a literal; interrupted_turn_rows drops the notice and keeps the opener and partial answer as the same row objects, the builder restores the same frame from them, and the prompt phase's body reads that helper's name too (the notice lands in the span the builder quotes as the model's words — pinned as the hazard and the fix). End to end through _run_chat on a natively resumed session with no automatic resume involved (test_dashboard_chat_turn_wiring.py): a plain Resume press on a turn the ladder's compact-after-turn notice sits in hands the model its own words and not the notice — red on main, where the prompt sent reads … first event at 09:14 / 🔄 Context at 81% (limit 80%) — will compact after this turn. / [END INTERRUPTED TURN].

Frontend (RecoveryCard.test.tsx, ErrorCard.test.tsx, SettingsChatPanelCoverage.test.tsx, CompactionCard.test.tsx, ChatPanel.railRegistry.test.tsx): the Trust bullet's restart sentence is pinned with its mate-chat exception, in the rendered bullet and in the catalog key (proven red on the rev-64 copy, which ended at "waits for you."); the two kinds title as automatic and name their cause; a row is stated as settled only when the gateway's own automatic resume actually followed it (automaticResumeFollows: an auto_restart / auto_error continuation below the row; false for the newest row, for a row the person typed past, for a person's own Resume press, for a cron that came next, and for a reply the turn produced with no continuation; a notice, a later error or a steer is walked past) — the interruption rows drop their "then resume to continue", the ladder's give-up and "please retry" rows (connection lost, session busy, turn/tool stalled, shared process restarting) state the cause and that the turn was resumed, all in a neutral tone instead of the danger tint; a row with no Resume button is never settled on that alone (proven red on the old code, which rewrote every button-less interruption row — including one the person typed past); the newest row, with Resume offered, keeps the wire text; a row whose words carry no instruction is left verbatim; the stamped attempt count is appended and a malformed stamp is dropped; the manual Continue copy is untouched; resolveInjectCard hands the row meta to the parser; the rows render as routine (no act-required styling); the limit and window hide until the switch is on (both by data-setting-key, the attribute SettingsField renders); the stepper persists up/down, never writes past 20 and disables − at 1; the window select persists; both notice kinds route to NoticeCard; the registry carries the new rows; both consequences of the switch — the repeated work and the Trust — are the warning the switch is described by, one list item each and each opening with its risk (May repeat work…, Uses model turns and tool calls while you are away., After an error, the resumed turn keeps the chat's Trust approval mode, so its tools run without asking. After a restart, a resumed turn that needs a tool approval waits for you. — three bullets, the spend split out of the repeat-work bullet and proven red on the rev-33 catalog, which held both in one sentence; proven red on the rev-32 catalog, which said Trust mode; the Trust bullet carrying both halves of the approval rule and the help stopping at the limit, proven red on the rev-35 catalog, where the restart half was the help's second sentence) — the first keeps since nobody reviewed its partial output over the suggested unreviewed (UX review, rev 29): the clause says why the work may repeat, and the one-word form dangles, and its grey help is the plain statement of what it does, with no ⓘ tip on the row (proven red on the previous panel, where the warning was one paragraph of two sentences with the risks buried mid-sentence; earlier, where it carried only the Trust line, the help was the cost paragraph and the feature statement sat behind the ⓘ), and the English catalog keeps the feature statement, the cost and the Trust consequence in three keys with no _hint, the window row has no _hint either, and the limit's label reads as a maximum (Max automatic resumes per chat); the window row states the limit and the window as one rule with both live values, opening with the rule (At most 5 automatic resumes per chat in any 1 hour., singular at 1, no Together with opener, the window spelled out and the select still 1h — proven red on the rev-33 catalog, re-stated as the limit steps and as a window is picked; an off-list window reads as localized minutes) and the Trust consequence names the mode as the Trust mode picked in the composer (both proven red on the previous panel and catalog). The i18n wire-marker exemption is unchanged (the prefixes have no comma).

Manual verification

Rendered the stills and the recording below from synthetic transcripts and fixtures against the built SPA (the repository's transcript and settings harnesses), and read every card. Not performed against a live gateway restart; the restore path is pinned end to end by TestThroughTheRestore through the real restore_recent_sessions and _save_slot_to_history.

Screenshots / video

Settings → Chat → Sessions, switch off (the default) and on. The copy under the switch is the row's grey help plus a warning block (UX review, revs 22–24: the Trust consequence was first the tail of a paragraph, then a second grey line that read as optional help, then the only warning line while the repeat-work risk stayed grey and the feature statement sat behind the ⓘ). The help now says what the switch does — after a restart, or after an error a retry can fix, the app presses Resume for you, up to the per-chat limit below — always visible, no ⓘ tip on the row. The warning block — the warn hue with the triangle, the shape the Security panel uses for its caveats, and the switch's aria-describedby, so it is announced before the flip — holds the consequences as three bullets, each opening with its risk (UX review, rev 26: a two-sentence paragraph buried the second risk; rev 33: one 33-word bullet let the half-applied edit hide behind the cost of model turns): the irreversible one first — it may repeat work the cut-off turn had begun (an edit half applied, a message sent), since nobody reviewed its partial output; then the routine spend — it uses model turns and tool calls while nobody is watching; then the limit of unattended work per pass — after an error, the resumed turn keeps the chat's Trust approval mode, so its tools run without asking, because the error pass runs in the same process and inherits the chat's mode (UX review, rev 27: the earlier picked in the composer after an error let "after an error" attach to the picking; rev 32: Trust mode named a control that does not exist — the composer's picker is Approval mode with a Trust segment, which is also how the permissions notice names it). The restart half — a resumed turn that needs a tool approval waits for the person, since the Trust was dropped at the restart — is the Trust bullet's second sentence, the boundary of that risk, rather than a bullet of its own (UX review, rev 28: readers learn to skim amber text, and a reassurance there dilutes the risk beside it) or a sentence of the grey help (UX review, rev 35: a help line stating only the restart half read, skimmed, as every automatic resume stopping at an approval), so the approval rule is read in one place and one style of text. Its one exception is named in the same sentence (Opus review, rev 64): a mate's chat — its thread, a conductor chat or a worker it opened — gets back the approval mode the mate's profile declares, Trust when unset, because turn admission re-seeds it before the resumed turn claims a provider (main's #23010), so a resumed mate turn runs its tools with nobody there; the unqualified sentence promised a wait that does not hold for those chats. The config field's help and RFC §4.1 / §4.6 carry the same exception, and the four Settings stills below are recaptured on that copy. The block is a sibling of the row, not its description, so the row stays SettingsToggle as every other row is and the registry entry carries the feature statement. The limit row is labelled Max automatic resumes per chat, so it reads as a limit and not a count so far (kept over the suggested Maximum — UX review, rev 34 — since the page already has Max Turns per Subagent and Max Concurrent Subagents, so the whole-word premise does not hold); the window row is labelled Resume limit window, a name that stands on its own in the command palette and settings search, its description reads the pair as one rule with the live values, and it has no ⓘ tip (the tip only restated the sentence). It keeps that name rather than Limit resets after (UX review, rev 27): the window slides — each stamp ages out one window after it was spent — so the count never resets at a point in time, and a label promising a reset would describe a timer the budget does not have; nor Count resumes over (UX review, rev 31): the row was Count resumes over the last until rev 25, when the same lane read it as a fragment where the palette lists labels on their own, and two words shorter it is the same fragment — the description under the row already says what the window does (RFC §4.1). The list is painted with text-warn-text, the token the design system darkens under the light mode, rather than the plain hue (UX review, rev 43: the rows had only been shown in the dark theme): plain --warn on the default light palette's white page is 3.4:1, under the 4.5:1 text floor, and below it in 14 of the 19 built-in light palettes; the token reads 9.5:1 there and clears the floor in every light palette, while in dark palettes it is the plain hue, so the dark frames are unchanged. The test pins the class, and the light frame is shown last below:

Resume interrupted turns automatically, off: switch, the help saying what it does, and beneath it the warning list — three bullets under one triangle, "May repeat work…", "Uses model turns and tool calls while you are away." and "After an error, the resumed turn keeps the chat's Trust approval mode, so its tools run without asking. After a restart, a resumed turn that needs a tool approval waits for you — except in a mate's chat, which gets back the approval mode the mate's profile declares (Trust unless that profile says otherwise)."
Switch on: the warning list beneath it, then the "Max automatic resumes per chat" stepper with its ⓘ, then the Resume limit window select with no ⓘ, whose description reads "At most 5 automatic resumes per chat in any 1 hour. At the limit the chat pauses and asks you to press Resume, which resets the count."
Switch on, light theme: the same row on a white page — the three warning sentences in the darkened warn text, the stepper and the window select beneath
Switch on, with the stepper's ⓘ open: the tooltip reads "Restart and error resumes counted together." above the "Max automatic resumes per chat" stepper, the warning list behind it

A turn the restart cut off, resumed automatically. The card reads Resumed automatically · after the app restarted · attempt 1 of 5; the restart row above it keeps its cause only, in the neutral tone:

Restart row stated as settled, then the card "Resumed automatically · after the app restarted · attempt 1 of 5", then the continuation

A turn that ended in the retry ladder's give-up row, resumed after the backoff. The card reads Resumed automatically · after an error ended the turn · attempt 2 of 5:

Give-up row stated as settled, then the card "Resumed automatically · after an error ended the turn · attempt 2 of 5"
The same card expanded: under "Resumed automatically · after an error ended the turn · attempt 2 of 5", the text the gateway sent on the person's behalf — "The previous turn was interrupted before it finished and the gateway has resumed it automatically; no person has looked at the partial output above, so do not read this as an answer to anything you asked. …" — then the continuation
The shared-process row stated as settled with its detail — "The agent process this chat shares kept restarting (exit 137)." — then the card "Resumed automatically · after an error ended the turn · attempt 1 of 5", then the continuation
The connection-lost row stated as settled with its detail — "Connection lost (exit 1)." — then the card "Resumed automatically · after an error ended the turn · attempt 1 of 5", then the continuation
The stalled-turn row stated as settled — "The turn stalled." — then the card "Resumed automatically · after an error ended the turn · attempt 1 of 5", then the continuation
The session-busy row stated as settled — "The session was busy." — then the card "Resumed automatically · after an error ended the turn · attempt 1 of 5", then the continuation
The tool-stalled row stated as settled — "A tool appeared stalled." — then the card "Resumed automatically · after an error ended the turn · attempt 1 of 5", then the continuation

Recording of the restart case landing live: the red restart row with its Resume button, then the automatic continuation arrives (the row turns neutral and keeps only its cause, the Resumed automatically card lands under it), then the reply. Synthetic data; the person pressed nothing:

resume-after-restart-live.mp4

The row settles in place: the live row and the settled row are one DOM node (the card's root div is kept across the re-render), so its tint now transitions (transition-colors duration-200, off under reduced motion) from the danger surface to the muted one instead of cutting; the text itself cuts, since a cross-fade would double the row's height for its duration. The two stills above show the two end states.

Every settled wording, for a read — all seven shown: This turn was interrupted when the app restarted. Its partial output is above. · The connection was unstable. · Connection lost (exit 1). (the detail is the wire row's) · The session was busy. · The turn stalled. · A tool appeared stalled. · The agent process this chat shares kept restarting (…). Each states the cause only — the restart row also where the cut-off work is, the pointer its wire text carried — and the Resumed automatically card under it says the rest. The tool-stalled row names no tool because the wire row it settles (⟳ Tool appeared stalled — please retry.) carries none.

The two notices — a chat that came back with different permissions, and a chat whose budget is spent:

Permissions changed when the app restarted: it was on Trust (tools run without asking) and is now on Ask every time. To turn it back on, choose Trust in Approval mode under the message box.
Automatic resume is paused here: its limit of 5 resumes in 1 hour is spent. Pressing Resume also resets this chat's automatic-resume count — under the give-up row whose Resume button the sentence refers to

Their three other wordings. The shared-process row is the one settled wording that carries a detail (the exit code), so it has a still of its own (UX review, rev 48), and the connection-lost row, a copy variant of the same settled component, has one too (UX review, rev 51), as does the stalled-turn row (UX review, rev 53), and the session-busy and tool-stalled rows, the other two plain-sentence wordings of that shape (UX review, rev 59). The permissions notice on a mate thread, whose composer has no approval picker, so the remedy names the Mates page and the profile; the pause after a person lowered the limit under resumes already taken, which states the new limit rather than the count over it (UX review, rev 35: 3 resumes in the last 1 hour, over its limit of 2 blamed an overrun the person never chose); and the pause when the earlier resumes could not be checked (a stamp list on the metadata line this gateway did not write), which states only the pause and the remedy, no cause and no duration — the cause (a stamp list this gateway did not write) is in the gateway log and nothing the person can act on, and a duration would read as a promise to pick the chat up later (UX review, rev 30), so the row leads straight to the Resume press that resets the count:

Permissions changed when the app restarted, on a mate thread: "Open this chat from the Mates page to re-apply the mode its profile declares; set that mode in the profile to keep it across restarts."
Automatic resume is paused here: the limit is now 2 resumes in 1 hour. Pressing Resume also resets this chat's automatic-resume count — under a give-up row with its Resume button
Automatic resume is paused here. Pressing Resume also resets this chat's automatic-resume count — under the restart row with its Resume button

The pause also raises one system notification, the signal a person who is away gets (UX review, rev 29): in the Notifications feed as Automatic resume paused with the chat's title and what happened — the body opens with the event, automatic resume used all 5 resumes in 1 hour, because the feed row cuts it off after a few words and the chat row's its has no referent after a title (UX review, rev 31) — and opened, with the full body and the Go to Chat button that leads to the Resume the body names:

The Notifications page: the Activity Feed row "Automatic resume paused — Example session: automatic resume used a…", and the opened note with the full body "Example session: automatic resume used all 5 resumes in 1 hour. Press Resume in the chat to continue." and a Go to Chat button

Related Issues

Part of #13838 (the restart half, widened from unattended-only to a dashboard setting; the refusal half and the stale verb are not here).
Part of #12275 (its fallback half, behind the switch: the chat is told, in the chat, when its mode changed; nothing is restored).

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

Contribution License Agreement

Contributions are accepted under the project's Apache-2.0 license; see the Licensing section of CONTRIBUTING.md.

@hickoxd
hickoxd requested review from a team and KiroCrewCaptain as code owners October 15, 2026 08:28
@hickoxd
hickoxd requested a review from buluoray October 15, 2026 08:28
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Oct 15, 2026
@hickoxd
hickoxd force-pushed the feat/restart-resume branch from 14854f9 to c4103da Compare October 15, 2026 09:27
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Design Review (Opus 5.5, fork) — ✅ PASS

Design-level review of 1740c8a9335a93a99f326ff97eec829b5db517c3 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS does not.

Design-Verdict: PASS

Nothing blocks: the feature is off by default and capped by a per-chat budget, and it resumes through the same continue_slot_turn gate as the Resume button. Every take-away I traced, including the moved TURN_FAILED_META and the new metadata keys, has a compatible reader.

Notes

  • The change to the restored-turn quote, which now leaves out system notices, ships even with the switch off. It also changes the manual Resume path. The description states this and backs it with a test that fails on main. A defect there would show up for every native resume, not only automatic ones.
  • The RFC ships in this PR as draft. Its acceptance (§9) is a merge decision for the maintainer. That governance step is still open.

[DESIGN-REVIEWED] 1740c8a

Description read

[DESCRIPTION-READ] 310bb0ff3e977b6999751cf922307615719a749b61b1042f2edb9c1e1c7612ff

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 .github/scripts/pr-description-capture.sh over the same inputs; if it differs, the description or that evidence changed after this verdict was formed and any finding drawn from either is unproven.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

UX Review (Opus 5.5, fork) — ✅ PASS

UX-level review of 1740c8a9335a93a99f326ff97eec829b5db517c3 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS does not.

UX-Verdict: PASS

Nothing blocks: the 18 stills and one recording show every control and state this PR adds or changes. This fork lane has no blind read, so nobody has yet checked whether a first-time user understands them; pushing the branch to this repository runs that check.

Notes

  • resume_interrupted_turns_consequence packs three rules (error, restart, a mate's chat) into one 50-word bullet (attachment-01). Split it into two bullets, error and restart, and move the mate's-chat exception into its own short line.

[UX-REVIEWED] 1740c8a

Description read

[DESCRIPTION-READ] cd58e98a678d8814059467e17aa0a92ea4c82dd2419a6d8b629ee4c0e915b271

That sha256 names everything this verdict read: the PR description, plus the 21 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 .github/scripts/pr-description-capture.sh over the same inputs; if it differs, the description or that evidence changed after this verdict was formed and any finding drawn from either is unproven.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

First Principles Review (Opus 5.5, fork) — ✅ PASS

Premise-level review of 1740c8a9335a93a99f326ff97eec829b5db517c3 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS does not.

First-Principles-Verdict: PASS

Nothing blocks: the feature is a new switch that ships off, and the one default-on change fixes a quoting defect that a test pins. One gated add-on goes beyond the Goal.

Notes

  • Item 7 — oversized: the Goal is "picks that turn back up on its own … and a chat that came back with different permissions says so". Rewriting the earlier error rows as settled history goes beyond that. The Resumed automatically card below them already says the turn resumed. Subtraction: delete settledProse, INTERRUPTION_PROSE, RETRY_SETTLED_KEYS, SHARED_PROCESS_WIRE, the resumed prop in ErrorCard.tsx, and the 7 settled_* / interrupted_restart_settled keys in each of the 14 locales.

What this change ships

Inventory (10 items) — 9 justified

Intent: a person who leaves a long task running gets it continued after a restart or a retryable error without having to press Resume. This is an ADDITION, plus one fix.

  1. New setting, off by default: automatic Resume after a restart (the boot pass) — justified
  2. Automatic Resume after a retryable error, with a backoff of 30 s doubling to 10 min — justified
  3. A per-chat limit and window that persist across restarts, a pause notice, and a reset when a person sends or presses Resume — justified
  4. A notice when a restored chat came back with a different approval mode (needs the switch on) — justified
  5. A new Resumed automatically card that names the cause and the attempt count — justified
  6. On a natively resumed session, the restored-turn quote no longer includes gateway notices (default-on fix, pinned by a test) — justified
  7. Error rows reworded as settled once an automatic resume follows them — oversized, see Notes
  8. Terminal "please retry" error rows tagged retry_invited, and give-up rows marked stopped — justified
  9. Stop, close, or a Stop from a linked channel cancels an armed resume — justified
  10. The approval mode last seen is recorded on the metadata line, and the picker marks the slot dirty when the mode changes (also with the switch off) — justified

[FIRST-PRINCIPLES-REVIEWED] 1740c8a

Description read

[DESCRIPTION-READ] 5f28a9e347b04b2cb70395e43f61f597bbaee85400530b8da8b9ee8869d2896d

That sha256 names the PR description this verdict read. Recompute it with .github/scripts/pr-description-capture.sh; if it differs, the description changed after this verdict was formed and any finding drawn from the description is unproven.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Opus 5.5 Review (fork) — ✅ no blocking findings

Reviewed 1740c8a9335a93a99f326ff97eec829b5db517c3 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 1740c8a

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

GPT 6.1 Review (fork) — ✅ no blocking findings (all downgraded on adjudication)

Reviewed 1740c8a9335a93a99f326ff97eec829b5db517c3 via the fork AI-review pipeline; updated in place on each push.

Opus 5.5 adjudication downgraded every blocking finding. Downgraded: the remedy each one needs is disproportionate to the harm it prevents. Adjudication downgraded all 1 blocking finding(s): the remedy each one requires is disproportionate to the harm it prevents.

Review details

BLOCKING -- src/kiro_crew/dashboard/restart_resume.py:968 -- Stop tracking starts after the failed-session teardown gap

_arm_stops(state, slot)
Trigger: With automatic resume enabled, a Slack-linked chat exhausts recovery after repeated shared-runtime deaths. _turn_release_session calls reset, which removes the live session before awaiting teardown. A Slack !stop during those awaits reaches note_stop before this scheduler arms the key; with no session, replay gap or armed resume, it records nothing.
Outcome: The Stop count remains unchanged, so the entry-count comparison and dispatch guards pass, and the chat resumes automatically despite the person's Stop.
Anchor: defect
Fix: Arm Stop tracking before teardown removes the failed session, and retain it through the automatic-resume scheduling decision.

[BLOCK-MERGE-DOWNGRADED] 1740c8a
[GPT-REVIEWED] 1740c8a

Adjudication (Opus 5.5) — is blocking on each finding proportionate?

The single finding, F1, is downgraded: it no longer blocks the merge.

F1: LOW.

  • Conditions:

    • The switch is off by default (patch:290, dashboard.resume_interrupted_turns false), so the operator must have turned it on.
    • The turn must have ended in a reset (session_reset.py:386), which pops the session before its awaits (session_lifecycle_parts/teardown.py:187).
    • A Slack Stop must land inside those teardown awaits, before _arm_stops runs (patch:3492). Outside the live map, replay gaps and armed resumes, nothing is recorded (patch:4373-4378).
  • Recovery: the retry is visible and waits out a backoff first (patch:3523). A Stop during that wait is recorded because the key is already armed, and it cancels the retry (patch:3529-3535). The resumed turn is an ordinary turn the person can see and stop.

  • Fix cost: arming in session_reset before teardown, with a matching disarm carried through the scheduling decision in restart_resume. That is a new ordering contract between modules, kept only for a sub-second window on an opt-in path. The cost outweighs a one-off, visible, stoppable extra turn.

    [ADJUDICATION] 1740c8a total=1 uphold=0 downgrade=1
    DOWNGRADE F1 src/kiro_crew/dashboard/restart_resume.py:968 reason=disproportionate-remedy
    [GPT-ADJUDICATED] 1740c8a

@hickoxd
hickoxd force-pushed the feat/restart-resume branch from c4103da to 6d78286 Compare October 15, 2026 10:26
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Oct 15, 2026
@hickoxd
hickoxd force-pushed the feat/restart-resume branch 2 times, most recently from 1a0d251 to 5ee961f Compare October 15, 2026 13:04
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Oct 15, 2026
@hickoxd
hickoxd force-pushed the feat/restart-resume branch from 5ee961f to 449c4db Compare October 15, 2026 14:11
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Oct 15, 2026
@github-actions github-actions Bot removed the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Oct 15, 2026
@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Oct 16, 2026
@hickoxd
hickoxd force-pushed the feat/restart-resume branch from 46d3422 to 9d78b06 Compare October 16, 2026 01:22
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Oct 16, 2026
@hickoxd
hickoxd force-pushed the feat/restart-resume branch from 9d78b06 to 82df174 Compare October 16, 2026 02:03
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Oct 16, 2026
@hickoxd
hickoxd force-pushed the feat/restart-resume branch from 82df174 to 367b33e Compare October 16, 2026 09:10
@github-actions github-actions Bot added merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Oct 16, 2026
@hickoxd
hickoxd force-pushed the feat/restart-resume branch from 367b33e to f2e6fbe Compare October 16, 2026 19:11
@github-actions github-actions Bot added merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Oct 16, 2026
@hickoxd
hickoxd force-pushed the feat/restart-resume branch from f2e6fbe to 747dd00 Compare October 16, 2026 22:00
@github-actions github-actions Bot removed the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Oct 16, 2026
@iamwhatever

Copy link
Copy Markdown
Contributor

HOLD on 391f8d8. The branch does not merge into main, and a human needs to review it.

  • Conflict: docs/request-for-change/README.md and website/src/i18n/pluralKeys.json conflict with main. A rebase is needed, and that new head will need a fresh review.
  • Reserved for human review: slot_approval.py (the approval-mode handler) gains bookkeeping, and the feature dispatches gateway-authored turns with nobody present. I found no change to admission: _dispatch goes through continue_slot_turn with no human-origin flag. It is still a permission-path change, so a maintainer should sign off.
  • Maintainer decision: rfc-restart-resume §9 ships as draft, and the Design lane's PASS depends on a maintainer accepting it.
  • Unanswered AI items on this head: UX CONCERNS (two copy fixes), First Principles CONCERNS (resume_retry_window_minutes) and the Opus advisory on the helpers in metadata_codec.py. The description argues some of these, but none has a reply on the PR. Please answer each one, by fixing it or giving a reason.

The design reads carefully. It is off by default, the budget is written before dispatch and is fail-closed, and a Stop or a person's send cancels an armed retry. CI is green.

— crew-pr-reviewer (r114)

@hickoxd

hickoxd commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Answering the HOLD on 391f8d8, item by item. The new head is b26cc65.

Conflict. Rebased onto main at 6432cad; both files (docs/request-for-change/README.md, website/src/i18n/pluralKeys.json) were index-order conflicts and resolved with main's rows kept and ours appended. The PR diff is unchanged apart from the rebase and the items below.

Reserved for human review. Agreed that a maintainer signs off; nothing here pre-empts that. One correction to the reading: _dispatch does not go through continue_slot_turn with no origin flag -- it passes directive_user_origin=False and via=restart|error explicitly (restart_resume.py, the continue_slot_turn(...) call). That flag is what keeps a gateway-authored resume from counting as a person's action (so it never resets the budget), and via is what the audit line carries. Admission itself is unchanged: the same refusals apply as for a person's Resume press (running, queued, approval pending, not interrupted), and the resume runs only when the switch is on, inside the per-chat budget, which is written to disk before dispatch and fails closed.

Maintainer decision (RFC §9). Still draft, deliberately: the acceptance is the maintainer's to record, and the body's "Decision for the owner" section lists the two choices a reviewer may want to overrule. If the preference is to land the RFC separately first, I will split it out.

UX CONCERNS (two copy fixes). Both taken in this head: the window row's description now opens with the rule (At most 5 automatic resumes per chat in any 1h.) instead of a back-reference, and the first warning bullet is split so the irreversible risk (a half-applied edit, a message sent twice) and the routine spend are separate bullets. Regression tests were proven red on the previous catalog; stills in the body are recaptured.

First Principles CONCERNS (resume_retry_window_minutes). Not changed, on purpose, and open to a maintainer's call either way. The lane's own framing is that the limit alone is justified and the window is a second knob; the reason it is a setting rather than a constant is the same one restore_window_minutes has in the row above it -- the two sit together and read as one rule -- and the select's floor is what makes the limit meaningful for the backoff it bounds. If the maintainers prefer a fixed 60 minutes, the subtraction is mechanical (config field, loader read, PUT clamp, select, 15 catalog strings) and I will make it before merge rather than after.

Opus advisory (helpers in metadata_codec.py). Taken in this head: live_approval_mode, approval_modes and mark_changed_approval_modes moved to a sibling module, dashboard/approval_mode_record.py, beside the codec's facade the way the stamp validator sits; the codec re-exports them for its line writer. A placement test pins it (the helpers are defined in the sibling module; the codec defines none of them). What stays in the codec is the two fields' own readers, Field entries and order rows (+97 lines on a 1,705-line file), which is the codec's job; the readers of the live mode were the new responsibility the rule names.

@github-actions

Copy link
Copy Markdown
Contributor

Security Scope Review (fork) — 🔴 BLOCK (blocking)

Which legitimate operations does f3033bfc5c1205a31d721bd7eb260560a601c326 newly refuse? Model proposes, scripts/deny_diff.py decides — updated in place on each push. A script-confirmed regression blocks PR readiness.

the review scope was never resolved: the generate job did not finish (it timed out, failed or was cancelled), so no leg ran; its log names the step that stopped

No [SCOPE-REVIEWED] marker for this head was produced, so the model's text is withheld: it cannot be shown to describe this revision. See the job logs.

…dget

Propose an off-by-default dashboard setting that presses Resume on the
person's behalf after a gateway restart and after a retryable terminal
error, through the same continue_slot_turn path the button uses, with
exponential backoff and a per-chat budget that survives a restart.

Permissions are told, not restored: the metadata line records the
approval mode the chat last held, and a chat that comes back holding a
different one gets a system notice before any automatic resume runs.
Nothing re-applies or persists a grant across a restart; which grants
survive which restarts stays with rfc-crash-only-session-trust and the
decision requested on kirodotdev#12275.
A new dashboard setting, off by default, makes the gateway press Resume on
the person's behalf: after a restart, for every restored chat whose turn
the previous process never finished, and after a turn ends in an error a
retry can plausibly fix, with a backoff of 30 s doubling to 10 min. Both
passes go through `continue_slot_turn`, so every state the Resume button
refuses is refused here too, the row lands as the same `inject` the button
lands (its own prefix line, so the card and the model know nobody pressed
anything), and the SEL `dashboard_continue` line names the module.

Per chat, `dashboard.resume_retry_limit` automatic resumes per
`dashboard.resume_retry_window_minutes` (5 per 60 by default), counted
across restart and error resumes and persisted on the metadata line so a
crash loop cannot replay the resume on every boot; on exhaustion one notice
and one notification, and the Resume button is back. A person's send or
Resume press resets the count and drops an armed retry; so do a Stop and a
close. Never resumed: a turn the time limit ended, a Stop card, a closed
chat, a chat an app owns, and any terminal kind not on the retryable list.

Permissions are told, not restored. The metadata line records the approval
mode the chat held at its last save; with the switch on, the restore
compares it with the mode the chat came back holding and, when they differ,
lands one system notice saying so before any automatic resume runs. The
notice is behind the switch like the passes, so a gateway that never opts
in sees nothing new at a restart; the record is kept either way. A crewmate thread whose profile
declares the recorded mode and an app-owned chat are not told: their grant
comes back by its own mechanism. Nothing is read back into the trust flags
(`test_trust_flags_not_restored` is unchanged).

Both automatic bodies join the runner's interrupted-turn restore, so on a
session the turn natively resumed the lost request is re-injected for an
automatic resume as it is for a press, with the gateway's own system notices
left out of what the model is shown as its partial answer. That last part
fixes a defect main has today, for the manual Resume too: the compact-after-
turn notice the ladder writes into a running turn was quoted back to the
model as its own words.

The persisted budget is read fail-closed in every case, including an integer
too large for a float: the read never raises inside the slot restore, so a
tampered line refuses the automation for one window and the chat still
opens. A terminal error row is stated as settled -- cause named, instruction
dropped, danger tint gone -- only when the gateway's own automatic resume
follows it in the transcript, never because its Resume button is missing:
a row the person typed past has no button either, and its work was never
picked up. A person's own Resume press leaves the row as it has always
been, so a dashboard with the switch off reads exactly as before.

The stamp a resume spends is written to the metadata line, and the write
awaited, before the resume is queued; a write that did not complete refuses
the resume, so no automatic provider work runs on a budget that exists only
in memory. A person's reset is carried by their own turn's marker as the
empty list.

The permissions notice pass and the resume pass run in one background task
behind the listen, notice first: each loads transcripts the restore
deferred, one per chat concerned, so neither belongs on the path the ready
signal waits on.

The retryable set is closed on structural kinds. The runner's own "please
retry" rows are stamped retry_invited by their writers; an error row with
no kind, or a kind off the list, is never retried unattended, whatever its
words say.

Settings -> Chat -> Sessions gains the switch, the per-chat limit and the
window; `RecoveryCard` draws the two automatic-resume kinds with the cause
and the attempt count the dispatcher stamped on the row. The RFC that
records the decision travels in this change (`rfc-restart-resume`, draft).

Part of kirodotdev#13838 (the restart half, widened from unattended-only to a
setting). Part of kirodotdev#12275 (its fallback half: the chat is told when its
mode changed).
@iamwhatever

Copy link
Copy Markdown
Contributor

HOLD at 1740c8a.

This PR is reserved for a maintainer. The gateway dispatches turns with nobody present (continue_turn.py#L292-L306 decides whose Continue resets the budget), the approval-mode handler gains bookkeeping (slot_approval.py#L446-L450), and the Stop and teardown paths change. The conflict and the r114 copy and placement items are fixed, and checks are green.

Open on this head, with no reply:

  • GPT 6.1, downgraded to Low: a Slack Stop that lands during reset teardown, before _arm_stops runs, is not recorded, so the chat resumes despite the Stop (restart_resume.py#L963-L969). Please fix it or reply with why a resumed turn after a Stop is acceptable.

  • First Principles item 7: rewording earlier error rows as settled history is beyond the Goal. Please remove it or say why it stays.

  • UX note: split the 50-word resume_interrupted_turns_consequence bullet.

  • The RFC ships as draft, and its section 9 decisions (L923) and resume_retry_window_minutes wait on a maintainer.

  • The branch now conflicts with main; the rebased head needs a fresh review.

What clears it: a rebase, replies to the three AI notes, and a maintainer's review of the unattended dispatch, approval-mode and Stop paths, including the RFC decision.

— crew-pr-reviewer (r126)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor) merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: passed Eligible automated validation passed for the current revision

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants