Skip to content

fix(dashboard): preserve concurrent same-key recreate during slot-close teardown (#7191) - #7212

Merged
bolichen97 merged 1 commit into
mainfrom
fix/slot-close-teardown-race-7191
Sep 4, 2026
Merged

fix(dashboard): preserve concurrent same-key recreate during slot-close teardown (#7191)#7212
bolichen97 merged 1 commit into
mainfrom
fix/slot-close-teardown-race-7191

Conversation

@bolichen97

@bolichen97 bolichen97 commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #7191

This closes the post-pop teardown race in the shared dashboard slot-close sequence.

Both api_chat_slot_delete and api_chat_slots_cleanup pop name from state._slots and then run several awaits (task cancel, save_slot_off_loop(..., closed=True), sessions.remove(_history_key_for(name))). A concurrent same-key recreate (a POST /api/chat, or the session_close MCP verb) can mint a replacement slot for the same key inside that window. The in-flight original then (a) writes its transcript over the replacement's shared history as closed=True, and (b) tears down the session the replacement now uses. The failure arms compounded this by blindly restoring the original (state._slots[name] = slot) over whatever now owned the key.

Approach

Routing facts on the current base: the single-tab teardown lives in close_slot, shared by api_chat_slot_delete and session-control's close_target, while api_chat_slots_cleanup carries its own copy of the same shape; the note_slot_closed tombstone already fires pre-teardown but the creation path never reads it.

Chose Option 2 (ownership re-check at teardown) over Option 1 (consult-tombstone-at-creation): both destructive sites already hold the popped object, so a synchronous, mechanism-free discriminator is available that does not penalize a legitimate fast recreate. Option 1 would require inventing reservation semantics on the hot creation path.

There are two discriminators, because the destructive steps do not all answer to the same owner and one answer cannot gate resources with different owners:

  • _slot_still_ours(state, name, <popped>) — "does a DIFFERENT object hold name". Governs the key-scoped steps: sessions.remove(_history_key_for(name)) (the session an unbound replacement runs on), the failure-arm _slots restores, cleanup's archived report, and the app-dismissal decision coupled to the restore.
  • _replacement_shares_transcript(state, name, <popped>) — "does that different object write MY transcript". Governs the closed=True save alone, whose resource is the transcript rather than the key. _save_slot_to_history targets slot_history_key(slot), so a channel-, cron- or workflow-linked slot writes its linked_session_key while a replacement minted by a plain get_or_create_slot(name) (what POST /api/chat and the session_close verb do) is unbound and writes dashboard:{name}. Same key, two files. Yielding the archive on such a pair leaves the ORIGINAL's transcript with no closed flag, and channel_slots._close_stands reads an absent flag as "the user never dismissed this" — so the reconcile pass resurfaces the tab that was closed. It compares FILE identity (transcript_stems on both sides) rather than key strings, because history._safe_key folds slack:<ts> and the slack_<ts> stem onto one .jsonl and a pre-migration thread still resolves to its bare thread_ts stem; the two errors are not symmetric, since over-reporting "shared" only declines an archive the next close will make while under-reporting stamps closed on a file a live slot is writing.

The two paths were deliberately not merged further: they diverge past the shared shape (nudge retirement + app-notify in close_slot; flush_deferred_notes + batched task cancel + error-row append in cleanup). What they share is three pure helpers — the two predicates above and the _resettle_restricted_key(state, name) postcondition — plus the _persist_handover_tail drain.

The guards are applied at three points per path, each with the predicate its resource answers to:

  • before the closed=True save — _replacement_shares_transcript, so the archive yields only to a replacement that actually holds the file,
  • before sessions.remove_slot_still_ours,
  • around the failure-arm restore — _slot_still_ours (now only restores when the key is free or still the popped object).

Cleanup additionally skips archived.append under _slot_still_ours, so a key with a live holder is never reported swept — archived names slot keys, whatever became of the transcript.

Yielding to a replacement carries four obligations, because the state a close compensates is not all scoped the same way:

  • Key-scoped state moves with the key. state._restricted_keys holds a session KEY (dashboard:{name}) and _is_restricted_session reads it before it looks at the slot, so every teardown exit now routes through one postcondition — _resettle_restricted_key: the key is marked iff the slot currently at it is restricted. A bare discard the hand-over exits skipped handed a persistent replacement an incognito original's 403; re-deriving rather than discarding keeps a restricted replacement's own marker, since dropping it is the fail-OPEN direction.
  • Slot-scoped compensation is coupled to the restore. notify_slot_close_undone is now conditional on the original getting its key back, not on slot._app alone. With a replacement on the key there is no tab to put back, and resuming the crew would let its watchdog grant auto-approve — then an unbounded nudge clock — to the user-owned slot now holding that key.
  • The original's unpersisted content is owed to its own transcript, not to the original's slot object. A hand-over exit stops referencing the popped slot, and _flush_dirty_slots iterates exactly state._slots, so an unreferenced slot has NO retry path: messages[_disk_window_len:], plus any note the bulk path still holds in the in-memory-only _deferred_notes, would simply cease to exist. The pre-save exits need no store failure to reach that — they return before the save is attempted, in a window that opens while a turn is in flight, so the rows at risk are typically the reply the user was watching. Every hand-over exit therefore routes through _persist_handover_tail(state, name, slot): it flushes held notes into the window and writes it with closed=False. Those rows belong on the ORIGINAL's transcript whether or not the replacement shares it; what must not happen is the archive stamp and the session teardown, not the write. The write target is slot_history_key(slot) and never a derived dashboard:{name}_save_slot_to_history resolves its own target the same way and REFUSES a save whose expected_history_key names a different transcript, so the derived form would make the drain a silent no-op for every cron-, channel- or workflow-linked tab and would name a row-less file in the failure log. It is non-destructive against the replacement in both directions — _save_slot_to_history's foreign-append scan carries through every on-disk line the saved window does not represent, so rows a replacement already committed survive. The METADATA line is not this slot's to move, so the write is rows_only: _save_slot_to_history is otherwise authoritative for SLOT_OWNED_META_KEYS and REBUILDS that line from whichever slot it is handed, so a default save here would revert a folder, pinned title, tag or pin the replacement had already published (POST /api/chat/slots persists both at birth) — silently undoing an acknowledged edit, and for a tab nobody types in again undoing it for good, so the next restart resurrects the dismissed tab's name and filing. rows_only keeps the on-disk value for each of those fields and narrows this write's ownership to ROWS_ONLY_OWNED_META_KEYS (the file's identity and accounting, which every writer maintains and which the rebuild reads out of the existing line anyway). The set it defers is named in full as ROWS_ONLY_DEFERRED_META_KEYS rather than derived as SLOT_OWNED_META_KEYS - ROWS_ONLY_OWNED_META_KEYS, because that difference under-approximates: the slot save also writes fields that DESCRIBE an owned one without being owned themselves, and a title's provenance and refresh budget (title_origin, title_refresh_mark) travel WITH the title rather than with the writer. Deferring the title while keeping those would commit a line matching NEITHER slot — read back beside another slot's title they either unlock the background title refresh on a name the user typed by hand or lock a generated name out of refresh permanently — so they are deferred with it, created_by and origin are the same shape with AUTHORIZATION rather than presentation behind them, so they are deferred too: created_by is what session-control's member ownership boundary reads and is meaningless without the mode deferred beside it, and origin must round-trip with the deferred app because that pair decides slots:user visibility and the unattended approval window. The conversation's own MONOTONE once-flags (auto_tagged, human_seen, channel_origin, channel_folder_filed) are set and never cleared, so two writers on one transcript cannot disagree about them in a way that outlives the pair; they stay as written. Deferring to disk is deliberately NOT the same as re-deriving the line from the replacement: a recreate that published nothing has no metadata to protect, and deriving from it would ERASE a real title and filing the shared conversation has — leaving the line alone is what gets both directions right with one write. The deferral is conditional on there BEING another writer to defer to, decided at the save from the line's tab_id — the one per-writer mark it carries, minted per slot object (get_or_create_slot assigns a fresh uuid, a rehydrate adopts the file's) and stamped by every save: fields are held back only on a line ANOTHER slot published, while a line this slot published itself, or no line at all, takes the ordinary rebuild. Unscoped it would cost the original its own uncommitted metadata, because a rename, re-file, tag or pin is acknowledged the instant it lands on the slot (_dirty) and persists on a later _flush_dirty_slots — which iterates state._slots, so no flush ever visits a popped slot again, and no failed save is needed to reach that state. Unprovable ownership defers, since the two errors cost differently: a deferred edit was never committed, while a rebuild over a live holder's line reverts what it published and nothing rewrites that for a replacement nobody types in again. tab_id is itself deferred, so a deferring write leaves the other writer's id in place rather than flipping the next drain's answer. closed/closed_at are deferred on that same asymmetry, so the drain is open-shaped without being un-closing. On a line the replacement published, a closed flag is that holder's own DISMISSAL, and the drain races its close for the transcript lock: erasing it would resurface a tab the user put away, permanently, since both slots are popped by then and nothing rewrites the flag. Leaving a stale one costs nothing durable, because the live holder owns those keys on its next full save. The only path that clears a stale flag from outside the holder is the resume route, and it clears one only when it can prove the close predates its own boundary (clear_closed(..., only_if_closed_before=...), compared inside the store's lock) for exactly this reason. Clearing a stale flag is therefore the job of the tab_id fallback: on a line THIS slot published there is no other holder's dismissal to lose, so the ordinary rebuild runs and the open-shaped write erases it. The failure arms take the same route in place of the restore they skip: a store that rejected the closed=True write can still accept the next one, and a lock lost to the recreate is exactly that case. When even that write fails the loss is unrecoverable, so it is logged with the exact row count rather than left silent.
  • A drain that fails is reported, not swallowed. _persist_handover_tail returns whether rows were owed and reached disk, and every caller honours it, because this frame is the last reference to those rows: nothing retries and nothing else can ever report them. Both PRE-SAVE hand-over exits therefore turn a False into their own path's failure — close_slot raises SlotCloseError(code="history_save_failed"), the same code an ordinary failed archive raises since from the caller's side it is one thing, and cleanup adds the key to failed. There is nothing to roll back on either exit (the original is popped, cancelled, and a live replacement holds the key), so the report IS the whole remedy; answering 200 there claims durability the close does not have. The two FAILURE-arm drains need no branch of their own — those arms already end in SlotCloseError / failed.append(name), so a lost tail reaches the caller regardless.

What this does NOT close

The guard covers the WIDE window (the app-notify awaits before the pop, the up-to-2.0s task cancel) and not the durable write: save_slot_off_loop reaches its commit through the process-wide default executor, so a recreate can still land between the last synchronous check and the in-lock write, leaving closed=True on a key a live replacement holds. That residual is what an unguarded close carries too — it is measurably identical on main — and the row it leaves is the one a plain sequential close-then-reopen of a reused key already produces: closed/closed_at are in SLOT_OWNED_META_KEYS, so the replacement's next full save drops them, and api_chat_slot_resume compensates a stale flag with an in-lock compare-and-clear. Closing it AT the commit needs an ownership predicate inside _locked(history_key) on the write AND on the resume's read-then-clear — a durable-metadata contract change across three modules, tracked as follow-up rather than folded in here. Both the code comment and session.md state this scope explicitly. Note the distinction from the row loss above: that residual is a stale closed FLAG that the replacement's next full save drops, not a lost message — the hand-over exits no longer discard content.

Also out of scope: _remove_slot_for_history_key in src/kiro_crew/dashboard/handlers/sessions.py has the same unguarded pop-then-await shape (it pops the slot key, awaits crew.purge_slot, pin cleanup and a 2.0s task cancel, then destroys the session) with no identity re-check. It is a permanent history DELETE rather than a close, so the recreate it would race is a different intent and the compensation differs; it is named here so the gap is on the record rather than folded into this diff.

Files changed

  • src/kiro_crew/dashboard/chat_handlers.py: the _slot_still_ours (key-scoped) and _replacement_shares_transcript (transcript-scoped) predicates, the _resettle_restricted_key postcondition, the _persist_handover_tail drain and the honouring of its result, and the guards at all six post-pop sites across both handlers.
  • src/kiro_crew/dashboard/chat_persistence.py: the rows_only save mode — write the window, leave the metadata line's slot-owned fields as they stand on disk. Default False, threaded through save_slot_off_loop, and used by exactly one caller (the hand-over drain), so no existing save path changes shape.
  • src/kiro_crew/history.py: ROWS_ONLY_OWNED_META_KEYS, the subset of SLOT_OWNED_META_KEYS a rows-only save still owns, and ROWS_ONLY_DEFERRED_META_KEYS, the set such a save must drop so the on-disk values are carried back — wider than the owned difference, because the rebuild also writes unowned fields that describe an owned one. Both defined next to the ownership vocabulary they partition.
  • docs/system-specs/modules/session.md: documents the two post-pop predicates and which step each governs, the four obligations yielding to a replacement carries, and the residual the guards do NOT close.
  • test/test_slot_close_recreation_race.py (new): concurrency tests driving both handlers directly with deterministic asyncio.Event interleaving — covering both predicates' polarity on their own, the first pre-save guard (via a parked running turn), the second guard, both failure arms, the failure-arm compensation asymmetry, the inert common path, the divergent-transcript case at both handlers (an unbound recreate over a linked tab: the original's own transcript IS archived with its tail, while the replacement keeps its slot and session), the drain-failure report at both handlers (a 500 with history_save_failed, a failed entry), five durability cases that run the REAL save_slot_off_loop against a real ConversationLog and pin BOTH halves at once (the replacement survives AND every row of the original reaches disk, unclosed — including a linked_session_key slot whose recreate is bound to the same key, which pins that the drain authorizes the slot's own transcript), and eleven metadata cases that pin rows_only from both sides (a published replacement keeps its title and folder AND its title's provenance and refresh budget, so the committed line matches one slot rather than half of each; a blank replacement does NOT erase the original's; a transcript with no line yet still gets the slot's own; a dismissal the replacement committed onto its own line survives the drain, while a stale closed on the ORIGINAL's own line is still erased; both windows' rows survive the write; the bulk path and the delete failure arm inherit the same restraint; the drain's OWN uncommitted rename, re-file and pin reach disk when the line is its own, with the save driven directly so the id on the line is the only variable; and a published replacement still outranks the drain's pending edit, which is the polarity guard a fix that merely stopped deferring would fail). Each destructive assertion fails if its corresponding guard is reverted, and each durability assertion fails if the drain is.

