Skip to content

fix(panel): route answers to the card's session, not the queue head - #1

Open
halindrome wants to merge 4 commits into
mainfrom
fix/answer-routing
Open

fix(panel): route answers to the card's session, not the queue head#1
halindrome wants to merge 4 commits into
mainfrom
fix/answer-routing

Conversation

@halindrome

Copy link
Copy Markdown
Owner

Fixes the misrouting reported upstream in wxtsky#308: with several sessions waiting, answering the notch card could deliver the answer to a different session.

Cause

The card is addressed by session (.questionCard(sessionId:) / .approvalCard(sessionId:)), but every answer path resolved queue.removeFirst(). Any mutation of the head while a card is open re-targets the answer:

  • handlePeerDisconnectdrainQuestions/drainPermissions(forSession:) removes the head when a session's socket drops (answered in the terminal, CLI exited), and showNextPending() only re-points the surface if Smart Suppress lets it auto-open — so a stale card can sit over a new head.
  • showNextPending() reorders permissionQueue; the tool-use cache removes entries by index.
  • A multi-question AskUserQuestion wizard collects answers over seconds of interaction.

Card rendering had the same split: chrome (project name, cwd) came from the surface's session, question/tool content from the head.

Change

  • approvePermission / denyPermission / dismissPermissionPrompt / answerQuestion / answerQuestionMulti / skipQuestion take an optional expectedSessionId and resolve that session's queued request.
  • When the addressed request is no longer queued, the action is discarded, the panel collapses, and showNextPending() re-opens whatever is genuinely waiting — no dead card, no fall-through to the head.
  • pendingPermission(forSession:) / pendingQuestion(forSession:) back the cards, so what is displayed and what is resolved cannot disagree.
  • Panel cards, session-list inline approvals, and keyboard shortcuts pass the acting session.
  • expectedSessionId: nil keeps head-of-queue behaviour for surfaces that only ever mirror the head (iPhone/Watch Buddy, answerCompanionQuestion, Codex app-server) — the Buddy wire protocol carries no session id, so that path is unchanged and still head-based.

Tests

Tests/CodeIslandTests/AppStateAnswerRoutingTests.swift — 7 cases covering cross-session answer/skip/approve routing, the drained-head stale-card case for both queues, session-scoped card lookup, and the unchanged head behaviour when no session is passed.

Full suite: 694 tests, 0 failures.

shanemccarron-maker and others added 2 commits August 12, 2026 08:58
Approving, denying, answering, or skipping from the notch resolved
`queue.removeFirst()`, while the card itself is addressed by session id.
Anything that mutated the head under an open card — a peer disconnect
draining another session, a stale tool-use eviction, the reorder inside
showNextPending() — delivered the answer to whichever request happened to
be first, i.e. a different CLI than the one shown. With two sessions
waiting this silently answered the wrong project.

The answer paths now take the acting card's session and resolve that
session's queued request; when it is gone (answered in the terminal,
drained on disconnect) the action is discarded and the panel resyncs
instead of falling through to the head. Cards also render the addressed
session's request rather than the head, so what is shown and what is
resolved cannot disagree.

Head-of-queue behaviour is unchanged for the surfaces that only ever
mirror the head (iPhone/Watch Buddy, Codex/companion paths).

Upstream issue: wxtsky#308

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
Round 1 confirmed one major and eight minor findings against the wxtsky#308 fix.

- major: a card whose request had been drained left the notch expanded and
  empty. Cards now render only their own session's request, so the previous
  "wrong content" case became "no content" whenever showNextPending() declined
  to reassign the surface (Smart Suppress). showNextPending() now collapses a
  card surface whose session has nothing queued, before deciding what to open —
  one guard covering every drain path, not just the answer paths.
- keyboard shortcuts passed surface.sessionId for any card kind, so a
  permission shortcut fired over a question card addressed a non-existent
  approval and discarded the live card. They now use kind-matched accessors.
- dismissPermissionPrompt got the stale-discard treatment the other actions had.
- the cards' "N of M" position was hardcoded to 1 while they may render a
  non-head request.

Tests: the stale-card case is now pinned by the suppression path that actually
reproduces it (verified red without the guard); routing tests assert queue
state before awaiting so a regression fails by name instead of hanging; the
head-of-queue test uses a two-element queue so it can fail; added coverage for
the single-answer path, dismiss routing, and the session-scoped lookups.

699 tests, 0 failures.

Not fixed, deliberately: within-session render-vs-resolve keying (hypothetical,
mechanism unreproduced) and pinning the view-layer call-site wiring, which needs
a SwiftUI view-test harness this project does not have. Both noted on the PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
@halindrome

Copy link
Copy Markdown
Owner Author

QA Round 1

MR: #1 fix/answer-routingmain (HEAD 3f20530)
Contract source: synthesized — no ticket linked; upstream defect report is wxtsky#308.
Panel: 3 lenses (contract-security, regression-edges, test-quality), all returned.
Verification: swift test on this HEAD — 694 tests, 2 skipped, 0 failures.

Panel navigation was degraded. All three lenses loaded the code-graph tools but
the codebase-memory index has no project covering /Users/ahby/Sources/CodeIsland
(project not found or not indexed; no ancestor in the 117-entry project list). Every
lens fell back to ctx_* capture with definition sites opened and cited directly, so
symbol claims are grounded — but the who-calls-X sweep was done by text search rather
than the call graph. Indexing this repo would strengthen the next round.

Mutation testing was not executed. The test-quality lens's mandate required
mutating production code and re-running the suite; its read-only constraint on the
shared working tree forbade it. Its mutation table (M1–M7) is reasoned from reading,
not run
, so findings F-06 through F-09 below carry slightly less evidentiary weight
than an executed mutation run would give them.

Contract Verification

Criterion Status Evidence
AC1 — approve/deny/dismiss/answer/skip resolves that card's session, never the queue head ✅ satisfied permissionIndex(expecting:) / questionIndex(expecting:)AppState.swift:1349, :1355; every mutator removes at that index (:1371-1378, :1571-1578, :1600-1602, :1786-1811, :1854-1884, :1958-1965). Tests at AppStateAnswerRoutingTests.swift:12, :73, :96 assert on resumed continuation payloads, not queue arithmetic.
AC2 — stale action discarded; no other session resolved; no dead card left on screen ⚠ partial Discard half correct (discardStalePanelAction, AppState.swift:1362-1368; tests :42, :118). Dead-card half not met — F-01, and dismissPermissionPrompt (AppState.swift:1600-1601) returns bare on the stale path — F-04. No test asserts appState.surface anywhere — F-06.
AC3 — cards render the request belonging to the surface's session ⚠ partial pendingPermission(forSession:) / pendingQuestion(forSession:) (AppState.swift:132-138) used at NotchPanelView.swift:209, :228. Holds across sessions; within one session the render→click window is still unbound — F-05.
AC4 — session-addressed call sites pass the session through ✅ satisfied Panel cards NotchPanelView.swift:219-222, :238-240; session-list inline :2284, :2291, :2298; shortcuts AppDelegate.swift:237-243. No session-addressed call site left on the head-of-queue default.
AC5 — session-less surfaces keep head-of-queue behaviour ✅ satisfied handleBuddyControlCommand (AppState.swift:1467-1488) and answerCompanionQuestion (:1490-1519) still call the no-argument overloads; expected == nil returns index 0 (:1350, :1356) — byte-identical to the old removeFirst(). Guarded by testOmittedSessionStillResolvesTheHead, though that test is weak — F-07.
AC6 — no regression in existing question/permission/Codex/subagent flows ⚠ partial 694 tests, 0 failures; Codex branches (AppState.swift:1801, :1866) re-indexed faithfully. Two uncovered behaviour changes ship: F-01 and F-02.
AC7 — new behaviour covered by tests that fail if routing regresses ✅ satisfied 7 tests in AppStateAnswerRoutingTests.swift; reverting remove(at: index) to removeFirst() breaks them. Caveats in F-06F-09.

