fix(diagnostics): deep-review round 2 — a send that claims, not one that blocks

The send no longer holds the session's row lock while it talks to the tracker:
it claims the session, gives the lock back, and checkpoints each step. A Stop
is never held behind a send, while a draft, a screenshot or a second send are
refused at once instead of queueing. The claim ages out, so a request that
died mid-send does not strand the session.

What the composer says is what happened: an attachment that went is no longer
named as one that did not (the missing body edit is its own line), and the
issue's screenshot count comes from the server when the list cannot be read,
so a failed read no longer blocks saving the draft. The zip is fetched as a
request, so its pace is a message and not a saved error page. Screenshots of a
finished report can be removed again; only one still being sent is held.

A recording's log elevation can no longer outlive it. The step prompt keeps
the keyboard (the page's keys stay quiet) and traps Tab itself. An image
pasted into a dialog opened over the composer stays that dialog's.
This commit is contained in:
2026-09-20 00:51:12 +02:00
parent 134ec9a68e
commit 3f983e6d76
17 changed files with 347 additions and 126 deletions
+27 -19
View File
@@ -19,8 +19,10 @@ composer's own preview shows our copies, never these links.
Delivery keeps its progress in the session's draft (`delivery`: the issue number, the files still to
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 flushes; the caller holds the session's lock throughout (`debugsession.lock`) and commits,
also when a step fails, so what did happen is kept. `debugsession.unfinished` reads that progress.
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.
"""
from collections.abc import Callable, Iterator
@@ -110,8 +112,12 @@ def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Respo
def _json(r: httpx.Response, *keys: str) -> dict[str, Any]:
"""The answer as the API's JSON, holding `keys` — anything else is refused, not a crash: a
crash after the issue was created would lose the record that it was."""
"""The answer as the API's JSON, holding `keys` — anything else is refused, not a crash, so a
step after the issue was created cannot take down the request that holds its record.
The CREATE call is the one place this cannot keep that record: its answer IS the record. An
answer it cannot read there leaves the session unsent, and a retry opens another issue — the
price of not being able to tell a refused create from an unreadable one."""
try:
data = r.json()
# silent-ok: not JSON is one of the answers refused (and logged) just below.
@@ -149,17 +155,19 @@ def preview(db: Session, text: str) -> str:
return r.text
def _save(session: DebugSession, state: dict) -> None:
# A new dict, so the JSONB column sees the change.
session.draft = {**(session.draft or {}), "delivery": state}
def deliver(
db: Session, session: DebugSession, *, before_create: Callable[[], None] | None = None
db: Session,
session: DebugSession,
*,
checkpoint: Callable[[], None],
before_create: Callable[[], None] | None = None,
) -> str:
"""Send the session's issue, or finish sending it; returns its URL. Flushes after every step.
`before_create` runs right before a NEW issue is opened — after every check that could refuse
it — and may raise to stop it (the caller's pace on new issues)."""
(
"""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.""",
)
state = debugsession.delivery(session)
files = bundle.files(db, session)
attachments = [f for f in files if f.name != "issue.md"]
@@ -184,8 +192,8 @@ def deliver(
"links": {},
}
session.issue_url = created["html_url"]
_save(session, state)
db.flush()
debugsession.set_delivery(session, state)
checkpoint()
log.info(
"issue created",
extra=logs.event("issues.gitea.created", number=created["number"]),
@@ -209,13 +217,13 @@ def deliver(
"pending": [n for n in state["pending"] if n != item.name],
"links": {**state["links"], item.name: asset["browser_download_url"]},
}
_save(session, state)
db.flush()
debugsession.set_delivery(session, state)
checkpoint()
shots = [n for n in state["links"] if n.startswith("screenshot-")]
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}"
_call(client, "PATCH", f"/repos/{repo}/issues/{number}", json={"body": body})
_save(session, {**state, "images_shown": True})
db.flush()
debugsession.set_delivery(session, {**state, "images_shown": True})
checkpoint()
return session.issue_url or ""