Testing

test/test_slot_close_recreation_race.py drives both handlers directly (not through a client) so the concurrent recreate is scheduled deterministically inside the teardown window, via asyncio.Events the monkeypatched save_slot_off_loop parks on. It covers each predicate's polarity on its own, the first pre-save guard (through a parked running turn), the second guard, both failure arms, the app-dismissal decision on both hand-over exits, the restricted-marker hand-over including a restricted replacement, and the inert ordinary path at both sites. Each destructive assertion fails if its corresponding guard is reverted.

Three groups pin what this round changed, and each was proved by reverting the change and watching exactly those tests red:

  • The transcript scope of the archive gate. test_delete_divergent_transcript_still_archives_the_original and its cleanup sibling give the original a linked_session_key and let an unbound same-name recreate land in the cancel-await; they assert the linked transcript comes out closed WITH the original's tail, and that the replacement keeps its slot and its session. Reverting _replacement_shares_transcript to the key-only answer reds both with "the dismissed linked transcript was left open to resurface".
  • File identity rather than key strings. test_shares_transcript_compares_files_not_key_strings pairs a bound channel slot with a channel_origin replacement whose stem never resolved — two key strings, one .jsonl — and asserts the pair reads as shared. Reverting the comparison to slot_history_key(a) == slot_history_key(b) reds it, which is the direction that would stamp closed on a file a live slot is writing.
  • The drain's result. test_delete_handover_write_failure_fails_the_close_and_names_the_rows asserts the 500 and the history_save_failed code alongside the row-count log (and that the replacement is still untouched — reporting the loss is not a licence to undo the hand-over); test_cleanup_handover_write_failure_is_reported_failed asserts the failed entry. Dropping the two if not drained: branches reds both.

