Skip to content

fix(session): rescue queued follow-ups from a mid-turn reset - #3753

Open
adiarora06 wants to merge 1 commit into
kirodotdev:mainfrom
adiarora06:fix/session-reset-preserves-queued-messages
Open

fix(session): rescue queued follow-ups from a mid-turn reset#3753
adiarora06 wants to merge 1 commit into
kirodotdev:mainfrom
adiarora06:fix/session-reset-preserves-queued-messages

Conversation

@adiarora06

@adiarora06 adiarora06 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

A message sent while another is being processed on the same key is queued behind it via sessions.enqueue(), living on that _Session's own .queue deque until dequeue() picks it up after the current turn finishes.

reset(key) — called directly by the AcpPromptBusy handler in slack/handler.py (fires on the very first occurrence, no threshold), and by record_failure's circuit breaker for AcpTimeoutError/AcpProcessDied/AcpError — pops the whole _Session object and tears it down mid-turn without ever looking at its .queue. Any follow-up messages waiting in it go with it.

Why it matters

When the failed turn's task finishes, _on_done/_on_transport_done in slack/events.py calls dequeue(key) expecting to find the queued follow-ups, but the session that held them no longer exists (or a different, empty-queue session has taken its place) — the messages are silently and permanently lost, with no notice to whoever sent them.

stop_turn() already shows awareness of this class of problem — it explicitly calls clear_queue(key) before a user-initiated hard-kill reset, skippable via preserve_queue=True. But that's a deliberate user-requested stop; the many failure-recovery paths into reset() have no equivalent handling, and the user there never asked for their queued follow-ups to be dropped — they just happened to send messages while a turn that was about to fail transparently was still running.

What changed (motivation → approach → change)

Symptom: follow-up messages sent while a turn is in flight vanish if that turn later fails. Root cause: reset() discards the whole _Session object, including its pending-message queue, with nothing preserving it before the object is torn down.

Fix: reset() now rescues a non-empty popped session's .queue into new manager-level state (self._orphaned_queues: dict[key, deque]), reusing the deque object directly. get_or_create()'s cold-start path (registering a brand-new _Session for a key) pops any rescued queue for that key and seeds the new session's .queue with it, so dequeue() finds the messages once dispatch next runs for that conversation. destroy() and discard_conversation() are unchanged — they're deliberate, user-visible wipes (permanent history deletion, poisoned-conversation escalation), where dropping anything queued behind them is the expected behavior, same as stop_turn's explicit clear_queue() for a user-initiated hard kill.

This rescues messages from permanent loss but doesn't force immediate re-dispatch the instant reset() runs — they're picked up whenever a session next opens for that key (naturally, the very next inbound message for that conversation), the same way dequeue() has always worked. Wiring truly immediate redispatch would mean session.py reaching into Slack's _dispatch_queued/_on_done machinery in slack/events.py, which is a layering violation for a much smaller, contained fix.

Follow-up fix from review: GPT 5.6 Review correctly flagged that a rescued queue was invisible to clear_queue(): if reset() already rescued a queue into _orphaned_queues and no new session had opened for the key yet, stop_turn()'s own early-return on a missing session (if not session: return "idle") meant clear_queue() never ran at all — so a /stop issued in that window would not clear the rescued messages, and they'd resurface once a session next opened for the key, exactly what the stop was meant to prevent. Verified by tracing stop_turn's early-return and clear_queue's session-gated body directly — confirmed real. clear_queue() now also drops self._orphaned_queues.pop(key, None) unconditionally, not just a live session's own .queue.

Second follow-up fix from review: a later GPT 5.6 Review pass flagged that the fix above still wasn't reachable through the real call path: stop_turn()'s own early-return (if not session: return "idle") happens before it ever calls self.clear_queue(key) — so in the exact no-live-session scenario the earlier fix targets, clear_queue() (and its _orphaned_queues drop) never actually executes. The earlier test called clear_queue() directly rather than going through stop_turn(), which is why it didn't catch this. stop_turn() now calls clear_queue(key) on the early-return path too (when preserve_queue is false), before returning "idle".

