fix(diagnostics): deep-review round 3 — a keyboard owner that is not a Tab trap

Round 2 made the step prompt a blocking layer so that `j` and the `g` chords
would not reach the page behind it. The dialog it sits in gave up its Tab trap
because of that, while the prompt's own replacement trap covered only its own
two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or
Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now
separates the two questions it had folded into one flag: a layer can own the
keyboard and still leave the Tab trap to the dialog below it. The mirror case,
a dialog opening over the prompt, falls out of the same split.

A send's claim is now a lease with an owner. Every progress report re-stamps
it, so a send slower than the lease cannot lose it while it is still running,
and only the send that took a claim can give it back. The issue_create audit
row is staged inside the send and rides the same checkpoint as `issue_url`, so
the trail cannot be missing for an issue that exists. The attachment-name
prefix has one definition, shared by the producer and both readers.

A running send is reported to the UI. A reload or a second tab used to render
it exactly like a send that STOPPED, and offered a retry the server was certain
to refuse; the composer now says the report is being sent, holds the draft, and
puts both the retry and Create issue out. The past-sessions list says the same
for the same reason — it is the list a user opens to check on a send, and both
"draft" and "half sent" describe one that died. `/preview` and the retry path
got a pace of their own, and the per-user byte cap took the user-scoped lock
that makes it a cap instead of a guess.

Verified in a real browser on localdev: Tab cycles the whole dialog with the
prompt open, the prompt still swallows the page's keys, and the issue link is
focusable where the composer puts it. All 24 regression tests mutation-checked.
This commit is contained in:
2026-09-20 02:43:56 +02:00
parent bc03e82687
commit 2fe58073e4
20 changed files with 718 additions and 137 deletions
+2 -1
View File
@@ -64,7 +64,8 @@ def files(db: Session, session: DebugSession) -> list[IssueFile]:
)
for n, shot in enumerate(shots, start=1):
ext = debugsession.IMAGE_TYPES[shot.mime]
out.append(IssueFile(f"screenshot-{n}.{ext}", shot.data, shot.mime))
name = f"{debugsession.SCREENSHOT_PREFIX}{n}.{ext}"
out.append(IssueFile(name, shot.data, shot.mime))
return out
+20 -10
View File
@@ -20,9 +20,10 @@ Delivery keeps its progress in the session's draft (`delivery`: the issue number
upload, the links of those uploaded, whether the body shows the screenshots yet), so a failure
half-way keeps the issue that exists and a retry does only what is missing — never a second issue.
Every step is checkpointed (the caller's `db.commit`), so what did happen is kept whatever fails
next — and no transaction is held open across these calls. The caller claims the session for the
send first (`debugsession.claim_send`), which is what keeps a second send, an autosave or a
screenshot out of the way. `debugsession.not_uploaded` / `images_pending` read that progress.
next — and the first checkpoint comes before the first call, so no row lock and no read snapshot is
held across them. The caller claims the session for the send first (`debugsession.claim_send`),
which is what keeps a second send, an autosave or a screenshot out of the way; the claim is renewed
by every checkpoint. `debugsession.not_uploaded` / `images_pending` read that progress.
"""
from collections.abc import Callable, Iterator
@@ -161,16 +162,23 @@ def deliver(
*,
checkpoint: Callable[[], None],
before_create: Callable[[], None] | None = None,
on_created: Callable[[], None] | None = None,
) -> str:
(
"""Send the session's issue, or finish sending it; returns its URL. `checkpoint` persists what
has happened so far and is called after every step (the caller's commit — this module does not
decide when a transaction ends). `before_create` runs right before the tracker is asked to open
a NEW issue — after the checks that can refuse it without asking — and may raise to stop it.""",
)
"""Send the session's issue, or finish sending it; returns its URL.
`checkpoint` persists what has happened so far and is called after every step (the caller's
commit — this module does not decide when a transaction ends). `before_create` runs right before
the tracker is asked to open a NEW issue, after the checks that can refuse it without asking,
and may raise to stop it; `on_created` runs right after one was opened, in time to be committed
by the SAME checkpoint that first writes `issue_url` — anything the caller must not be able to
lose separately from the link belongs there.
"""
state = debugsession.delivery(session)
files = bundle.files(db, session)
attachments = [f for f in files if f.name != "issue.md"]
# Reading the bundle and the tracker's settings opened a transaction; end it before the first
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O.
checkpoint()
with _client(db) as (client, repo):
_private_repo(client, repo)
if "number" not in state:
@@ -197,6 +205,8 @@ def deliver(
}
session.issue_url = created["html_url"]
debugsession.set_delivery(session, state)
if on_created is not None:
on_created()
checkpoint()
log.info(
"issue created",
@@ -223,7 +233,7 @@ def deliver(
}
debugsession.set_delivery(session, state)
checkpoint()
shots = [n for n in state["links"] if n.startswith("screenshot-")]
shots = [n for n in state["links"] if n.startswith(debugsession.SCREENSHOT_PREFIX)]
if shots and not state.get("images_shown"):
images = "\n".join(f"![{n}]({state['links'][n]})" for n in sorted(shots))
body = f"{bundle.body_of(session)}\n\n---\n\n{images}"