3 of 7 criteria are partially satisfied. The cross-session routing fix — the substance of wxtsky#308 — is sound and correctly constructed. The gaps are all on the second clause of AC2 (panel state after a discard) and on how thoroughly the change is pinned by tests.


Finding 1 — Stale card leaves an expanded but empty notch panel

  • Severity: major · Status: confirmed · Category: edge-case / ui-state
  • Reported by: [claude:regression-edges] | [claude:contract-security]
  • Location: Sources/CodeIsland/NotchPanelView.swift:205-228, Sources/CodeIsland/AppState.swift:2044-2077

What was tested: what the panel renders when surface == .approvalCard(sid) / .questionCard(sid) but that session's request has left the queue, with other sessions still queued.

Expected: per AC2, the panel collapses or falls through to whatever is genuinely waiting — "the panel must not be left displaying a dead card."

Actual / risk: NotchPanelView.swift:206-225 guards on if let pending = appState.pendingPermission(forSession: sid) with no else branch, while shouldShowExpanded (:125-127) remains true. showNextPending() (AppState.swift:2044-2054) promotes the next session's request and returns true but only assigns surface conditionally — if surface != .sessionList, shouldAutoOpenPendingSurface(for: sid) — skipping the collapse branches at :2071-2074. shouldAutoOpenPendingSurface (:941-949) returns false under smart-suppress when the session's terminal is frontmost, and SettingsDefaults.smartSuppress = true (Settings.swift:139, registered :223) is the default. refreshDerivedState (:1067) never touches surface.

Sequence: approval card open for A → A answered in the terminal → handlePeerDisconnect(A) (:2005) drains A → showNextPending() finds B's permission but B's terminal is frontmost → surface stays .approvalCard(A). The notch sits expanded showing a dashed divider (:200-203) and nothing else.