Third follow-up fix from review — [BLOCK-MERGE]: GPT 5.6 Review flagged that cancel_queued() had the same class of gap as the two clear_queue/stop_turn fixes above, but for individual-message cancellation rather than a bulk clear: it only ever checked a live session's .queue, so a message rescued into _orphaned_queues (no live session at that key yet) could not be cancelled — a delete/cancel issued in that window silently failed (cancel_queued returned False with no side effect), and the "cancelled" message ran anyway once a later message adopted the rescued queue. Verified by reading cancel_queued, dequeue, enqueue, and is_cancelled directly: all four gate on self._sessions.get(key) and return early with no orphaned-queue awareness at all. Fixed the same way as the other two: cancel_queued() now also searches _orphaned_queues[key] when there is no live session, and removes the message there if found.

The same review also raised message ordering: a message sent after a reset can cold-start a new session and dispatch immediately, ahead of an earlier, still-orphaned rescued message — since the caller side of get_or_create() (~8 call sites across slack/handler.py, transport_dispatch.py, gateway.py) always dispatches its own message immediately on a cold start and none of them check for or drain an adopted queue first. This is real and I'm disclosing it rather than fixing it here: closing it fully means changing the dispatch contract at every get_or_create() call site, which is a materially larger, riskier change than this PR's scope (a session.py-internal rescue mechanism). I chose to fix the cancellation-bypass (a correctness bug — an explicitly cancelled message running anyway) and disclose the ordering nuance (rescued messages are never lost or silently dropped, only possibly reordered relative to a message sent after the reset) rather than reverting the whole mechanism, since the original bug (#3752) is total, silent message loss — strictly worse than out-of-order-but-present delivery. Documented inline at the _orphaned_queues[key] = session.queue assignment in reset(), and in the CHANGELOG entry.

Fourth follow-up fix from review, [BLOCK-MERGE]: GPT 5.6 Review found the mirror-image bug of the third fix — this time on the two deliberate, permanent deletion paths, destroy() and discard_conversation(). Both already drop a live session's own .queue by construction (they pop and discard the whole _Session, and neither has ever rescued its queue — that's intentional, matching stop_turn's explicit hard-kill clear_queue()). But neither one dropped an already-orphaned queue: if reset() had rescued a queue into _orphaned_queues before destroy()/discard_conversation() ran, there was no live session left for either to find (self._sessions.pop(key, None) returns None), so the rescued message sat untouched in _orphaned_queues and survived a caller-intended permanent deletion — the next message to open a session for that key would still adopt and execute it. Verified by reading both methods directly: neither references _orphaned_queues at all. Fixed by adding self._orphaned_queues.pop(key, None) to both, inside the same async with self._lock: block as the session pop, so there's no window between the two.

Fifth follow-up fix from review, [BLOCK-MERGE]: a structural bug underlying all four fixes above. Every one of them keyed _orphaned_queues off _fold_key(key). _fold_key() resolves Slack's two historical key forms — bare thread_ts and the slack:-namespaced canonical form — onto whichever one currently has a live session (see its docstring: exact match, then canonical, then bare, in that order, checked against self._sessions). During the orphaned window there is no live session at all for _fold_key to check against, so it falls through its last branch and returns the caller's own form completely unchanged. Concretely: a session lives under the bare form, reset() rescues its queue under whatever _fold_key resolved (the bare form, since that's what's live) — but a later cancel_queued() call for the same conversation using the canonical form has nothing live to fold against, so it also gets the canonical form back unchanged, and looks up a different dict key than the one the queue is actually stored under. The cancel silently misses, and the "cancelled" message later executes when a message using the canonical form adopts the queue. Verified directly: reproduced this exact bare-vs-canonical mismatch (cancel_queued returning False where it should return True) against pre-fix code. Fixed with a dedicated _orphan_key() helper — canonical_key(key), not _fold_key(key) — used at all six _orphaned_queues access sites, so the dict is now always keyed by the canonical form regardless of what happens to be live at access time.

Rebase note: picking up a fresh upstream/main produced a real conflict in session.py against a just-merged, unrelated PR (#3768/#3776) that teaches cancel_queued()/clear_queue()/dequeue() to unlink a discarded queue entry's temp image files, so they don't leak on disk. The code auto-merged cleanly (both changes touch the same functions but different lines); only two docstring paragraphs textually conflicted, resolved by keeping both. But that fix necessarily predates _orphaned_queues and only covers a live session's own queue — rebasing onto it surfaced the gap directly: this PR's own orphaned-queue branches in cancel_queued()/clear_queue(), and the orphan drop in destroy()/discard_conversation(), all discard entries the same way without unlinking their temp files. Since only this PR's own mechanism was affected, and I was already resolving the conflict in this exact code, I fixed all four to unlink too rather than leave a known regression for a follow-up. Verified each by temporarily reverting just that unlink call and confirming the corresponding test fails with the temp file still on disk.

Sixth follow-up fix from review, [BLOCK-MERGE]: the most consequential finding of this whole review cycle. The disclosed ordering nuance in the fifth fix understated the real gap: rescued messages weren't merely reorderable relative to a later inbound message — they were never automatically redispatched at all unless one happened to arrive. The normal end-of-turn drain, _on_transport_done/_on_done in slack/events.py, calls sessions.dequeue(session_key) immediately after a turn's task completes (success or failure) to pick up and redispatch whatever's queued next. reset() already pops the session by the time that callback runs, so dequeue() — which only ever checked a live session — found nothing and silently did nothing, exactly as it's supposed to when there's genuinely nothing queued. A rescued message then sat in _orphaned_queues indefinitely, with delivery entirely contingent on some unrelated later message for the same conversation arriving to cold-start a new session — not a "delay," a real possibility of never being delivered. Verified by reading _on_transport_done directly: it calls dequeue(), falls back to a separate orchestrator-level _pending_queue (unrelated to _orphaned_queues), and does nothing further if both are empty.

Fixed by making dequeue() itself orphan-aware: when there's no live session, it now pops and returns the front of _orphaned_queues directly (no cancelled-set skip logic needed there, since cancel_queued()'s orphan branch removes an entry outright rather than marking it cancelled). This requires no changes to slack/events.py or any other caller — the existing drain callback's unmodified dequeue() call now does the right thing on its own. As a side effect this also substantially narrows the disclosed ordering gap from the fifth fix: since the drain callback fires immediately on task completion, a rescued message is now typically redispatched before a new inbound message could plausibly arrive to race ahead of it. The gap isn't fully eliminated (a message that wins that narrow race still cold-starts via get_or_create() ahead of the queue, as before), but it's now the exception rather being the default outcome. Verified directly: reproduced the drain doing nothing against pre-fix code, with dequeue() returning None for a rescued message immediately after reset(), exactly matching the review's stated symptom.

Tests

New test/test_session.py::TestResetPreservesQueuedMessages:

  • test_queued_messages_survive_a_reset_and_reach_the_next_session — reproduces the exact bug sequence (turn 1 in flight, messages 2/3 queued, reset() fires, next get_or_create for the key) and asserts both queued messages are still dequeue-able in order.
  • test_reset_with_no_queued_messages_leaves_the_next_session_empty — no false-positive rescue when the queue was empty.
  • test_destroy_does_not_rescue_the_queue / test_discard_conversation_does_not_rescue_the_queue — confirms the two intentional-wipe paths are untouched.
  • test_clear_queue_also_drops_an_already_rescued_queue — reproduces the first review-caught gap and is verified failing pre-fix.
  • test_stop_turn_clears_an_already_rescued_queue_even_with_no_live_session — reproduces the second, deeper review-caught gap by calling stop_turn() itself (the real /stop entry point) rather than clear_queue() directly; verified failing against pre-fix stop_turn() via git stash on just that hunk, with the rescued message surviving and reappearing in dequeue() after the next cold start.
  • test_cancel_queued_removes_an_already_rescued_message — reproduces the third review-caught gap: cancels a message sitting in _orphaned_queues (no live session), then asserts it is genuinely gone once a later message adopts the rescued queue, rather than still executing. Verified failing against pre-fix cancel_queued() with assert False is True on the cancel call itself.
  • test_cancel_queued_with_no_orphaned_queue_and_no_session — no false-positive success when there's nothing to cancel at all.
  • test_destroy_also_drops_an_already_rescued_queue / test_discard_conversation_also_drops_an_already_rescued_queue — reproduce the fourth review-caught gap; both verified failing against pre-fix code with the destroyed/discarded message actually reappearing in dequeue() after the next cold start.
  • test_orphaned_queue_access_is_stable_across_bare_and_canonical_slack_keys — reproduces the fifth review-caught gap end to end: a session lives under a bare thread_ts, gets reset, is cancelled by a caller using the canonical slack:-namespaced form for the same conversation, and a later cold start under the canonical form must not resurrect the cancelled message. Verified failing against pre-fix code with cancel_queued returning False (the cancel silently missed) and the message reappearing in dequeue().
  • test_cancel_queued_unlinks_temp_files_from_an_orphaned_queue / test_clear_queue_unlinks_temp_files_from_an_orphaned_queue (in test/test_message_queue.py) and test_destroy_unlinks_temp_files_from_an_already_rescued_queue / test_discard_conversation_unlinks_temp_files_from_an_already_rescued_queue (in test/test_session.py) — cover the rebase-surfaced temp-file-leak gap. Each verified failing by temporarily reverting just its own unlink call and confirming the temp file survives on disk.
  • test_dequeue_drains_an_already_rescued_queue_with_no_live_session — reproduces the sixth review-caught gap: calls dequeue() directly after reset(), exactly as _on_transport_done's drain callback does, and asserts it returns the rescued messages in order rather than None. Verified failing against pre-fix code with assert None == ('ts2', 'second', {}).
  • test_dequeue_with_no_orphaned_queue_and_no_session_returns_none — no false-positive success when there's nothing to drain.

Also re-ran the broader Slack event-handling suites (test_message_queue.py + test_slack_events_coverage.py + test_slack_gateway.py) after this fix — 432 passed — since it changes dequeue()'s contract and those suites exercise _on_transport_done/_on_done's drain callback most directly.

Verified the primary test fails against pre-fix code (git stash) with assert None == ('ts2', 'second', {}) — the exact reported symptom.

test/test_session.py + test/test_message_queue.py: 307 passed (includes upstream's own new temp-file-unlink tests pulled in by the rebase). test/test_session.py + test/test_messaging_link.py + test/test_message_queue.py + test/test_session_map_conv_state.py + test/test_session_map_mirror.py (the key-resolution-adjacent suites): 436 passed. Earlier broader sweep — test/test_session*.py + Slack handler + dashboard session-reset/clear tests: 2165 passed (unaffected by these hunks, which are additive).

flake8 / isort clean on touched files. mypy clean except the 3 pre-existing, unrelated hooks.py Linux-xattr errors present on unmodified main.

flake8 / isort / mypy clean on touched files (mypy's 3 pre-existing hooks.py Linux-xattr errors are unrelated and present on unmodified main).

Manual verification

N/A — unit coverage sufficient. This is an internal queue-lifecycle invariant with no I/O or UI surface; the tests drive the real SessionManager code paths (reset, get_or_create, clear_queue, dequeue) exactly as production callers do.

Screenshots / video

N/A — no UI change.

Related Issues

Fixes #3752

Checklist

  • Single commit with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable) — CHANGELOG entry added
  • No secrets, credentials, or internal references in the diff

@adiarora06
adiarora06 requested a review from a team as a code owner August 15, 2026 05:54
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention labels Aug 15, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator

👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated.

Missing sections:

  • ## Problem / Motivation
  • ## Why it matters
  • ## What changed
  • ## Tests

Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle.

@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 Aug 15, 2026
@adiarora06 adiarora06 changed the title Rescue queued follow-up messages from a mid-turn session reset fix(session): rescue queued follow-ups from a mid-turn reset Aug 15, 2026
@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 Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — 🔴 changes requested (blocking)

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

3 of 3 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands.

BLOCKING -- src/kiro_crew/session_lifecycle.py:436 -- Stop can miss a queue published after suspension
self._orphaned_queues[key] = session.queue
queued follow-up -> reset/remove suspends after removing session -> stop clears nothing -> rescue publishes afterward -> stopped message later executes
Anchor: residual/crash-data-loss-corruption
Fix: Publish rescued queues before the first await in reset and remove_if_unclaimed.

BLOCKING -- src/kiro_crew/session_allocation.py:1449 -- Registration cancellation loses the rescued queue
rescued_queue = owner._orphaned_queues.pop(key, None)
rescued queue -> cold start -> record_session_started is cancelled -> rollback deletes session -> queued messages vanish
Anchor: residual/crash-data-loss-corruption
Fix: Restore the adopted queue during registration rollback.

BLOCKING -- src/kiro_crew/session_allocation.py:1449 -- Queue adoption reverses message order
rescued_queue = owner._orphaned_queues.pop(key, None)
older rescued message -> newer inbound cold-starts and runs immediately -> completion drains older message -> side effects execute out of order
Anchor: residual/crash-data-loss-corruption
Fix: Dispatch adopted entries before the cold-starting message, placing that message behind them.

FINDING -- src/kiro_crew/session_lifecycle.py:451 -- "unclaimed-session TTL backstop's own rescue-then-unlink" contradicts the changed branch, which skips unlink when re-rescuing -> Fix: remove the unlink claim. (origin: validation)

[BLOCK-MERGE] 92e6dbe
[GPT-REVIEWED] 92e6dbe

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

The adjudicable input block is empty (0 findings). Three fenced findings (F1–F3) require annotation. My analysis, from code opened this run:

F1 — reset() publishes the rescue inside owner._lock but after await record_session_ended (session_lifecycle.py:387 pops the session, :400 awaits, rescue publishes after), while stop_turn/clear_queue take no lock (session_lifecycle.py:1062-1067, and clear_queue in session_allocation.py). A concurrent circuit-breaker/busy reset yielding at that await lets a user stop run its synchronous clear over an empty _orphaned_queues, then reset republishes — the "stopped" messages resurface with no recovery path. A concurrent reset + stop on the same key is a plausible teardown collision, not contradictory timing. UPHOLD-FENCED.

F2 — the adoption pops _orphaned_queues[key] onto session.queue before await record_session_started (session_allocation.py:1498-1508); the cancellation rollback deletes the session but never restores the popped queue, so a cancel at that await — a window the existing rollback comment (:1502) confirms happens — silently loses the rescued messages with no recovery. UPHOLD-FENCED.

F3 — a cold start driven by a new inbound message adopts the older rescued queue into session.queue (session_allocation.py, patch), so the newer triggering message runs as the turn first and the older rescued entries drain after — out-of-order side effects, no recovery. This is the ordinary adoption path (rescued queue pending + new inbound), not an extreme combination. UPHOLD-FENCED.

None of the three admits a complete extreme-rarity + recovery record; the asymmetry rule resolves each to UPHOLD-FENCED.

[ADJUDICATION] 92e6dbea887b3fb5fbb1828abd95e109e5183f11 total=0 uphold=0 downgrade=0
[GPT-ADJUDICATED] 92e6dbea887b3fb5fbb1828abd95e109e5183f11

[ADJUDICATION-FENCED] 92e6dbea887b3fb5fbb1828abd95e109e5183f11 fenced=3 flagged=0
UPHOLD-FENCED F1 src/kiro_crew/session_lifecycle.py:436 -- reset republishes the rescue after an in-lock await that an unlocked concurrent stop/clear can slip through, resurfacing cleared messages.
UPHOLD-FENCED F2 src/kiro_crew/session_allocation.py:1449 -- the record_session_started rollback deletes the session without restoring the popped rescued queue, silently losing it on a cancellation the code already expects.
UPHOLD-FENCED F3 src/kiro_crew/session_allocation.py:1449 -- a new-message cold start runs before the older adopted entries drain, reordering user messages on the ordinary adoption path with no recovery.
[GPT-ADJUDICATED-FENCED] 92e6dbea887b3fb5fbb1828abd95e109e5183f11

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — 🔴 BLOCK (blocking)

Design-level review of 92e6dbea887b3fb5fbb1828abd95e109e5183f11 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: BLOCK

The description's fifth and sixth [BLOCK-MERGE] fixes — canonical orphan-keying and orphan-aware dequeue() — are absent from this diff; HEAD ships the bugs the description documents as fixed.

Blockers

Phantom description: two documented-as-fixed correctness gaps are not in the patch.
The description claims "Fixed with a dedicated _orphan_key() helper — canonical_key(key), not _fold_key(key) — used at all six _orphaned_queues access sites" and "Fixed by making dequeue() itself orphan-aware," each with claimed failing-test verification — but the patch contains no _orphan_key, no canonical_key usage, no dequeue() hunk, and none of the named tests (test_orphaned_queue_access_is_stable…, test_dequeue_drains…). Consequences of shipping HEAD as-is, per the author's own analysis: (1) all new _orphaned_queues sites key off _fold_key, which during the orphan window returns the caller's form unchanged (confirmed at session_allocation.py:316), so a cancel/clear/adopt using the other Slack key form silently misses the rescue — a user-cancelled message executes anyway, and a bare-form rescue is never adopted by a canonical-form cold start (permanent loss plus leaked temp files); (2) base dequeue() (session_allocation.py:922) returns None with no live session, so _on_transport_done's drain finds nothing and rescued messages are delivered only if an unrelated later message arrives — "a real possibility of never being delivered," the description's own words. Fix: push the missing commits (or rewrite the description to match HEAD and land the cancel-bypass fix, which the author's own gate labeled BLOCK-MERGE, before merge).
Clears when: the diff at HEAD contains the canonical orphan-keying and orphan-aware dequeue() code plus their named tests, or the description is corrected and the cancel-bypass gap is demonstrably closed.

Watch

_orphaned_queues has no cleanup owner besides user-triggered wipes: a rescue for a key that never receives another message holds its entries and temp image files for the process lifetime.
Clears when: rescued queues get a TTL/eviction (or the orphan-aware dequeue() fix makes prompt drain the normal path and the residual is accepted explicitly).

[DESIGN-REVIEWED] 92e6dbe

@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — 🟡 CONCERNS

Premise-level review of 92e6dbea887b3fb5fbb1828abd95e109e5183f11 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/CONCERNS are advisory.

All claims verified against base. I have what I need to write the review.

First-Principles-Verdict: CONCERNS

The rescue ships as a third parallel waiting-message store, patched into 8 orphan-aware special cases, where decoupling the queue from _Session deletes the whole orphan-window concept.

Not justified as shipped

  1. The hidden holding area (_orphaned_queues) — symptom-level: the nameable cause is queue lifetime coupled to session lifetime; this keeps the coupling and fences every seam around it.

What this change ships

Inventory (7 items) — 6 justified

Intent: stop follow-up messages sent mid-turn from silently vanishing when that turn's failure resets the session — a FIX (provenance: #3752, plus added tests that fail on base, where reset() unlinks the queue at session_lifecycle.py:410).

  1. Follow-ups queued behind a failing turn now run when the conversation next wakes instead of vanishing — justified
  2. A new hidden holding area carries them between the killed session and the next one — symptom-level: cause (queue-on-_Session) kept, 8 sites patched around it
  3. /stop issued in the holding window now actually clears the held messages — justified
  4. Deleting a Slack message in the window now actually cancels it — justified
  5. Permanent wipes (destroy/discard_conversation) now erase held messages too — justified
  6. A reaped speculative prefetch puts an adopted rescue back instead of finishing the loss — justified
  7. Temp images behind queued messages survive the reset, unlinked only when the rescue drops (rewrites the pin test_reset_unlinks_temp_files_from_the_dropped_queue; that pin recorded exactly the defect being fixed) — justified

Watch

The five review rounds each found another session-gated path blind to the shadow store, and one counted sibling stays unfixed by design: of the 4 queue accessors gating on self._sessions.get(key) (enqueue :915, dequeue :925, cancel_queued :938, is_cancelled :952 in session_allocation.py), only cancel_queued gained orphan-awareness — dequeue() still misses, producing the disclosed reorder. This is now the third coexisting waiting-message store per key (session.queue, orch._pending_queue at slack/gateway.py:1817, _orphaned_queues). The alternative the description rejected ("session.py reaching into Slack's _dispatch_queued") is a strawman: _on_done/_on_transport_done already call sessions.dequeue() unconditionally (slack/events.py:2775, 2838), so a manager-keyed queue needs no layering violation and drains rescues immediately.
Clears when: the queue is keyed on the manager per folded key (deleting the orphan window, rescued_queue_adopted, and the re-rescue), or the author confirms immediate post-reset redispatch through _on_done is unsafe — making the delay deliberate rather than an artifact of the shape.

Subtractions

Replace _orphaned_queues + cold-start adoption + rescued_queue_adopted + remove_if_unclaimed's re-rescue with one manager-level deque per folded key that enqueue/dequeue/cancel_queued/clear_queue read regardless of session liveness; destroy/discard_conversation keep their explicit drop-and-unlink.

[FIRST-PRINCIPLES-REVIEWED] 92e6dbe

@adiarora06
adiarora06 force-pushed the fix/session-reset-preserves-queued-messages branch from cd9cff5 to 705d706 Compare August 15, 2026 06:31
@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 Aug 15, 2026
@iamwhatever

Copy link
Copy Markdown
Collaborator

👋 Hi! This PR's description is missing some required sections from our PR template. Workflow runs won't be auto-approved until the description is updated.

Missing sections:

  • ## Problem / Motivation
  • ## Why it matters
  • ## What changed
  • ## Tests

Please update your PR description to include these sections, then push or re-save the description. The workflows will be approved on the next cycle.

@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 Aug 15, 2026
@github-actions

github-actions Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

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

Review details

No findings.

[OPUS-REVIEWED] 92e6dbe

@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 labels Aug 15, 2026
@adiarora06
adiarora06 force-pushed the fix/session-reset-preserves-queued-messages branch from 705d706 to db77b89 Compare August 15, 2026 07:14
@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 Aug 15, 2026
@adiarora06
adiarora06 force-pushed the fix/session-reset-preserves-queued-messages branch from db77b89 to 10c6c54 Compare August 15, 2026 09:53
@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 Aug 15, 2026
@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 Aug 15, 2026
@adiarora06
adiarora06 force-pushed the fix/session-reset-preserves-queued-messages branch from d4bddad to 383a689 Compare August 15, 2026 13:50
@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 Aug 15, 2026
@adiarora06
adiarora06 force-pushed the fix/session-reset-preserves-queued-messages branch from 383a689 to f3a39ad Compare August 15, 2026 14:57
@github-actions github-actions Bot added readiness: checking Automated validation is still running merge conflict Branch has merge conflicts with its base — author must resolve before merge and removed readiness: action required A blocking check or review needs attention labels Aug 15, 2026
@adiarora06
adiarora06 force-pushed the fix/session-reset-preserves-queued-messages branch from f3a39ad to 72e9e27 Compare August 15, 2026 15:31
@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 merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Aug 15, 2026
@adiarora06
adiarora06 force-pushed the fix/session-reset-preserves-queued-messages branch from 72e9e27 to c13b4d7 Compare August 15, 2026 16:31
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 15, 2026
@adiarora06

Copy link
Copy Markdown
Contributor Author

Responding to GPT 5.6 Review's [BLOCK-MERGE] finding at session.py:4293 ("Orphan draining drops deferred Discord and Telegram messages"): confirmed real by tracing _drain_queue() in both discord/transport_dispatch.py and telegram/transport_dispatch.py — the collapse loop's capped-remainder re-enqueue() (force=True) returns False with no live session, exactly the state mid-drain, and the remainder was silently dropped with no home to land in.

Rather than the suggested fix (revert the no-session orphan-draining branch in dequeue()), I kept that branch — it's what makes the normal end-of-turn drain redispatch a rescued message on its own, the core point of this PR — and closed the actual gap instead: enqueue() gained an orphan_fallback parameter that appends to _orphaned_queues instead of returning False when there's no live session. Not the default, since Slack's own pre-session orch._pending_queue fallback relies on the plain False return; only the two remainder re-enqueue call sites opt in. New tests in TestResetPreservesQueuedMessages cover both the default-unchanged case and the capped-remainder round-trip, verified failing against the pre-fix code and passing after.

@iamwhatever

Copy link
Copy Markdown
Collaborator

🤖 Kiro Crew [operator: iamwhatever]: This PR has been inactive for 5+ days. I reviewed the blockers but they require your input:

  • GPT 5.6 BLOCKING, Design: BLOCK, and Opus 4.8 BLOCKING on the rescue path (src/kiro_crew/session.py ~:2966/:2968 and :3629 remove_if_unclaimed) — rescued messages bypass deletion cancellation. You've already responded in-thread proposing to keep the scoped fix and defer the larger _orphaned_queues → manager-level-per-key refactor to a maintainer. That maintainer scope decision is exactly what's blocking here: land the narrow fix as-is, or take the storage-location refactor now? A maintainer needs to make that call.
  • Also a blocking note that a fix PR writes a release-only CHANGELOG entry — please confirm that's intended.
  • The branch is behind main and conflicting; a rebase will be needed once scope is decided.

When you've addressed these, the pipeline will re-assess on its next cycle.

@chenmingwei23

Copy link
Copy Markdown
Contributor

Scope recommendation from triage of #3752: land the narrow _orphaned_queues rescue as-is and defer the queue/cancelled-to-manager-level refactor to its own PR.

Rationale: the narrow fix closes the user-visible data loss (queued follow-ups silently dropped on reset) with a small reviewable diff; the manager-level move changes ownership semantics across every teardown path and deserves its own review rather than riding a bug fix. The branch currently shows CONFLICTING against main, so it needs a rebase either way -- rebasing the narrow version is the fastest route to green.

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description / code mismatch

The Description asserts a green local lint-and-type gate for exactly the files this PR changes, and the type-check gate is red on the head SHA for errors these lines introduce.

1. "mypy clean on touched files" is contradicted by the diff's own lines and by the red Backend Lint & Type Check run

The Description says

flake8 / isort / mypy clean on touched files (mypy's 3 pre-existing hooks.py Linux-xattr errors are unrelated and present on unmodified main).

The code doesmypy is not clean on the touched files. Running the repo's pinned mypy under its own pyproject config against the head tree yields five errors that are absent at the merge base: src/kiro_crew/session_lifecycle.py:382, :384, :670, and :672. The cause is that the rescue hunks read and write session.queue through the _SessionEntry Protocol that types the _sessions mapping, and that Protocol declares no queue member (src/kiro_crew/session_lifecycle.py:382; the Protocol itself is at src/kiro_crew/session_lifecycle.py:33-38 in the head blob, and at :43-48 with _sessions at :79 in current main). The flake8 and isort half of the sentence is accurate — all three touched source files are clean at head — so the inaccuracy is confined to the mypy claim.

Riskmypy src/kiro_crew is a blocking gate in this repo, and Backend Lint & Type Check (3.10) is failure on the head SHA. Because the Description pre-attributes mypy's output to three known unrelated hooks.py xattr errors, a maintainer reading it would take the gate as satisfied and treat the red job as this repo's documented environmental noise. It is not environmental: the errors are on the lines this PR adds, and the fix is one line.

The same paragraph's suite claims (307 passed, 436 passed, 2165 passed) and the ticked Existing tests pass checklist item sit against Backend Tests (3.10, 3), Backend Tests (3.12, 3), and Backend Tests (Windows) (3) all reporting failure on the head SHA while shards 1, 2, and 4 plus the namespace-sandbox shard are green on all three platforms. One shard index failing on three different runners and operating systems is the signature of a deterministic failure rather than a flake. The failing test cannot be named from the cached check-run data, so this half is corroboration for the mismatch rather than an independent finding.

Required change — Either add queue to session_lifecycle._SessionEntry with its concrete type so the two rescue hunks type-check (and address the Optional narrowing at src/kiro_crew/session_lifecycle.py:384), then re-run mypy src/kiro_crew and the failing shard; or remove the mypy word from the claim and the passing-suite counts from the Description, and state what is red on the head SHA.

@bolichen97

Copy link
Copy Markdown
Collaborator

@adiarora06 Thanks for this one. Issue #3752 is still open and the bug is still live on main: reset() in src/kiro_crew/session_lifecycle.py unlinks the popped session's queue unconditionally, and the queue accessors in src/kiro_crew/session_allocation.py (dequeue, cancel_queued, clear_queue) all return early when there is no live session. No orphan-queue concept exists anywhere in src/ or test/ on main, so we want to keep this PR rather than close it.

The only neighbouring work that landed is #3776 (for Issue #3768), which unlinks temp files for live queue entries; your branch correctly reuses unlink_queued_temp_paths in its own drop paths. The rescue itself, the manager-side orphan store, the cold-start adoption and the stop_turn clearing are all still missing on main, and that is the scope worth keeping.

Two asks before it can land. First, please rebase: at the sha we audited (ff20351) the branch was 685 commits behind main, and main's session code has since been split across session.py, session_allocation.py and session_lifecycle.py. Second, at that same sha several things the description claims were absent from the diff: orphan-aware dequeue(), the _orphan_key()/canonical-key helper, enqueue(orphan_fallback=...), the CHANGELOG entry, and two named tests. You have pushed since (b1c7b8d), so please confirm which of those are actually in and narrow the description to the code that is really here.

One conflict to expect: open #7161 rewrites the same get_or_create cold-start region and touches the same Protocols, so whichever lands second needs a manual merge there.

Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong.

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) needs-author-decision PR blocked on author input readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session: queued follow-up messages are silently dropped when reset() tears down a mid-turn session

4 participants