Three pre-existing tests moved with the behaviour rather than being loosened: the linked-slot drain test now binds its recreate to the same key (which is what a cron re-injecting the same job produces, and is what makes it a hand-over at all — the unbound pairing is the new divergent test), the drain-failure test now expects the 500 it always should have, and the three restricted-marker tests whose save_slot_off_loop stub raised on EVERY call now record instead, since raising also failed the drain and turned those exits into the drain-failure case. Each still pins what it pinned before: exactly one write, closed=False and rows_only.

The durability half runs the REAL save_slot_off_loop against a real ConversationLog rather than a stub, in five cases: the pre-save hand-over persists the tail and keeps the replacement; the same exit on a linked_session_key slot writes that slot's own transcript (and nothing onto dashboard:{name}); a store that cannot take the tail reports the exact row count; the delete failure arm's hand-over persists the tail; and cleanup's hand-over persists the tail plus the note it was still holding in _deferred_notes, leaving that list empty. Stubbing _persist_handover_tail to return early reds exactly those five plus the three guard assertions that changed from "no save" to "no closed=True save".

The close flags' side of the deferral, proved by reverting. test_delete_handover_keeps_a_dismissal_the_replacement_committed publishes the replacement's line, commits its dismissal at closed_at=1234.0 while the original's drain is still owed, and asserts both flags survive with the instant intact; putting closed/closed_at back into ROWS_ONLY_OWNED_META_KEYS reds it with "the drain erased a dismissal the replacement committed". Its sibling test_delete_handover_erases_a_stale_closed_flag_on_its_own_line pins the other branch — the replacement publishes nothing, the drain meets the original's own line, and the erase still happens. Both assert _disk_window_len == 2 before the close, because the earlier single test published AFTER the tail was appended, which committed the tail and reduced the drain to a no-op that satisfied every line assertion vacuously.

Local gate green: isort, flake8 src/kiro_crew test, mypy --platform linux src/kiro_crew (0 errors, 1,281 files), scripts/check_black_formatting.py, scripts/check_subprocess_encoding.py, scripts/check_brand_name.py, scripts/check_harness_parity.py, scripts/docs-lint.sh, plus every test module that touches a slot save, a metadata carry or the close flags — the 49 modules matching save_slot_off_loop / _save_slot_to_history / carry_unowned_metadata / SLOT_OWNED_META_KEYS / clear_closed / update_metadata_if (3,669 passed). scripts/run_scoped_tests.py --surface backend escalates this diff to the full suite, which was run once.

Pattern harvest

Rule candidate: semgrep (new rule, semgrep/popped-key-identity-guard.yaml, alongside the existing find-sentinel-truthiness.yaml + its semgrep-tests/ fixtures)

Pattern: within one function body, $D.pop($K, ...) assigned to $V, followed later by an identity guard on $D.get($K) is $V (or $D.get($K) is not $V). The comparison is structurally near-constant: the key was just removed, so on the ordinary path the lookup is None and the guard answers the opposite of what the author meant. Both directions are expressible as an ordered pattern: | $V = $D.pop($K, ...) \n ... \n $D.get($K) is $V, and the fix the rule should suggest is the three-way form that classifies the absent state explicitly (current = $D.get($K); current is None or current is $V).

Why this class and not a one-off: a post-mutation ownership re-check that fails toward "skip the work" is invisible in every direction a reviewer normally looks. This one type-checked, passed flake8/black/isort, read correctly in prose (the docstring faithfully described the wrong predicate, so prose and code agreed), and returned HTTP 200 on the broken path — the only observable was a leaked kiro-cli session per tab close, an unwritten transcript, and archived=0 from bulk cleanup. It is the same shape as the repo's own is_kiro_cli fail-OPEN lesson in AGENTS.md (§ Harness parity) generalized off the harness axis: a guard whose default answer is the permissive one goes unnoticed until someone pays for it.