Proportionality: this MR converts a wrong-content card (the wxtsky#308 symptom) into a blank one. discardStalePanelAction covers only the click path; nothing covers the request vanishing while the card sits idle. The two lenses split on severity (major vs minor) over how quickly it self-heals — it does clear on hover-exit (NotchPanelView.swift:401) or the next queue event. Recorded at the higher severity because it is the AC2 clause the MR explicitly claims. Fix is small: an else that collapses, or making showNextPending correct a surface it declined to auto-open.


Finding 2 — Keyboard shortcuts pass the surface session id for any card kind, collapsing a live card

  • Severity: minor · Status: confirmed · Category: logic-error / ux
  • Reported by: [claude:regression-edges] | [claude:contract-security] | [claude:test-quality]
  • Location: Sources/CodeIsland/AppDelegate.swift:234-243, Sources/CodeIsland/AppState.swift:1364-1368

What was tested: pressing the approve / approve-always / deny hotkey while a .questionCard or .completionCard is on screen, and the skip-question hotkey while an .approvalCard is on screen.

Expected: a hotkey that does not apply to the current card is inert. Pre-MR it was a silent guard !queue.isEmpty else { return }.

Actual / risk: AppDelegate.swift:237-243 passes appState.surface.sessionId unconditionally, and IslandSurface.sessionId (IslandSurface.swift:17-22) is non-nil for .approvalCard, .questionCard and .completionCard. A non-nil expectedSessionId with no matching queue entry takes the discardStalePanelAction branch, which unconditionally sets surface = .collapsed (AppState.swift:1366). The shortcut monitors (AppDelegate.swift:201-211) are global with no surface guard. So a question card for A is dismissed by a stray approve hotkey; showNextPending() reopens it only if shouldAutoOpenQuestionSurface (:951) passes, which under default smart-suppress it does not. Symmetrically the skip hotkey closes a live approval card, and with a completion card up the approve hotkey collapses it instead of approving a queued request for another session.

Proportionality: no wrong request is ever resolved, and the new behaviour is arguably safer than the old head-of-queue approve — hence minor. Filed so the behaviour change is a decision on record rather than an accident. Cheap fix: pass the id only when the surface case matches the action kind.


Finding 3 — queuePosition: 1 is now a lie

  • Severity: minor · Status: confirmed · Category: logic-error (display)
  • Reported by: [claude:regression-edges] | [claude:test-quality]
  • Location: Sources/CodeIsland/NotchPanelView.swift:214, :236

What was tested: the "n of N" position indicator when the surface's session is not first in the queue.

Expected: the counter matches the request actually shown.

Actual / risk: queuePosition: 1 is hardcoded while the rendered request is now pendingPermission(forSession: sid) / pendingQuestion(forSession: sid) (AppState.swift:130, :134), which first { … }-matches rather than taking index 0. handlePermissionRequest sets surface = .approvalCard(sessionId:) for a newly-arrived session (:1331-1333) while appending to the end of the queue, so [A, B] with the card on B is reachable — the card correctly shows B but labels it "1 of 2". Pre-MR the counter was accurate because the head was always what rendered. The session-list path already computes this correctly (NotchPanelView.swift:2262 uses idx + 1), which makes the card path's constant the outlier.

Proportionality: cosmetic; no routing consequence. One expression (approvalQueueIndex at :2157 already does the work). Defensible to defer if minimising diff.


Finding 4 — dismissPermissionPrompt is the one action that did not get the stale-discard treatment

  • Severity: minor · Status: confirmed · Category: logic-error
  • Reported by: [claude:test-quality] (corroborated in [claude:contract-security]'s AC2 row)
  • Location: Sources/CodeIsland/AppState.swift:1600-1601

What was tested: whether all five card actions that gained expectedSessionId handle the session-miss consistently.

Expected: a session miss routes through discardStalePanelAction like approve / deny / answer / skip do.

Actual / risk:

func dismissPermissionPrompt(expectedSessionId: String? = nil) {
    guard let index = permissionIndex(expecting: expectedSessionId) else { return }

A bare return — no collapse, no showNextPending() — versus approvePermission :1371-1377, denyPermission :1571-1577, skipQuestion :1958-1964. Caller is NotchPanelView.swift:222. Dismissing a card whose request was already drained leaves .approvalCard(sid) on screen with an empty body until an unrelated event calls showNextPending() — the same dead-card state as Finding 1, reached by a different door. It does not misroute; only AC2's second half is missed.

Proportionality: narrow window (a drain between render and click), two-line fix. One lens argued the branch is unreachable because the dismiss button lives inside the card body which does not render when the request is absent — but Finding 1 establishes that the expanded-but-empty state exists, and the hover/animation window makes the click plausible. Recorded rather than dismissed.


Finding 5 — Session-only keying still allows the resolved request to differ from the rendered one within a session

  • Severity: major · Status: hypothetical (mechanism confirmed; timing window not reproduced)
  • Reported by: [claude:contract-security]
  • Location: Sources/CodeIsland/AppState.swift:1349-1358, :1371-1378, :1571-1578

What was tested: whether the identity key the card renders with and the key the action resolves with are stable across the render→click window when a single session holds more than one queued request.

Expected: per AC3 — "the content shown and the request resolved cannot disagree." The wording is unconditional.

Actual / risk: both pendingPermission(forSession:) (:132-134) and permissionIndex(expecting:) (:1349-1352) resolve to the first entry matching the session id. A session can hold several queued permissions — handlePermissionRequest appends unconditionally (:1324), deduplicating only by tool_use_id (:1319), and AppState+ToolUseCache.swift:92 states the design intent outright: "Requests that DO carry a tool_use_id are left alone — they still wait for proper correlation so parallel tool calls don't deny each other (wxtsky#147)". A single entry can then be evicted independently at AppState+ToolUseCache.swift:62 (permissionQueue.remove(at: staleIndex), keyed on toolUseId). If that eviction lands between render and click, the session's new first entry becomes the target: the card said Bash: rm -rf …, the click allows the queued WebFetch. With Always allow, the rule written at AppState.swift:1455-1460 is built from the resolved request's toolName — a persistent allow-rule for a tool the user never saw.

Proportionality: requires parallel tool calls in one session plus a sub-second eviction race, so low frequency — but it is the same privilege-crossing class the MR exists to close, and the AC3 wording claims the guarantee unconditionally. The queue entry already carries toolUseId and NotchPanelView.swift:209 already holds the rendered request, so binding the action to toolUseId rather than sessionId is roughly ten lines across two files. Marked hypothetical because the window was reasoned, not reproduced; the mechanism is confirmed at every cited line.


Finding 6 — The AC2 panel-recovery side effect is asserted by no test

  • Severity: minor · Status: confirmed · Category: test-gap
  • Reported by: [claude:test-quality] | [claude:regression-edges]
  • Location: Tests/CodeIslandTests/AppStateAnswerRoutingTests.swift:42-71, :118-143

What was tested: whether the new tests exercise discardStalePanelAction's panel handling, i.e. the half of that function that exists specifically to satisfy AC2.

Expected: tests fail if the panel recovery regresses (AC7).

Actual / risk: testAnswerIsDroppedWhenTheCardsRequestIsNoLongerQueued and testDenyIsDroppedWhenTheCardsRequestIsNoLongerQueued assert only queue contents. The string appState.surface does not appear anywhere in the file. Reasoned mutation M5 — delete surface = .collapsed / showNextPending() from AppState.swift:1364-1366 — leaves the suite green, so the entire side effect could be dropped in a refactor unnoticed. This is the test-side counterpart of Findings 1 and 4.

Proportionality: two lines per test (set surface before the stale action, XCTAssertEqual(appState.surface, .collapsed) after). No new harness.


Finding 7 — The core routing tests detect a regression by deadlock, not by assertion

  • Severity: minor · Status: confirmed by reading (hang-vs-fail outcome not executed) · Category: test-gap
  • Reported by: [claude:test-quality]
  • Location: Tests/CodeIslandTests/AppStateAnswerRoutingTests.swift:33, :89, :113

What was tested: the failure mode of the three tests that verify the primary fix, under reasoned mutations M1 (permissionIndex reverted to head) and M2 (questionIndex reverted to head).

Expected: a routing regression produces a named failing test.

Actual / risk: each test awaits the target session's response before asserting on queue state (await secondResponse.value at :33 precedes the assertions at :36-37; same shape at :89/:91 and :113/:115). Under a head-of-queue regression the target continuation is never resumed, so the await blocks indefinitely. With no per-test timeout configured, the run hangs rather than reporting a failure — a CI regression surfaces as a hung job needing manual diagnosis instead of a named test.

Proportionality: one-line reorder — assert questionQueue.map { $0.event.sessionId } / permissionQueue.map { … } before awaiting — converts all three to clean failures at near-zero cost. Worth doing precisely because it is this cheap. Note this is the finding most weakened by mutation testing not having been executed; a real run would settle hang-vs-fail definitively.


Finding 8 — The AC5 head-of-queue test uses a single-element queue and cannot fail

  • Severity: minor · Status: confirmed · Category: test-gap
  • Reported by: [claude:test-quality]
  • Location: Tests/CodeIslandTests/AppStateAnswerRoutingTests.swift:167-181

What was tested: whether testOmittedSessionStillResolvesTheHead — the only test for AC5 — can distinguish "resolve the head" from "resolve anything".

Expected: the Buddy/companion head-of-queue guarantee is pinned by a test that fails when it breaks.

Actual / risk: the test enqueues a single makePermissionRequestEvent then calls approvePermission() with no argument. With one element, head and tail and "any" coincide. Reasoned mutation M7permissionIndex with expected == nil returning permissionQueue.indices.last — leaves this test green. The branch under test is AppState.swift:1350.

Proportionality: one extra event in the existing test: enqueue two sessions, assert the first is resolved and the second remains.


Finding 9 — Changed code paths with no coverage at any session id

  • Severity: minor · Status: confirmed · Category: test-gap
  • Reported by: [claude:test-quality] | [claude:regression-edges]
  • Location: Sources/CodeIsland/AppState.swift:1786-1830, :1600, :136

What was tested: which lines this MR changed are exercised by no test at all.

Actual / risk: three groups:

  • answerQuestion(_:expectedSessionId:) single-answer path (:1786-1830), including the questionQueue[index] wizard guard and the Codex remove(at: index) branch — the new tests only drive answerQuestionMulti and skipQuestion. The Codex app-server lines (:1801, :1866) are the riskiest: they were questionQueue[0] and are now questionQueue[index], and no test drives a Codex request from a non-head position.
  • dismissPermissionPrompt(expectedSessionId:) (:1600) — all four existing call sites (AppStatePermissionFlowTests.swift:73, :77, :112, :137) use the nil overload.
  • pendingQuestion(forSession:) (:136) — only the permission-side lookup is tested (AppStateAnswerRoutingTests.swift:161-162).
  • approvePermission(always: true, …) is likewise uncovered.

Proportionality: a Codex-at-index-1 case is worth adding; the other two are two-line additions to existing tests.


Finding 10 — Reverting the call-site wiring leaves the suite green

  • Severity: minor · Status: confirmed · Category: test-gap · Fix judged disproportionate to this MR
  • Reported by: [claude:test-quality]
  • Location: Sources/CodeIsland/NotchPanelView.swift:219-222, :238-240, :2284, :2291, :2298; Sources/CodeIsland/AppDelegate.swift:235-243

What was tested: whether any test covers the changed call sites — which is where the shipped bug actually lived.

Actual / risk: reasoned mutation M6 — revert NotchPanelView.swift and AppDelegate.swift to the no-argument calls, undoing the user-visible half of the wxtsky#308 fix while keeping the AppState plumbing — leaves all 7 new tests green. Every test invokes appState.* directly; no view or shortcut handler is instantiated anywhere, and no test target exercises NotchPanelView.

Proportionality — explicitly flagged as not worth fixing here. Closing this needs a SwiftUI view-testing harness the project does not have, which is more new code than the MR itself contains and would arrive unreviewed. The reviewing lens's own judgement: follow-up ticket or explicit acceptance, not another round. The AppState coverage is the part that can be tested cheaply, and it is tested.


Summary

Severity Contract Regression Test-quality Total
critical 0 0 0 0
major 1 (hypothetical) 1 0 2
minor 0 3 5 8
Blocking total 10
observations 2

Confirmed blocking: 1 major, 8 minor. One further major (Finding 5) is hypothetical — mechanism confirmed at every cited line, timing window not reproduced.

The routing fix itself is correct and well-constructed. The index helpers are the right shape, the nil default is a genuine no-op for the surfaces AC5 protects, the Codex/subagent index substitution is faithful, and the new tests assert on real resumed continuation payloads rather than queue arithmetic. Every blocking finding is either a panel-state consequence of the new discard path (Findings 1–4), the within-session residue of the same defect class (Finding 5), or a gap in how tightly the change is pinned (Findings 6–10).

Cheapest items with the clearest payoff: Finding 7 (one-line reorder, turns CI hangs into named failures), Findings 6 and 8 (two lines each), Finding 3 (one expression).

What the panel verified clean

Recorded so a later round does not re-litigate it:

  • Caller sweep. No call site of any of the six changed functions was missed. handleBuddyControlCommand (AppState.swift:1467) and answerCompanionQuestion (:1490) still resolve head-of-queue with no expectedSessionId, matching AC5's carve-out. Codex app-server reply paths are reached only through answerQuestion / answerQuestionMulti, whose in-function Codex branches now index by index rather than 0 consistently (:1800, :1811, :1865, :1884).
  • Session-list inline approvals (NotchPanelView.swift:2284/2291/2298) pass their own row's sessionId and are gated by isActiveApproval (:2159, approvalQueueIndex == 0), so the change is a strict safety net there, not a behaviour change.
  • Multiple queued requests for the same session: render (first {}) and resolve (firstIndex {}) use the same predicate and both pick the earliest, so FIFO within a session is preserved. (Finding 5 concerns eviction between the two calls, not predicate disagreement.)
  • Concurrency. AppState is @MainActor; queue mutation and surface assignment are synchronous on the main actor in every touched path. The change introduces no new suspension point between lookup and removal, so no TOCTOU window exists between permissionIndex/questionIndex and remove(at:).
  • Security posture of the diff. No injection surface: every response body is a compile-time string literal (AppState.swift:1580) or built via JSONSerialization.data(withJSONObject:) from a dictionary (:1821-1830, :1832-1838), so answer text is never interpolated into JSON. No credentials, tokens, or file paths are read or written. The one trust decision — that event.sessionId nil-coalesced to "default" is a sound identity — is pre-existing and applied consistently (:133, :137, :1351, :1357; matching ESP32StatePublisher.swift:418-421, NotchPanelView.swift:2157).
  • Vacuous-assertion audit of the new tests. One inert line (XCTAssertFalse(firstResponse.isCancelled) at :38 is trivially true), no empty-array assertions, no assert-on-a-value-the-test-just-set. Queue-state assertions all read state produced by production code.

Observations (non-blocking)

  • Session-id normalisation is inconsistent between the new matchers and the drain paths. permissionIndex / questionIndex (AppState.swift:1349, :1355) and pendingPermission(forSession:) / pendingQuestion(forSession:) (:130, :134) all normalise with ($0.event.sessionId ?? "default"), whereas drainPermissions (:1997) and drainQuestions (:2026) compare item.event.sessionId == sessionId with no fallback. A request whose event carries no sessionId is addressable as "default" by the new card path but is never drained by handlePeerDisconnect("default"). Pre-existing in the drain functions, untouched by this MR, and every production surface observed supplies a session id. Fixing it here would widen the diff for no observed symptom.

  • iPhone/Watch Buddy and companion answers still resolve the queue head — the same privilege-crossing class this MR closes for the panel. handleBuddyControlCommand (AppState.swift:1467-1488) guards only on queue non-emptiness; answerCompanionQuestion (:1490-1519) reads and writes questionQueue[0] directly (:1496, :1507). If the remote surface displays session A while the head is session B, a remote approve grants B. AC5 explicitly and correctly ring-fences this: BuddyControlCommand carries no session id, so closing it means a protocol change plus companion-app changes on both ends. Not worth doing inside this MR — it warrants its own ticket. Recorded so the exclusion is a decision on record rather than an oversight.

Test-hygiene note (not a finding)

Most tests in the new file leave continuations unresumed at teardown — firstResponse.cancel() (:39) does not resume a CheckedContinuation, and the _ = Task { … } handles at :51, :78, :101, :127, :152, :156 are never drained. This produces SWIFT TASK CONTINUATION MISUSE … leaked its continuation noise on stderr rather than failures. Draining with handlePeerDisconnect(sessionId:) at the end of each test would silence it. Mentioned so it is not mistaken for a real failure in a later run.


SAST review not applicable

Target default has security_stage: false in base-branches.json. No CI
security stage is wired for this target, so no SAST/SCA delta is computed.

No security claim is made about a pipeline; none ran for this target.

Schema

No schema change detected. Swift/SwiftUI package with no DDL, no persisted-store definition, and no code-only column or table dependency in the diff.


⚠ Posted with dev credentials — QA agent token unavailable.

QA performed by Claude Code (claude-opus-5), manager + 3-lens panel


Round 1 disposition (main loop)

Fixed in eb5ef930923ded019336572e5f4b4ff50285a321: findings 1, 2, 3, 4, 6, 7, 8, 9.

Not fixed, deliberately:

  • Finding 5 (within-session render-vs-resolve keying) — hypothetical; mechanism confirmed by reading but the timing window was not reproduced.
  • Finding 10 (reverting the view-layer call-site wiring leaves the suite green) — pinning it needs a SwiftUI view-test harness this project does not have. Accepted for this PR.

Verification: swift test from the repo root — 699 tests, 2 skipped, 0 failures. preflight reported verify.state=none-found; that detector gap is a harness issue, not a project one. The stale-card guard was confirmed to fail without the fix before being committed.

QA-Fix-Commit: eb5ef93

Round 2 confirmed one major and five minor findings; three of the six sat on
code round 1 wrote.

- major (introduced by eb5ef93): collapseStaleCardSurface tested card liveness
  by queue membership, but dismissPermissionPrompt hides a request without
  dequeuing it. Under Smart Suppress a dismissed approval card therefore stayed
  on screen, re-rendering the request the user had just dismissed. The guard now
  applies the same predicate nextVisiblePermissionIndex() uses: dismissed counts
  as not visible. No request was ever misrouted by this.
- the routing tests failed by name but then deadlocked on the await that
  follows; each pre-await assertion now stops the test instead.
- added coverage for the kind-matched surface accessors, both dismiss paths
  (routing and stale-discard), and a Codex app-server request answered while
  queued behind another session — those branches were only ever exercised at
  index 0.
- corrected a doc comment describing a collapse that moved to another function.

Both new guards were verified to fail without their fix before committing.
703 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
@halindrome

Copy link
Copy Markdown
Owner Author

QA Round 2

3 of 6 blocking findings are on code an earlier round of this QA cycle introduced.
This cycle may be fixing its own work rather than the MR's.

MR: #1 fix/answer-routingmain (HEAD eb5ef93)
Contract source: synthesized — no ticket linked; upstream defect report is wxtsky#308.
Panel: 3 lenses (contract-security, regression-edges, test-quality), all returned.
Verification: swift test on this HEAD — 699 tests, 2 skipped, 0 failures. The round-1 stale-card guard was separately confirmed to fail without its fix.
Round-1 disposition: eb5ef93 addresses findings 1, 2, 3, 4, 6, 7, 8, 9. Findings 5 and 10 were explicitly accepted, not fixed; all three lenses were told so and none argues against either acceptance.

Panel navigation was degraded, identically to round 1. All three lenses loaded the
code-graph tools successfully, but the codebase-memory index still has no project covering
/Users/ahby/Sources/CodeIsland (project not found or not indexed; no ancestor in the
117-entry project list). Every lens fell back to ctx_* capture with definition sites opened
and cited directly, so symbol claims are grounded — but the who-calls-X sweep was again text
search rather than the call graph. Indexing this repo would strengthen a later round.
(The contract-security lens self-labelled read-grep-fallback; it made successful ctx_*
calls, so the honest regime is ctx. Recorded here so the label is not read as a tool failure.)

Mutation testing was again not executed. The suite was run once by the orchestrator on
this HEAD and not re-run; the test-quality lens's per-test mutation table is reasoned from
reading, not run, under a read-only-tree constraint. Its findings carry the same slightly
reduced evidentiary weight as round 1's.

Contract Verification

Criterion Status Evidence
AC1 — approve/deny/dismiss/answer/skip resolves that card's session, never the queue head ✅ satisfied permissionIndex(expecting:) / questionIndex(expecting:) (AppState.swift:1359, :1365), unchanged by eb5ef93; every mutator removes at that index (:1387, :1587, :1815, :1880, :1979). New testSingleAnswerQuestionTargetsTheCardsSession (AppStateAnswerRoutingTests.swift:316) closes round-1's single-answer gap.
AC2 — stale action discarded; no other session resolved; no dead card left on screen ⚠ partial Round-1 F-01/F-04 are genuinely fixed, and fixed at the root: collapseStaleCardSurface() (AppState.swift:2060-2069) runs unconditionally at the head of showNextPending() (:2074), so every drain path recovers the panel, not just the discard path. One state escapes it — a dismissed but still-queued request — Finding 1.
AC3 — cards render the request belonging to the surface's session ⚠ partial Unchanged from round 1. pendingPermission(forSession:) / pendingQuestion(forSession:) (AppState.swift:132, :136) at NotchPanelView.swift:209, :228. The within-session render→click window is round-1 finding 5, accepted. queuePosition is now derived (:142, :146) — round-1 F-03 fixed.
AC4 — session-addressed call sites pass the session through ✅ satisfied Shortcuts now use kind-specific IslandSurface.approvalSessionId / questionSessionId (IslandSurface.swift:27, :32) at AppDelegate.swift:237-243, so a permission hotkey no longer discards a live question card — round-1 F-02 fixed. Panel cards and session-list inline approvals unchanged.
AC5 — session-less surfaces keep head-of-queue behaviour ✅ satisfied eb5ef93 touched no line in handleBuddyControlCommand (AppState.swift:1476) or answerCompanionQuestion (:1499); both still call the no-argument overloads. testOmittedSessionStillResolvesTheHead (:291) now uses a two-element queue and asserts the survivor — round-1 F-08 fixed.
AC6 — no regression in existing question/permission/Codex/subagent flows ⚠ partial 699 tests, 0 failures. The 20 showNextPending() call sites were swept for the newly-unconditional collapse; multi-question wizards keep their queue entry so no mid-wizard collapse, and completion cards fall to the default branch (:2066). One user-visible defect ships: Finding 1.
AC7 — new behaviour covered by tests that fail if routing regresses ⚠ partial 13 tests, none vacuous; round-1 F-06/F-07/F-08/F-09 all closed (surface asserted at :79/:122/:222; queue asserted before the await at :37/:166/:190). Residual gaps: Findings 2, 3, 4, 5.

4 of 7 criteria satisfied, 3 partial. The cross-session routing fix remains sound, and round 1's eight actioned findings are genuinely fixed — the AC2 fix in particular is better than what was asked for.


Finding 1 — A dismissed card is still on screen afterwards ↩ on code QA round 1 introduced

  • Severity: major · Status: confirmed · Category: logic-error
  • Reported by: [claude:regression-edges]
  • Location: Sources/CodeIsland/AppState.swift:2060-2069, :1609-1631
  • Introduced by: eb5ef93 (QA round 1 fix commit) — as an incomplete fix, not a new mechanism

What was tested: what the panel shows after the user clicks dismiss on an approval card while another session is also queued and Smart Suppress is active.

Expected: dismiss removes the card from view. Per AC2, "the panel must not be left displaying a dead card."

Actual / risk: collapseStaleCardSurface() decides a card is alive by queue membership, while the rest of the panel decides what is showable by visibility. The two disagree on exactly one state. dismissPermissionPrompt does not remove the request — it inserts into dismissedPermissionSessionIds (AppState.swift:1619) — so after a dismiss, pendingPermission(forSession: sid) (:132, an unfiltered permissionQueue.first) is still non-nil and the guard at :2062 declines to collapse. nextVisiblePermissionIndex() (:226-231) does filter dismissed sessions, so control reaches showNextPending(), which promotes the next session but only assigns surface behind shouldAutoOpenPendingSurface (:2081). Under the default smartSuppress with the other session's terminal frontmost that gate fails, nothing reassigns surface, and NotchPanelView.swift:209 re-renders the request the user just dismissed.

Sequence: queue [A, B], card on A, Smart Suppress on, B's terminal frontmost → click dismiss on A → A's card is still there, unchanged. Dismiss appears not to work; clicking approve on that card then grants A.

No cross-session misrouting occurs — the wrong card is shown, never the wrong request resolved. Baseline comparison: on origin/main the card rendered the queue head, and showNextPending had just rotated the next visible request to index 0, so the panel swapped to the live request. The session-scoped render this MR introduces is what turns that into a re-display of the dismissed one.

Proportionality: requires ≥2 queued sessions and smartSuppress (the default), so the window is narrow — but this is the one path collapseStaleCardSurface was written to cover and does not, and it is a behaviour regression against main, not merely an unmet aspiration. The fix is small and local: have the collapse guard consult the same visibility predicate the rest of the panel uses.

Two smaller items ride the same lines and are recorded here rather than filed separately: permissionQueuePosition(forSession:) (:142) counts dismissed entries, so "N of M" can name an unreachable position (queueTotal had this pre-MR, so it is no worse); and collapseStaleCardSurface assigns surface = .collapsed bare (:2063, :2065) while every sibling collapse wraps in withAnimation(NotchAnimation.close) (e.g. :1625), so the panel snaps rather than closes.


Finding 2 — The two accessors that fix round-1 finding 2 have zero test references ↩ on code QA round 1 introduced

  • Severity: minor · Status: confirmed · Category: test-gap
  • Reported by: [claude:test-quality] | [claude:contract-security]
  • Location: Sources/CodeIsland/IslandSurface.swift:27-35
  • Introduced by: eb5ef93

What was tested: whether approvalSessionId / questionSessionId — the kind-matching logic that decides whether a keyboard approve/deny addresses the on-screen card or falls back to the queue head — are pinned anywhere.

Actual / risk: grep -rn 'approvalSessionId\|questionSessionId' Tests/ returns no matches; their only call sites are AppDelegate.swift:237-243. Reverting both bodies to return sessionId restores the round-1 finding-2 bug and leaves all 699 tests green.

Proportionality — worth acting on. This is explicitly distinct from the accepted round-1 finding 10: these are pure computed properties on an enum, so pinning them needs no SwiftUI harness. Two lines: XCTAssertNil(IslandSurface.questionCard(sessionId: "s").approvalSessionId) and its mirror. The AppDelegate wiring stays accepted-unpinned; the kind-matching logic itself need not be.


Finding 3 — The dismissPermissionPrompt stale-discard guard ships unpinned

  • Severity: minor · Status: confirmed · Category: test-gap
  • Reported by: [claude:test-quality]
  • Location: Sources/CodeIsland/AppState.swift:1610-1615

What was tested: which of the six stale-discard guards are exercised on their stale path.

Actual / risk: guards exist at :1381, :1581, :1610, :1801, :1872, :1973. Stale-path tests exist only for denyPermission (AppStateAnswerRoutingTests.swift:215) and answerQuestionMulti (:72). The suite's one session-scoped dismissPermissionPrompt call (:240) targets a session that is queued — the happy path; every other call in Tests/ uses the no-argument overload and cannot reach the new branch. Reverting :1610-1614 to the bare return it had before eb5ef93 leaves the suite green, so the round-1 finding-4 fix ships unverified.

Proportionality: the shared discardStalePanelAction helper is covered twice and the four uncovered guards are byte-identical three-line boilerplate, so four near-identical tests would be poor value. Pin dismiss only — ~15 lines cloned from testDenyIsDroppedWhenTheCardsRequestIsNoLongerQueued. The reviewing lens explicitly does not recommend adding the other three.


Finding 4 — The Codex app-server branches are still only exercised at index 0

  • Severity: minor · Status: confirmed · Category: test-gap
  • Reported by: [claude:contract-security] | [claude:regression-edges]
  • Location: Sources/CodeIsland/AppState.swift:1795-1806, :1866-1897

What was tested: the residue of round-1 finding 9, which named the Codex branches as the riskiest uncovered change.

Actual / risk: eb5ef93 closed two of the three named gaps (single-answer path, dismiss routing) but not this one. Both Codex arms changed from questionQueue[0] / removeFirst() to questionQueue[index] / remove(at: index), and every Codex test (AppStateCodexRequestUserInputTests.swift:42, :60, :71, :83) drives a single-element queue with no expectedSessionId, so index is only ever 0 — the value the old code hardcoded. approvePermission(always: true, …) (:1391-1409) is likewise untested at a non-head index.

No defect is proven — the substitution reads faithfully at every line. This is a coverage gap on the MR's least-tested changed lines, and an index mix-up here would send one Codex thread's answer to another over JSON-RPC while shipping green.

Proportionality: one test that enqueues a non-Codex question first, then a Codex one, and answers the Codex session. Both lenses call it the highest-value remaining test addition. approvePermission(always:) is lower value — its extra work is rule persistence, which the routing index does not affect.


Finding 5 — Routing-regression tests now fail by name but still deadlock afterwards ↩ on code QA round 1 introduced

  • Severity: minor · Status: confirmed · Category: test-gap
  • Reported by: [claude:test-quality]
  • Location: Tests/CodeIslandTests/AppStateAnswerRoutingTests.swift:37, :166, :191, :309, :333
  • Introduced by: eb5ef93 (partial fix of round-1 finding 7)

What was tested: whether round-1 finding 7 — "a routing regression should fail by name, not hang CI" — is fully closed.

Actual / risk: the pre-await assertions were correctly added and the named failure now is emitted at the moment it occurs, a genuine improvement. But XCTAssertEqual records and continues, so each test then enters an await on a Task suspended inside withCheckedContinuation that a routing regression never resumes. firstResponse.cancel() (:46) is inert — withCheckedContinuation is not cancellation-aware. swift test therefore still hangs until the CI timeout kills the process rather than completing the run.

Proportionality: five one-line edits — a guard <same condition> else { return } after each pre-await assertion. No new infrastructure; the fix is smaller than the problem.


Finding 6 — discardStalePanelAction's doc comment describes a collapse that moved

  • Severity: minor · Status: confirmed · Category: documentation
  • Reported by: [claude:contract-security]
  • Location: Sources/CodeIsland/AppState.swift:1370-1378

Actual / risk: the comment reads "Collapse first so a dead card can't stay on screen, then let showNextPending() re-open whatever is genuinely waiting", but eb5ef93 deleted the surface = .collapsed line from that body; the collapse now happens inside collapseStaleCardSurface() (:2056-2069, which carries its own accurate comment). The body is now log.notice(…), showNextPending(), refreshDerivedState().

No runtime effect — the net behaviour is correct and strictly broader than before. The cost falls on the next reader of the function this MR just changed, who will look for a collapse that is not there. One-line comment edit; not worth holding the MR for if it is the last item standing.


Summary

Severity Contract Regression Test-quality Total
critical 0 0 0 0
major 0 1 0 1
minor 2 1 2 5
Blocking total 6
observations 2

Confirmed blocking: 1 major, 5 minor. Nothing critical; no security defect in the routing decisions themselves.

Round 1's fixes are correct. All eight actioned findings are genuinely closed, and two are closed better than asked: the AC2 fix was moved into showNextPending() so every drain path recovers the panel rather than only the discard path, and the shortcut fix uses kind-specific accessors that restore main's exact behaviour where no matching card is up. The one substantive new finding is the sliver that root-cause fix missed — a dismissed request stays queued, so the membership test says the card is alive.

Cheapest items with the clearest payoff: Finding 2 (two lines), Finding 5 (five one-line edits), Finding 6 (one comment). Finding 1 is the only one that changes behaviour.

What the panel verified clean

  • Concurrency on the new code. collapseStaleCardSurface is a synchronous @MainActor method with no suspension point between the lookup and the surface assignment. The surface didSet (AppState.swift:151-162) acts only on isExpanded == true and does not re-enter showNextPending, so the new unconditional call introduces no recursion or TOCTOU window.
  • All 20 showNextPending() call sites were swept for the newly-unconditional collapse. It fires only when the surface is an approval/question card with no queued request for that session — a state that was already the bug. Multi-question wizards keep their queue entry (askUserQuestionState lives on the queued item, removed only at :1898), so no mid-wizard collapse; completion cards hit the default branch.
  • Removing the unconditional surface = .collapsed from discardStalePanelAction is correct, not a regression. It is strictly narrower: a stale session-list inline approval now leaves .sessionList intact, and the if surface != .sessionList guard at :2081 stops showNextPending stealing it. Two lenses independently re-derived this.
  • No vacuous assertions in the 13 new tests. Each was mapped to the specific production line a break in which it would catch, and none answered "none". Two verified negatives recorded so a later round does not re-derive them: the XCTAssertNotEqual(surface, …) assertions at :79 and :222 do not pin collapseStaleCardSurface (with the guard removed, shouldAutoOpenQuestionSurface returns true for AskUserQuestion and the surface becomes the other card — still not-equal, still green); only testStaleCardCollapsesWhenAutoOpenIsSuppressed goes red.
  • Security posture unchanged from round 1. No injection surface, no credential or token handling; response bodies remain string literals or JSONSerialization output.

Observations (non-blocking)

  • A hotkey with no matching card resolves the queue head unseen. With the island collapsed or showing a completion card or the session list, approvalSessionId / questionSessionId return nil, permissionIndex(expecting: nil) returns index 0, and the global shortcut (AppDelegate.swift:237-243, monitors at :201-211 have no surface guard) grants or denies a request the user cannot see. This is byte-identical to origin/maineb5ef93 deliberately restored it while fixing round-1 finding 2, and it is plausibly the intended "hotkeys work when the island is collapsed" behaviour. Both lenses that raised it agree it is a product decision for its own ticket, not a defect this MR introduced. Recorded so the trade is on record rather than accidental.

  • The one test pinning the round-1 major fix depends on the machine's live GUI state. AppStateAnswerRoutingTests.swift:98 does try XCTUnwrap(NSWorkspace.shared.frontmostApplication?.bundleIdentifier), which hard-fails on a headless runner, and shouldAutoOpenPendingSurface re-reads frontmost state at AppState.swift:2081 after the test's precondition assertion at :100-103, so a frontmost change in between fails the test spuriously. Recommended for acceptance, not fixing: the pattern is pre-existing house style (AppStateQuestionFlowTests.swift:96, :130), so CI must already tolerate it, and the real fix means threading the existing isTerminalFrontmost seam (AppState.swift:953) through showNextPending — production surgery in service of a test. The test asserts its own precondition, so it can only fail loudly, never pass vacuously.

Round-1 accepted findings — no argument against either acceptance

All three lenses were told findings 5 (within-session render-vs-resolve keying) and 10 (view-layer call-site wiring) were accepted rather than fixed, and were invited to argue. None did. Finding 10's reasoning was independently reinforced: the test-quality lens separated the enum accessor logic (cheap to pin — Finding 2 above) from the AppDelegate wiring (needs the harness), and recommends acceptance only for the latter.


SAST review not applicable

Target default has security_stage: false in base-branches.json. No CI
security stage is wired for this target, so no SAST/SCA delta is computed.

No security claim is made about a pipeline; none ran for this target.

Schema

No schema change detected. Swift/SwiftUI package with no DDL, no persisted-store definition, and no code-only column or table dependency in the diff. All three lenses returned schema_change_detected: false.


⚠ Posted with dev credentials — QA agent token unavailable.

QA performed by Claude Code (claude-opus-5), manager + 3-lens panel


Round 2 disposition (main loop)

All six findings fixed in c65b9d9e9d06c058886887ad84abd27f15da69b2 — including finding 4 (Codex-at-non-zero-index), which the panel scoped as cheap and was.

The major was again introduced by the previous round's fix: collapseStaleCardSurface (round 1) used queue membership as its liveness test, while dismissPermissionPrompt hides without dequeuing. Real regression against main, no misrouting. The guard now uses the same dismissed-filtered predicate as nextVisiblePermissionIndex(), and the test was verified red without it.

qa_introduced_blocking: 3 — half of this round's findings were on code round 1 wrote. Recorded here because that is the diminishing-returns signal the cycle is supposed to surface, not bury.

Verification: swift test — 703 tests, 2 skipped, 0 failures.

QA-Fix-Commit: c65b9d9

Round 3 found no blocking defects. Both findings were assertion-strength gaps
in tests round 2 added — each passed whether or not the behaviour it named
still held.

- the stale-dismiss test observed only the queue, which is unchanged by a
  head-based dismiss (dismiss hides without dequeuing). It now drives
  showNextPending() and asserts the other session's card is still offerable,
  which is where a wrongly-dismissed session actually shows up.
- the Codex-behind-another-session test asserted the dequeue, which a
  head-anchored Codex check would also satisfy while replying down the hook
  path and leaving the server waiting forever. The request now carries a
  capturing reply closure, so the test asserts the JSON-RPC path was the one
  taken. The session status it previously asserted could not discriminate: both
  paths land on .processing.

Both were verified by mutating the production path each one names and watching
it fail. 703 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K4myY8wXiJtDb1xAjsxP5h
@halindrome

Copy link
Copy Markdown
Owner Author

QA Round 3

Panel: 3 lenses (contract-security, regression-edges, test-quality). No blocking findings.
Both prior rounds' major findings were regressions introduced by the previous round's own
fix, so this round targeted c65b9d9 itself — collapseStaleCardSurface's dismissed-session
predicate and the tests that commit added. That pattern did not repeat: c65b9d9 introduced
no regression of its own.

Contract Verification

Contract synthesized from the PR title/description; no ticket linked (upstream defect wxtsky#308).

Criterion Status Evidence
Approve/deny/dismiss/answer/skip resolves that card's session, never the queue head satisfied permissionIndex(expecting:) / questionIndex(expecting:) (AppState.swift:1359, :1365); all six resolvers index through them (:1379, :1579, :1608, :1800, :1869, :1971). Zero permissionQueue/questionQueue removeFirst remain anywhere in Sources/.
Stale action discarded; no other session resolved; no dead card left satisfied discardStalePanelAction (:1373) fires only when expectedSessionId != nil, so there is no fallback to index 0; collapseStaleCardSurface() (:2064) runs first in showNextPending() (:2080).
Cards render the surface's session's request satisfied NotchPanelView.swift:209/:228 use pendingPermission(forSession:)/pendingQuestion(forSession:); the queue label uses permissionQueuePosition(forSession:) instead of a hardcoded 1. Render and resolve share one predicate.
Session-addressing call sites pass the session through satisfied Panel cards NotchPanelView.swift:219-222, :238-240; session-list inline :2284, :2291, :2298; shortcuts AppDelegate.swift:237-243 via kind-matched IslandSurface.approvalSessionId/questionSessionId.
Session-less surfaces keep head-of-queue behaviour satisfied handleBuddyControlCommand (:1475) and answerCompanionQuestion (:1498) still pass no session; permissionIndex(expecting: nil) returns queue.isEmpty ? nil : 0 — semantically identical to the old removeFirst. ESP32 publisher still reads the head-based computed properties, so its display and resolution stay consistent.
No regression in question/permission/Codex/subagent flows satisfied Resolvers keep their response bodies, resolveMergedSubagentAfterUI calls, and showNextPending()/refreshDerivedState() ordering; only index selection changed. Suite on this HEAD: 703 tests, 2 skipped, 0 failures.
Covered by tests that fail if routing regresses satisfied, with two gaps 15 routing tests assert the surviving queue by session id before any await; round 2's assertQueue helper turns a routing regression into a named failure rather than a continuation deadlock. Two of the assertions c65b9d9 added do not discriminate — Findings 1 and 2 below.

Finding 1 — Stale-dismiss test cannot fail on the routing bug its message names ↩ on code QA round 2 introduced

  • Severity: minor · Relevance: regression · Category: test-gap · Status: confirmed
  • Where: Tests/CodeIslandTests/AppStateAnswerRoutingTests.swift:395-421 (added by c65b9d9)
  • What was tested: dismissPermissionPrompt(expectedSessionId: "s-stale") after s-stale was drained by handlePeerDisconnect must not hide s-other's prompt.
  • Expected: the guard goes red if dismiss routing regresses to head-of-queue.
  • Actual risk: both assertions hold under the regressed behaviour. Dismiss hides without dequeuing, so permissionQueue.map { $0.event.sessionId } == ["s-other"] is true whether s-other was wrongly dismissed or not; and a head-based dismiss also leaves surface at .collapsed, satisfying XCTAssertNotEqual(surface, .approvalCard("s-stale")). The one state that differs — s-other landing in dismissedPermissionSessionIds — is never observed. The assertion message names exactly the condition the test cannot detect.
  • Proportionate fix: one added assertion, not new infrastructure. dismissedPermissionSessionIds is private (AppState.swift:225), but its effect is observable through existing API: call showNextPending() and assert surface == .approvalCard(sessionId: "s-other") — a wrongly-dismissed s-other is filtered out by nextVisiblePermissionIndex() (:226-231) and the panel stays collapsed instead.

Finding 2 — Codex behind-another-session test asserts the dequeue but not the JSON-RPC reply path ↩ on code QA round 2 introduced

  • Severity: minor · Relevance: regression · Category: test-gap · Status: confirmed
  • Where: Tests/CodeIslandTests/AppStateCodexRequestUserInputTests.swift:77-114 (added by c65b9d9)
  • What was tested: per its own doc comment, a Codex app-server request queued behind another session's question "must still reply over the JSON-RPC path rather than the hook path".
  • Expected: an assertion distinguishing the Codex branch from the hook branch.
  • Actual risk: the only post-action assertion is questionQueue.map { $0.event.sessionId } == ["s-hook"]. A regression to a head-anchored Codex check (questionQueue[0].isCodexAppServer) would send the Codex request down the hook path while the indexed remove(at:) still dequeues it — the queue assertion stays green and the Codex server waits forever. The test verifies the half of its docstring that was never at risk.
  • Proportionate fix: one assertion on an observable of the JSON-RPC reply, in the style the sibling testServerRequestResolvedDropsQueuedQuestion already uses. No new harness.

Summary

Severity Contract Regression
critical 0 0
major 0 0
minor 0 2
Relevance Count
contract 0
regression 2
observation 1
Total 3

Both confirmed findings are minor test-assertion gaps, and both sit on tests c65b9d9 — this cycle's own round-2 fix — added. Neither is blocking.

Observations

O-1 — Dismissed permission request stays queued, suppressing auto-open and sound for every later request (severity major, non-blocking: pre-existing, in code this PR does not modify)

handlePermissionRequest gates auto-open and the approval sound on permissionQueue.count == 1
(Sources/CodeIsland/AppState.swift:1336-1345), but dismissPermissionPrompt (:1608-1630) hides
without dequeuing. So after any dismiss, the dismissed entry stays queued and every subsequent
permission request from any session arrives with count >= 2 — no notch card, no sound; the CLI
blocks with only the session list as a cue. git blame attributes the gate to bb0eb72
(2026-04-06); this PR does not touch it. The fix is small (count the visible queue via the existing
nextVisiblePermissionIndex() rather than raw count) but changes auto-open semantics on an
untested path this PR never touched — its own ticket, not this PR.

Security

No blocking security findings. The change adds no auth, network, or credential surface; session ids
are compared, never interpolated into a shell command or rule body. Session ids reach
log.notice(… privacy: .public) at :1374, consistent with pre-existing logging at :2011 — these
are CLI-generated identifiers, not secrets. The routing fix is a net security improvement: it removes
a path where an "approve" could resolve a different CLI's tool-permission request.


SAST review not applicable

Target default has security_stage: false in base-branches.json. No CI
security stage is wired for this target, so no SAST/SCA delta is computed.


Round caveats

  • Schema gate did not run — no schema.files configured. That is an absent result, not a clean
    one. This is a Swift/SwiftUI package with no schema file and no database layer, so nothing is
    believed to be at risk, but the gate was not an input to this round.
  • Code-graph index unavailable — the codebase-memory project for this repo is not indexed
    (trace_path/search_graph returned project-not-found; no list_projects entry is an ancestor of
    the repo root). All three lenses ran ctx-only. Caller enumeration was done by repo-scoped grep
    over Sources/ plus definition-site reads — complete for a single-module Swift package with no
    cross-module dispatch on these methods, but weaker than a graph traversal.
  • test-quality axis was run inline by the QA manager, not by a separate lens subagent: the
    subagent stalled twice without returning and was abandoned for wall-clock. The axis is covered —
    Findings 1 and 2 are its output — but by the manager rather than a dedicated lens.
  • No file was modified by the review; the working tree is unchanged at c65b9d9.
  • Round-1 findings 5 and 10 remain accepted; no new evidence was found against either acceptance.

QA performed by Claude Code (claude-opus-5), manager + 3-lens panel

⚠ Not posted to the forge — the configured project (wxtsky/CodeIsland) is the upstream repo; this PR lives on the fork halindrome/CodeIsland#1.


Round 3 disposition (main loop)

Both findings fixed in f40cee5a8b82e0e269617bfd0c27953940c9ed5f. Neither was a shipped defect — both were assertion-strength gaps in tests round 2 added.

One correction to finding 2 as scoped: the suggested discriminator (session status) does not distinguish the two paths — resolveMergedSubagentAfterUI lands the hook path on .processing as well, and a test asserting it passed under the regression when I checked. The test now carries a capturing reply closure and asserts the JSON-RPC path was taken. Both fixes were verified by mutating the production path each names and watching the test fail.

Cycle ending here. Round 3 raised diminishing_returns with 0 blocking findings, 2 of 2 findings self-referential, and all seven contract criteria satisfied.

Caveats this round carries, recorded rather than smoothed over:

  • failed_lenses: ["test-quality"] — the dedicated lens subagent stalled twice; the manager covered that axis inline. Covered, but not by an independent lens.
  • codebase-memory-mcp has no index for this repo, contrary to what preflight told the panel. All three rounds enumerated callers by grep plus definition-site reads, not graph traversal.
  • The schema gate never ran (state: skipped:not-configured). Benign for a Swift package with no schema file, but it is an absent check, not a pass.
  • Non-blocking pre-existing observation, untouched by this PR and worth its own issue: a dismissed permission request stays queued, so after any dismiss, later permission requests from any session can go card-less and silent (AppState.swift:1336-1345, blamed to bb0eb72).

Verification: swift test — 703 tests, 2 skipped, 0 failures.

QA-Fix-Commit: f40cee5

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.

2 participants