|
| 1 | +<!-- Content type: Reference --> |
| 2 | + |
| 3 | +# Deferred work |
| 4 | + |
| 5 | +Known defects and design gaps that are recorded rather than fixed, with enough |
| 6 | +evidence that a fresh session can act on them without redoing the |
| 7 | +investigation. Each entry states what is wrong, how it was observed, why it was |
| 8 | +deferred, and what "done" looks like. |
| 9 | + |
| 10 | +Line numbers are as-of the baseline commit named in each entry and must be |
| 11 | +re-confirmed before editing. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Attended session status cannot express "connected but not attached" |
| 16 | + |
| 17 | +**Baseline:** branch `dev`, commit `d6fab0de4aea6be587e158b59faea21c6c4bed06`. |
| 18 | +**Found:** 2026-07-29, during the Phase 3a attended-compatibility scenario. |
| 19 | +**Severity:** consumers can be told a session is ready when it cannot execute |
| 20 | +anything. No silent success, but a status that is affirmatively wrong. |
| 21 | + |
| 22 | +### What was observed |
| 23 | + |
| 24 | +With an attended session attached and working, the operator pressed **Detach |
| 25 | +tab** in the side panel. The session then reported: |
| 26 | + |
| 27 | +```json |
| 28 | +{"mode":"attended","status":"connected","browser":{...},"tabs":[{"tabId":2134210639,...}]} |
| 29 | +``` |
| 30 | + |
| 31 | +while every command answered: |
| 32 | + |
| 33 | +```json |
| 34 | +{"type":"action_result","ok":false,"error":"no active CDP session"} |
| 35 | +``` |
| 36 | + |
| 37 | +So `status` said `connected`, `browser` was populated, and `tabs` still listed a |
| 38 | +tab that was no longer under control. |
| 39 | + |
| 40 | +### Why it happens — two independent causes |
| 41 | + |
| 42 | +**1. The attended enum has no state for it.** `packages/protocol/src/index.ts:382`: |
| 43 | + |
| 44 | +```ts |
| 45 | +export const AttendedSessionStatusSchema = strictObject({ |
| 46 | + mode: z.literal("attended").optional(), |
| 47 | + status: z.enum(["pending", "connected", "detached"]), |
| 48 | +``` |
| 49 | +
|
| 50 | +Three states, and the real machine has at least four: no socket; socket up with |
| 51 | +no tab attached; socket up with a tab attached; socket gone. `connected` |
| 52 | +currently covers the middle two, which are the two that differ in whether a |
| 53 | +command can succeed. |
| 54 | +
|
| 55 | +The unattended lifecycle in the same file (`:367`) models its machine properly, |
| 56 | +which is the contrast worth noting — the attended path was simply never given |
| 57 | +the same treatment: |
| 58 | +
|
| 59 | +```ts |
| 60 | +export const UnattendedSessionLifecycleSchema = z.enum([ |
| 61 | + "allocating", "provisioning", "connected", "recovering", |
| 62 | + "closing", "closed", "expired", "lost", |
| 63 | +]); |
| 64 | +``` |
| 65 | +
|
| 66 | +**2. The extension never tells the backend.** `apps/extension/src/entrypoints/background.ts:648`: |
| 67 | +
|
| 68 | +```ts |
| 69 | +async function detach(): Promise<void> { |
| 70 | + const active = session; |
| 71 | + try { |
| 72 | + if (active !== null) await active.detach(); |
| 73 | + } catch (cause) { |
| 74 | + log(`detach error (continuing): ${errorMessage(cause)}`, "warn"); |
| 75 | + } |
| 76 | + await clearAttachment(); |
| 77 | + log("detached"); |
| 78 | + broadcastState(); |
| 79 | +} |
| 80 | +``` |
| 81 | +
|
| 82 | +`broadcastState()` updates the side panel only. No frame goes over the |
| 83 | +WebSocket, so even with a richer enum the backend could not make the |
| 84 | +transition. The same is true of the involuntary path, |
| 85 | +`background.ts:595` — which matters more, because it is the one a user |
| 86 | +triggers by accident: |
| 87 | +
|
| 88 | +```ts |
| 89 | +async function onDetach(source: { tabId?: number }, reason: string): Promise<void> { |
| 90 | + await profileClient.sessions.onDebuggerDetach(source); |
| 91 | + const active = session; |
| 92 | + if (active === null || source.tabId !== active.tabId) return; |
| 93 | + await fenceStartedAttendedWrites(); |
| 94 | + await clearAttachment(); |
| 95 | + log(`debugger detached from tab ${active.tabId} (${reason})`); |
| 96 | + broadcastState(); |
| 97 | +} |
| 98 | +``` |
| 99 | +
|
| 100 | +`chrome.debugger.onDetach` fires when the controlled tab is closed, or when the |
| 101 | +user clicks **Cancel** on Chrome's debugger banner. Both leave the session |
| 102 | +reporting `connected`. This is the mechanical reason |
| 103 | +`apps/extension/RUNBOOK.md` instructs operators not to touch that banner — the |
| 104 | +instruction exists, but the reason is not written down anywhere. |
| 105 | +
|
| 106 | +For completeness, `detached` today is set only on socket-level events, in |
| 107 | +`apps/backend/src/session.ts` at lines `529`, `1132`, `1164`, `1198` and `1231`: |
| 108 | +connection close, session reconnect / browser-epoch change, device credential |
| 109 | +revocation, and terminal close. Losing the debugger attachment is not among |
| 110 | +them. |
| 111 | +
|
| 112 | +### Why it was deferred |
| 113 | +
|
| 114 | +It is not a correctness hole in execution. A consumer that sends a command gets |
| 115 | +an explicit `ok: false` with a specific reason, not a false success, and |
| 116 | +Metamind's connector surfaces that. The exposure is limited to a consumer that |
| 117 | +gates on `status` alone and concludes the session is usable. |
| 118 | +
|
| 119 | +It also cannot be fixed in one repo. Adding a value to |
| 120 | +`AttendedSessionStatusSchema` is a **breaking protocol change**: the schema is |
| 121 | +exported from `@understudy/protocol` and consumers parse responses with it, so |
| 122 | +`z.enum` rejects an unknown value. Shipping it needs a protocol major, a |
| 123 | +connector release, and a consumer upgrade — coordination that did not belong in |
| 124 | +the middle of a canary acceptance run. |
| 125 | +
|
| 126 | +### Proposed fix |
| 127 | +
|
| 128 | +Model the machine, then report the transition. Both halves are required; either |
| 129 | +alone changes nothing. |
| 130 | +
|
| 131 | +1. **Protocol.** Add a state meaning "extension socket present, no tab |
| 132 | + attached". Recommended name `idle`, giving |
| 133 | + `pending | idle | connected | detached`, and keep `connected` meaning |
| 134 | + *ready to execute*. Preserving the existing meaning of `connected` is the |
| 135 | + point: consumers that already gate on `status === "connected"` keep working |
| 136 | + and simply stop treating a detached session as usable. |
| 137 | +
|
| 138 | + *Rejected:* renaming the ready state to `attached` and repurposing |
| 139 | + `connected` to mean socket-up. Cleaner on paper, but it silently flips the |
| 140 | + meaning of a value existing consumers already branch on — the failure mode |
| 141 | + would be worse than the bug. |
| 142 | +
|
| 143 | +2. **Extension.** Send an explicit frame on both detach paths — the deliberate |
| 144 | + `detach()` and the involuntary `onDetach()` — so the backend can transition |
| 145 | + to `idle`. It must carry the tab identity so a late frame from a superseded |
| 146 | + attachment cannot clear a newer one; `sendIfPeerCurrent` in |
| 147 | + `apps/extension/src/core/peer-binding.ts` is the existing guard for exactly |
| 148 | + this class of post-await staleness and should be reused rather than |
| 149 | + reinvented. |
| 150 | +
|
| 151 | +3. **Backend.** Clear `tabs` and `browser` alongside the transition. Reporting a |
| 152 | + `tabId` that is no longer controlled is part of the same wrongness and should |
| 153 | + not survive the fix. |
| 154 | +
|
| 155 | +### Out of scope |
| 156 | +
|
| 157 | +- The unattended lifecycle. It already models its machine and must not be |
| 158 | + touched by this work. |
| 159 | +- `SessionAgent`'s existing `detached` transitions. They are correct for what |
| 160 | + they describe — socket-level loss — and this adds a state beside them rather |
| 161 | + than redefining them. |
| 162 | +
|
| 163 | +### Verification |
| 164 | +
|
| 165 | +- A unit test asserting the full attended sequence: `pending` on create, |
| 166 | + `connected` after hello, `idle` after a detach frame, `detached` after the |
| 167 | + socket closes. |
| 168 | +- A test that a detach frame naming a superseded tab does **not** move a live |
| 169 | + session out of `connected`. |
| 170 | +- Manually, the scenario that found it: attach, confirm `connected`, press |
| 171 | + **Detach tab**, confirm `idle` and empty `tabs`; then repeat by closing the |
| 172 | + controlled tab instead of pressing the button, which must reach the same |
| 173 | + state. |
| 174 | +- Quality gate: clean-code, architecture, and QA lanes, since this is a protocol |
| 175 | + change with cross-repo consumers. |
0 commit comments