Secondary rule candidate: AUTOSDE.yaml recurring-defect-patterns — add a bullet for the reviewer-judgment half that semgrep cannot see. "A diff that answers the SAME ownership/liveness question with two DIFFERENT predicates is self-contradicting; one of them is wrong. Flag the disagreement, do not guess which side is intended." This PR contained both spellings: the shared _slot_still_ours used get(name) is slot, while the two failure arms it was written to serve hand-rolled current is None or current is slot inline. The correct answer was already present in the diff, four lines away from the defect, in the author's own hand — which is a mechanically detectable signal and stronger evidence than any single site read alone. This slots naturally next to the existing "docstring that CONTRADICTS the code below it" bullet, as the code-vs-code variant of it.

Not generalizable (the second half): the accompanying mypy failure — a return web.json_response(...) left inside close_slot(...) -> None — is not a defect class, it is an unrun gate. The change was authored against a base where that teardown was still inline in api_chat_slot_delete, and the sandbox had no pytest-asyncio, so the PR shipped on static checks with its own nine new tests never executed; seven of them fail or hang against the code as written. No lint rule retires that. The existing python3 scripts/check_black_formatting.py && ... && mypy && python -m pytest gate in AGENTS.md already covers it, and CI caught both within minutes.

@bolichen97
bolichen97 requested a review from a team as a code owner August 31, 2026 05:11
@bolichen97
bolichen97 requested a review from pepmach August 31, 2026 05:11
@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: checking Automated validation is still running labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 86a61b87279e8d0bca11c5a6b19e00fb1c411c25 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 86a61b8

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 86a61b87279e8d0bca11c5a6b19e00fb1c411c25: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 86a61b87279e8d0bca11c5a6b19e00fb1c411c25 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound yield-to-replacement design for a real race, honestly scoped; the residual risks are convention-maintained metadata ownership and a retry-ambiguous shared error code.

Watch

  • ROWS_ONLY_DEFERRED_META_KEYS is safe only by hand-maintained convention for describing fields. New slot-owned keys auto-defer (the set is derived from the difference), but the four extras (title_origin, created_by, origin, …) were hand-enumerated because they "DESCRIBE an owned field without being owned" — the next field of that shape lands outside the set silently, and the drain then commits "a line that matches NEITHER slot", the exact failure the comment warns about, with no gate or test forcing classification.
  • The hand-over drain failure reuses history_save_failed, but its retry semantics invert. On the ordinary failure arm the slot is restored, so a client retrying the close re-closes the same tab; on the hand-over arm ("there is nothing to roll back… the report IS the whole remedy") the replacement holds the key, so the same retry deletes the user's freshly recreated tab. A distinct code, or a documented non-retryable marker on that path, would remove the ambiguity.

Suggestions

  • The acknowledged residual (ownership predicate inside _locked(history_key)) and the same-shaped _remove_slot_for_history_key gap are named only in prose — file the two follow-up issues so "tracked as follow-up" is literally true.

[DESIGN-REVIEWED] 86a61b8

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 86a61b87279e8d0bca11c5a6b19e00fb1c411c25 — 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 checks are complete. The change is a declared fix with every mechanism derived from a named harm; the one first-principles finding is a counted unfixed sibling of the same pop→await→destroy pattern on the history-delete path.

First-Principles-Verdict: CONCERNS

The teardown-race fix is real and complete for the close paths, but the same pop→await→destroy pattern survives unguarded in history delete.

What this change ships

Intent: stop a tab closed mid-recreate from archiving, tearing down, or clobbering the replacement that took its key — a FIX.

  1. Close no longer stamps closed on a transcript a same-key replacement writes — justified
  2. Close/cleanup skip sessions.remove when another slot owns the key — justified
  3. Failure arms restore the original only when the key is free or still its — justified
  4. Yielded closes still persist the original's unsaved rows and held notes — justified (consequence of 1–2, declared)
  5. Incognito 403 marker follows the key's current holder at every exit — justified
  6. App worker stays paused (dismissal kept, warning logged) when a replacement holds the key — justified
  7. A failed hand-over drain returns 500 / failed instead of a silent 200 — justified
  8. New rows_only save mode + two ROWS_ONLY_* key sets deferring another writer's metadata line — declared, one consumer (_persist_handover_tail, chat_handlers.py:2530)
  9. Cleanup's archived list omits keys with a live holder — justified
  10. session.md spec section — mandated by AGENTS.md same-commit rule

Watch

  • One unfixed sibling of the root cause. Grepped _slots.pop( (20 hits): all but one are same-frame rollback pops, where a concurrent recreate returns the same object so no replacement can exist. The exception is the history-delete teardown (handlers/sessions.py:1312 pop → crew purge, pin cleanup, 2.0s task-cancel awaits → sessions.destroy(effective_session_key(slot)) at :1376) — no ownership re-check, so a recreate landing in that window has its live session destroyed, the exact harm item 2 fixes. Delete semantics soften it (the replacement's transcript is being erased anyway), so accepted-and-deferred, but the description's routing-facts survey doesn't name it.
  • rows_only deserves its complexity only because _save_slot_to_history rebuilds the metadata line; ConversationLog.append (history.py:1898) writes rows without touching the line but cannot carry the original's uncommitted metadata edits or the window reconciliation, so it is not a second spelling — noted so nobody later "simplifies" the drain onto it.

[FIRST-PRINCIPLES-REVIEWED] 86a61b8

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 86a61b87279e8d0bca11c5a6b19e00fb1c411c25 — this comment is updated in place on each push.

Review details

I have enough to conclude. Let me verify the one remaining fact for CANDIDATE 1 — that the empty-window branch (_save_slot_to_history lines 2644–2804) is genuinely unmodified by this diff and does not honor rows_only/dirty when force=False, closed=False. The diff only touched the non-empty-window path (around line 3108) and added the rows_only param; the if not window: / if force or closed: branch is unchanged. Confirmed from the diff and the file.

My analysis:

CANDIDATE 1 — grounded and survives. A message-less slot can carry a deferred _dirty metadata edit that is not force-saved (e.g. the color route, chat_handlers.py:7511, sets slot._dirty = True and returns 200). The periodic flush skips it (flush_slot_now, dashboard_persistence.py:98: not slot.messages → return), so its only durability path is a closed=True empty-window merge on close. The new hand-over drain replaces that with save_slot_off_loop(closed=False, rows_only=True) (force=False), which in _save_slot_to_history hits if not window:if force or closed: is False → return True writing nothing, yet _persist_handover_tail returns True and the close reports 200. This contradicts the PR's own pinned invariant for the non-empty case (test_delete_handover_persists_the_original_uncommitted_metadata). Severity: loss of a UI metadata edit (not conversation rows — unsaved==0 here) on a dismissed tab's shared transcript → FINDING, not BLOCKING.

CANDIDATE 2 — dropped. flush_deferred_notes calls only in-memory slot.append; the candidate itself concedes reachability is thin ("rarely raises"). Input (a) resolves to "might raise," failing the falsification bar, and it is an API-consistency issue, not a BLOCKING class.

Line 1 punchline note: there is no BLOCKING finding, so no [BLOCK-MERGE] and no block reason; one advisory FINDING survives.

Message-less dirty slots lose an acknowledged metadata edit on the new hand-over drain.

FINDING — src/kiro_crew/dashboard/chat_handlers.py:388 — for a message-less slot with a deferred _dirty edit (e.g. color set via chat_handlers.py:7511) and no unsaved rows, _persist_handover_tail skips the early return and calls save_slot_off_loop(closed=False, rows_only=True), which lands in _save_slot_to_history's if not window:if force or closed: (both False) branch and writes nothing while returning True, so the acknowledged edit is silently lost even on the original's own line — unlike the closed=True ordinary close and unlike the non-empty-window case the PR pins → Fix: in _persist_handover_tail, when the window is empty but the slot is _dirty, route the metadata through the empty-window merge in a rows-only-aware way (so a line the original owns rebuilds and a line another writer owns still defers), rather than relying on a force/closed flag that this open-shaped drain deliberately does not set.

[OPUS-REVIEWED] 86a61b8

Verdict parsed from the review's SHA-scoped output markers for commit 86a61b87279e8d0bca11c5a6b19e00fb1c411c25.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 86a61b87279e8d0bca11c5a6b19e00fb1c411c25: <one-sentence reason>

@bolichen97
bolichen97 force-pushed the fix/slot-close-teardown-race-7191 branch from 80a0dc6 to 8964ed5 Compare September 1, 2026 07:17
@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 Sep 1, 2026
@bolichen97
bolichen97 force-pushed the fix/slot-close-teardown-race-7191 branch 2 times, most recently from 8964ed5 to f3e5e0e Compare September 1, 2026 07:56
@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 Sep 1, 2026
@bolichen97

bolichen97 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Round 3: escalating instead of pushing a fourth patch

RESOLVED — no maintainer ruling is needed. The third option this comment could not
find does exist and has landed on 0456217ed02da9038d095db8d4c4c616a786453e: every
hand-over exit now drains the original's unsaved window (and any held _deferred_notes)
onto slot_history_key(slot) with closed=False, so the replacement survives AND the
rows are durable. Five tests pin both halves together against a real ConversationLog.
The reasoning below is left intact because it is what the fix was built from; the
A/B/C options in it are no longer live. See the fixed disposition record for
span=78d6e248f252.

Three consecutive rounds have landed blocking findings in this one file:function span, which is the trigger AGENTS.md's prepare-pr guidance names for stopping and opening a restructure round rather than shipping the next point-fix. So this round changed no code. Here is the branch table it asked for, what the table shows, and the one decision I cannot make.

Every exit of both teardown paths

[S] = _slots entry · [R] = _restricted_keys · [A] = app-close undo · [L] = armed nudge loop · [D] = persisted closed=True

EVERY EXIT OF BOTH TEARDOWN PATHS, and what each does with: [S]=`_slots` entry, [R]=`_restricted_keys`, [A]=app-close undo, [L]=armed nudge loop, [D]=persisted `closed=True`.

=== THE INVARIANT THAT SPLITS THE TABLE IN HALF ===
`get_or_create_slot(name)` returns the INCUMBENT when the key is occupied. So a same-key
replacement can only come into existence while `name` is ABSENT from `state._slots`. Every
pre-pop exit is therefore immune for free — which is exactly why no finding in three rounds
has ever landed on one — and every post-pop exit owes an ownership answer for all five pieces.

--- close_slot: PRE-POP exits (key held; NO replacement can exist) ---
E1 first nudge-retire failed  | S: never popped (original)      | R: untouched (original still owns key) | A: notify not yet called, nothing owed | L: restored via `_restore_slot_nudge_loop` | D: never written
E2 app close-hook failed      | S: original                     | R: untouched | A: hook returned False, nothing committed | L: restored | D: never written
E3 late nudge-retire failed   | S: original                     | R: untouched | A: `notify_slot_close_undone` called | L: restored | D: never written
E4 `pre_pop_check` raised     | S: original                     | R: untouched | A: undone if `_app` | L: restored | D: never written

--- close_slot: POST-POP exits (key free -> a replacement CAN exist) ---
E5 pre-save guard fires (NEW) | S: REPLACEMENT retained         | R: `_resettle_restricted_key` -> re-derived from the REPLACEMENT | A: deliberately NOT undone (dismissal did happen for the original; resuming would re-arm a worker onto the replacement) — but with NO log line, unlike E7 | L: left retired (correct: arming would put a clock on the replacement's key) | D: NOT written  -> returns SUCCESS
E6 save raised, key still ours| S: `_slots[name] = slot` (original back; once back, no replacement can be minted, so this arm's later awaits are safe) | R: resettled from the restored original | A: undone | L: restored (its own `get_slot(name) is slot` gate passes) | D: not written -> raises `history_save_failed`
E7 save raised, replacement owns key | S: replacement retained  | R: resettled from the replacement | A: NOT undone + `logger.warning` | L: NOT restored (admission predicate fails) | D: not written -> raises 500 even though the end state equals E5's success
E8 save SUCCEEDED, pre-remove guard fires | S: replacement retained | R: resettled from the replacement | A: kept | L: retired | D: ***`closed=True` IS ON DISK UNDER A LIVE REPLACEMENT*** -> returns SUCCESS   <== ROUND-3 HOLE
E9 ordinary close             | S: key gone                     | R: dropped (resettle with no owner -> discard) | A: told, not undone | L: retired | D: written; `sessions.remove` ran -> SUCCESS

--- api_chat_slots_cleanup: per-key exits ---
K1 pop returned None          | nothing touched, `continue`
K2 pre-save guard fires (NEW)  | S: replacement retained | R: resettled from replacement | A: n/a (bulk archive deliberately never calls `notify_slot_closed`) | L: n/a (slots with armed loops are exempt via `_looped` at :4137) | D: not written -> `continue`, counted in NEITHER `archived` nor `failed`
K3 flush/save raised, still ours | S: restored | R: resettled | A: n/a | L: n/a | D: not written -> `failed`
K4 flush/save raised, replacement owns key | S: NOT restored | R: resettled from replacement | A: n/a | L: n/a | D: not written -> `failed`; note the error row is appended to the POPPED original, which nobody will ever see (pre-existing arm shape, harmless)
K5 save SUCCEEDED, pre-remove guard fires | S: replacement retained | R: resettled | D: ***`closed=True` ON DISK UNDER A LIVE REPLACEMENT*** -> `continue`, not counted archived   <== ROUND-3 HOLE, 2nd instance
K6 ordinary archive           | R: dropped | D: written; `sessions.remove` ran -> `archived`

--- THE HISTORY COMMIT ITSELF (`chat_persistence._save_slot_to_history`) ---
C1 empty window + closed=True -> `update_metadata_if(key, fields, guard)`. The guard DOES run under
   the cross-process lock, and refuses an empty record (no line -> no phantom file). It tests the
   RECORD, never the closer's ownership of the key. `_fresh_fields()` writes `closed`/`closed_at`
   ONLY when closed=True, so this branch can never clear a stale flag either.
C2 non-empty window -> `with conversation_log._locked(history_key)`: the delete-won identity guard
   (`slot._disk_meta_created_at` vs the on-disk `created_at`) runs INSIDE the lock, then `meta_line`
   is rebuilt with `closed: True` and `atomic_write` replaces `meta + frozen_prefix + window`.
   The one in-lock identity test answers a DIFFERENT question — "was this session DELETED?" — and a
   same-key recreate keeps the file and carries `created_at` forward, so it passes by construction.
C3 delete-won -> returns `False` without writing; archival callers ignore `False`.

=== WHAT THE TABLE SHOWS ===
(1) Rounds 1-2 are all LOOP-OWNED state (S, R, A, L). For those a SYNCHRONOUS check adjacent to the
    mutation is a correct linearization point, and the awaited app RPC is correctly slaved to the
    synchronous `restored` decision. `_slot_still_ours` + `_resettle_restricted_key` + the
    `restored` coupling is the right instrument, and E1-E4 + E6's re-occupation of the key show why
    the class is genuinely closed rather than narrowed.
(2) Round 3 is the ONLY finding in DISK-OWNED state, and it is categorically different: [D] commits
    under `ConversationLog._locked(history_key)` on an executor thread, so no loop-side ordering can
    ever be adjacent to it. E8/K5 are unfixable from `chat_handlers.py` alone.
(3) Two nits the table surfaces that no round has named: E5 takes E7's decision with no operator log
    line, and E7 returns 500 for a state E5 calls success.

The invariant I would implement

NONE ADOPTED — no code changed this round. The invariant I would implement, and am handing over:

A teardown may commit durable state only under a predicate evaluated INSIDE the same lock that commits it. Split by where state lives: loop-owned state (_slots, _restricted_keys, the nudge registry, the app-undo decision) is settled by a synchronous check adjacent to its mutation — already true at this head; the closed=True row must be settled by an ownership predicate evaluated inside _locked(history_key).

Shape, with an in-repo precedent: thread commit_if: Callable[[], bool] | None through save_slot_off_loop -> _save_slot_to_history, evaluated inside with conversation_log._locked(history_key) beside the existing delete-won guard (and inside update_metadata_if's guard for the empty-window branch, which already runs under the lock). Refusal reuses the existing False = "skipped, not committed" return. Callers pass commit_if=lambda: _slot_still_ours(state, name, slot) — one GIL-atomic dict lookup plus an identity compare, safe from the executor thread, and it must stay read-only (_resettle_restricted_key is loop-only). close_slot/cleanup then treat False as the same key-yielded exit as E5. The precedent is api_chat_slot_resume's clear_closed(history_key, only_if_closed_before=resume_started_at), whose own comment states the principle: "only_if_closed_before moves the comparison inside the store's own lock, so there is no window between the check and the write." A conditional SET is the missing mirror of that conditional CLEAR.

Why that makes the class unreachable rather than narrower: if the recreate lands after the in-lock predicate, the row was CORRECT when written (the replacement did not exist yet) and the replacement merely inherits it — which I measured to be byte-identical to the ordinary sequential close-then-reopen. That is not a race; it is the designed lifecycle of a reused key, and it is what the resume path's in-lock compare-and-clear exists to compensate.

Why I did not implement it here: the finding's actual bite comes from the OTHER half of the same TOCTOU, in api_chat_slot_resume, which reads meta before publishing the slot and calls clear_closed only if meta.get("closed") — in the race the close's write lands after that read, so the compensation never fires. Fixing only the writer leaves the reader's hole. A complete fix spans close_slot, api_chat_slots_cleanup, api_chat_slot_resume, chat_persistence.save_slot_off_loop/_save_slot_to_history, and the ConversationLog durable-metadata contract: three modules, a new save-API parameter, and a semantic change. That is a separate PR, and it is the 4th consecutive change to this span — which is the shape the restructure rule exists to stop.

  • Replacement lost (round 1) — closed and pinned at f3e5e0ee6: _slot_still_ours now guards all six post-pop sites across both teardown paths (close_slot :3924/:3948/:4005, api_chat_slots_cleanup :4229/:4258/:4294), and the predicate's polarity — an absent key is the ordinary post-pop state, so None must read as still-ours — has its own pin in test_still_ours_treats_a_freed_key_as_ours alongside the two ordinary-path tests that prove the guard is inert when nothing races.

  • Loop retired before the persist (round 1) — holds at f3e5e0ee6: the retirement is the first thing close_slot does, is re-arbitrated after the app hook's awaits, and the pop follows it with no suspension in between, so the persist can only run against an already-retired loop; every restore is admission-gated on slot identity (state.get_slot(name) is slot), which is what keeps exits E5/E7/E8 from arming a clock on a replacement's key.

  • Cleanup vs. armed loops (round 1) — closed at f3e5e0ee6: the idle-archive pass resolves the armed-loop key set once outside its per-slot loop (folding both the raw and normalized slot-key spellings) and exempts those keys before any pop, which is why the bulk path's new key-yielding exit owes neither a loop restore nor an app-dismissal undo.

  • "The save never ran" (round 1) — the assertions now pin the save's arguments in both directions (saved_closed == [True] on the ordinary close, saved == [] when the first guard fires), but I want to record the residual rather than claim more than that: every test in the file stubs save_slot_off_loop, so none exercises the real durable write — which is precisely why the round-3 hole is invisible to this file, and a real-ConversationLog interleaving test is the first thing the follow-up owes.

  • App-worker resume on a skipped restore (round 2) — closed at f3e5e0ee6 by coupling notify_slot_close_undone to the restored decision instead of to slot._app, with both arms pinned; the branch table did surface a sibling nobody has named, which I am recording rather than patching: the pre-save-guard exit at :3924 takes the identical "app keeps the dismissal" decision with no logger.warning, so the more common of the two hand-overs is the silent one.

  • Stale restricted marker on the early exits (round 2) — closed at f3e5e0ee6 by making it one shared postcondition rather than per-exit cleanup: _resettle_restricted_key re-derives dashboard:{name} from whoever holds the key now, all six exits route through it, and it re-derives rather than discards so a restricted replacement keeps its marker (discarding is the fail-open direction) — with the restricted-replacement case pinned separately from the hand-over case.

  • Ownership check races the history commit (round 3) — the mechanism is real and I reproduced it against the real ConversationLog (executor thread parked inside the closed=True write, recreate minted on the loop in that window: the row ends closed: True with the replacement owning the key, and _recent_session_payload returns None so the tab is not restored) — but the same harness disproves the prescribed remedy and relocates the defect: with _slot_still_ours forced True (i.e. origin/main) the on-disk row is identical, so this diff neither introduces nor widens the durable-write race and reverting the guard removes none of it while re-adding four harms; no content is lost and the row self-heals on the replacement's next full save; and the sequential close-then-reopen with zero concurrency produces a byte-identical row, which means the end state is the designed lifecycle of a reused slot key that api_chat_slot_resume compensates with an in-lock compare-and-clear (clear_closed(..., only_if_closed_before=resume_started_at)) — what the race actually defeats is that compensation, because the resume reads meta before publishing the slot, so a correct fix needs the in-lock ownership predicate on both halves (a commit_if threaded into _save_slot_to_history and the resume's read-then-clear) across three modules and the durable-metadata contract; that is a separate PR and a fourth change to this span, so I have stopped and am escalating the ruling rather than patching again.

What needs your ruling

  1. THE RULING I NEED: does PR fix(dashboard): preserve concurrent same-key recreate during slot-close teardown (#7191) #7212 carry the cross-module in-lock ownership fix, or land as the loop-owned half with a follow-up? Option A (land as-is + follow-up): the head is complete and correct for every LOOP-OWNED piece of state, gates are green, and the remaining durable-write hole is measurably identical on origin/main and identical in the no-concurrency sequential case. Option B (gate this PR on the full fix): I implement the invariant below, which is a 4th change to a span that has produced a blocking finding in three consecutive rounds and where two of those rounds found instances the reviewer had not named. My recommendation is A, because the evidence for the restructure rule's premise is now quantitative rather than anecdotal.

  2. SCOPE OF THE FOLLOW-UP, if you pick A. It is NOT just the closer. api_chat_slot_resume (chat_handlers.py:5674) reads meta BEFORE publishing the slot and calls clear_closed(history_key, only_if_closed_before=resume_started_at) only if meta.get("closed") — so a close whose closed=True write lands after that read is never compensated. The reopen-from-History flow is the only realistic same-key recreate (generated slot names carry an epoch suffix, so a collision needs a caller-supplied name), which makes the resume side the half that actually reaches a user. Fixing only close_slot/api_chat_slots_cleanup leaves the hole open. Full span: those two handlers + api_chat_slot_resume + chat_persistence.save_slot_off_loop/_save_slot_to_history + the ConversationLog durable-metadata contract, plus a spec update to session.md and history.md.

  3. ONE DESIGN QUESTION INSIDE THAT FOLLOW-UP, which is a product call and not mine to make: should the class instead be closed at the SOURCE with a state._closing_keys reservation that get_or_create_slot honours? That is the only candidate that makes all seven findings unreachable through a single chokepoint (a replacement can only exist because the key is free), but it turns a hot synchronous API into one that can refuse or await, and it needs a decision on what a same-key POST /api/chat/slots should DO while a close is in flight: 409, await the close, or adopt the incumbent.

  4. TWO NITS THE BRANCH TABLE SURFACED THAT NO REVIEW ROUND HAS NAMED, deliberately left unpatched so this span stops changing: (1) the pre-save-guard exit (chat_handlers.py:3924) takes the same "the app keeps the dismissal" decision as the failure arm but with no logger.warning, and it is the more common of the two hand-overs — so the frequent case is the silent one; (2) that exit returns SUCCESS while the failure arm at :3960 raises a 500 for an end state that is identical (the original is gone, the replacement lives, nothing was persisted). Fold both into the follow-up.

Notes

  • MEASUREMENT METHOD, so it can be re-run. Two scratch modules under the worktree's test/ (deleted afterwards; git status --porcelain is empty). Real ConversationLog via chat_test_helpers._make_state. chat_persistence._save_slot_to_history wrapped so that when closed=True the executor thread sets a threading.Event and blocks on a second one; the test awaits the first via asyncio.to_thread, mints the same-key replacement ON THE LOOP inside that window, then releases. Three arms: guard present + replacement force-saves; guard present + replacement silent; handlers._slot_still_ours monkeypatched to always-True (= origin/main's behaviour). Plus a sequential no-concurrency baseline, and a self-heal arm. Raw JSONL lines dumped after each step.

  • WHY THE RESIDUAL WINDOW IS NOT NEGLIGIBLE, stated against the PR's own comment. The comment at chat_handlers.py:3919 says "_slot_still_ours is a synchronous identity check, so no recreate can slip between it and the save that follows on this same frame." That is true of the CALL but not of the COMMIT: save_slot_off_loop reaches its write through await loop.run_in_executor(None, _do), which submits and yields. Because that is the process-wide default executor — shared with every other patient off-loop history save and every asyncio.to_thread — the submit-to-lock-acquire gap is not merely small; under executor saturation it is unbounded. If the PR lands as-is, that sentence should be corrected to say the guard closes the wide pre-save awaits (task cancel up to 2.0s, the app-notify awaits, nudge retirement) and NOT the commit itself.

  • WHAT THE GUARD DEMONSTRABLY BUYS, measured rather than asserted, so the escalation is not read as "this PR does nothing". With the guard defeated the same interleaving additionally: called sessions.remove("dashboard:<name>") on the live replacement's session key, tearing down the provider process a visible tab is driving; let the failure arm write state._slots[name] = <original> over the replacement; left dashboard:{name} in _restricted_keys when the original was incognito, which _is_restricted_session tests BEFORE it looks at the slot, giving a persistent replacement a 403 on every memory, artifact and mcp-apps call for as long as that tab lives; and let notify_slot_close_undone re-arm an app worker whose watchdog resolves slot_key through a bare state.get_slot(...) with no ownership test. None of those four self-heal.

  • A CONSEQUENCE OF THE GUARD WORTH RECORDING, checked for the OPPOSITE failure mode per the widening corollary. Skipping sessions.remove means the replacement INHERITS the original's live _Session — same provider process, same context, and the per-key state session_lifecycle.remove would have cleared (_compact_cooldown_until, _suppress_replay, _compact_pending_verdict, _origin_links). I checked this against session.md's own warning that remove clears the compaction cooldown "because slot keys ARE reused and a leaked flag would starve the NEXT holder of that key of its re-anchor", and concluded it does NOT apply: that sentence is about a NEW session under a reused key inheriting a stale flag, whereas here it is the same live session continuing, so the cooldown is still about the session it describes. I also checked the incognito angle — a persistent replacement inheriting an incognito original's session context — and it is not a new leak, because restricted slots are persisted to history regardless (memory_mode is stored in the metadata line; is_restricted blocks memory writes, not the transcript), and a same-key resume would re-inject that transcript anyway. Recording both so the next reviewer does not have to re-derive them.

  • PIECES OF STATE THE BRANCH TABLE FOUND UNCOMPENSATED ON EVERY EXIT, and why I am not raising either as a finding. (1) The note_slot_closed tombstone (channel_slots._RECENT_CLOSES, TTL 3600s) is never cleared by ANY exit, including the four pre-existing failure arms that restore the slot — so a channel reconcile can decline to re-surface a restored or replacement conversation for up to an hour. Uniform and pre-existing: origin/main leaves it set in the same scenario, so the PR neither introduces nor widens it. (2) api_chat_slots_cleanup's K4 arm appends its "archiving this tab failed" error row to the POPPED original, which is not in _slots and which nobody will ever see. Pre-existing arm shape, harmless.

  • PR BODY / DIFF NOTE for whoever reads the diff next: git diff origin/main..HEAD in my worktree shows 11 files because the local origin/main ref has advanced 3 commits past this branch's parent (1d705a0 vs 5603ae7). The commit itself is exactly 3 files — src/kiro_crew/dashboard/chat_handlers.py, test/test_slot_close_recreation_race.py, docs/system-specs/modules/session.md — and git rev-list --count origin/main..HEAD is 1. A rebase onto current main will be needed before merge; I did not rebase, since rebasing would have required re-gating and I am pushing nothing.

@bolichen97
bolichen97 force-pushed the fix/slot-close-teardown-race-7191 branch from f3e5e0e to 2f81851 Compare September 1, 2026 23:31
@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 Sep 1, 2026
@bolichen97
bolichen97 force-pushed the fix/slot-close-teardown-race-7191 branch from 2f81851 to 386d513 Compare September 2, 2026 01:48
@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 Sep 2, 2026
@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: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 4, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Dependency Audit follow-up: eight attempts, all infrastructure

Everything else at 86a61b87279e8d0bca11c5a6b19e00fb1c411c25 is green — all four Linux backend
shards, Coverage Gate, Coverage Combine, all four Windows shards, all four frontend shards,
and all four review lanes. Dependency Audit / Audit Production Dependencies is the only
remaining red, and it has now failed eight consecutive attempts without ever reporting an
advisory:

Seven attempts ended in npm audit timed out after 120s — on site/package-lock.json,
website/electron/package-lock.json, and website/package-lock.json at various times, i.e.
whichever lockfile the sequential pass happened to reach. One ended in
npm audit returned an error object: {'summary': '', 'detail': ''}, an empty tool error rather
than a finding. scripts/check_npm_audit.py and its AUDIT_TIMEOUT_SECONDS = 120 are
long-standing on main and untouched here, so the timeout floor did not move.

The gate is intermittently succeeding elsewhere in the same window (PR #8315 among others), which
is what says the remedy is a rerun rather than a change: registry egress from the runners is
flaky right now. Re-running remains the only correct action — this branch modifies no lockfile,
no package.json and nothing under website/, and a .vulnerability-exceptions.json entry is
for an unremediable advisory, not for a tool timeout, so adding one here would be suppressing a
gate that has not actually reported anything.

PR Readiness reads failure solely because of that one item. Once the audit passes it needs an
explicit pr-readiness.yml workflow_dispatch to recompute, because the periodic sweep only
re-fires pending readiness statuses, not failure ones.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

Resolved: every check is green at 86a61b87279e8d0bca11c5a6b19e00fb1c411c25

Dependency Audit / Audit Production Dependencies passed on the eleventh attempt with no code
change, which closes out the diagnosis in the two comments above: registry egress from the
runners recovered and the audit reported clean. PR Readiness recomputed itself to success on
the back of it, so no workflow_dispatch was needed after all.

Final state at this head: 64 checks, all success or skipped, none failing. All four review lanes
(GPT 5.6 Review, Opus 4.8 Review, First Principles Review, Design Review) are green and
fresh with zero blocking findings, and the disposition record set is clean at 15 records with no
violations. One commit, mergeable, no push was made during this pass because none was needed.

The only open item is the Opus advisory FINDING recorded above — a maintainer scope call, not a
blocker.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • PR #5933 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #5933: KEEP. No conflict measured (the two new force=True calls in PR #7212 are in its test file, so the census is unaffected, and rows_only=True reuses the one existing src call site). But two partial-save protocols on one seam, arriving separately, is the drift PR #5933's own docs argue against -- the authors should agree whether persist_swept_slot_meta should be built on rows_only rather than beside it. Files: src/kiro_crew/dashboard/chat_persistence.py.
  • PR #6807 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #6807: MERGE_DISCUSSION. Same close path, different layers and different goals (client list ordering vs server same-key-recreate safety), and they merge cleanly at the text level while contradicting each other semantically. If 7212 lands, definitive=True on history_save_failed needs to be conditioned on the restore actually happening, or the refused-close copy will invite a retry against a replacement. Files: src/kiro_crew/dashboard/chat_handlers.py. The two independent directions used different labels; the matrix conservatively retains OVERLAPPING for coordination.
  • PR #6813 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #6813: KEEP. 7212's deferred-key set is derived from the frozenset 6813 extends, so landing both silently decides how pending_context behaves on a rows-only save; the census tests 6813 adds should be made to cover that third class explicitly. Files: src/kiro_crew/history.py, src/kiro_crew/dashboard/chat_persistence.py.
  • This PR is OVERLAPPING with PR #4904. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #7212: CONTINUE_DEVELOPMENT. Distinct goals (fork merge-back vs teardown race) with a real code-level collision in close_slot and api_chat_slot_delete. Both should land; the second needs a manual rebase in those two spans. Files: src/kiro_crew/dashboard/chat_handlers.py, src/kiro_crew/history.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

@chenmingwei23 chenmingwei23 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approving: PR Readiness green (the repo's only required check), no failing lanes, MERGEABLE.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slot close: concurrent same-key recreation during post-pop teardown can clobber the replacement session

3 participants