Report why a Copilot turn never reaches the gateway - #310
Conversation
build_exchange_from_transcript has four exits that return None, and none of
them logs. A None exchange fails the `if exchange and (...)` gate at the Stop
handler, so send_to_api is never called and the hook exits 0. The turn is
never attempted live and never reported as missing.
Backfill later reads the same transcript from disk and recovers it, which is
why turns reappear days later with backfilled=true. Measured on Xome: 38
applications have 574 backfilled rows dated after their live traffic had
already started. Ameer.Khan's 284 track his 270 api_call errors, but
Ramya.Kumar (77) and Veronika.S (33) have zero hook errors at all, so for them
nothing failed because nothing was tried.
Each exit now reports a distinct reason under the 'transcript' category so we
can tell which of the four actually fires. The bare `except Exception` is
narrowed to the IO and decode errors expected there; anything else now reaches
main's handler, which logs it and still prints {} so Copilot is unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uc9SimrhSKoJVj1WynML1a
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
🛡️ Automated Security Review (consensus)
1 finding: 0 high-confidence, 1 to triage. Reviewers: Claude (lead), Semgrep, Gitleaks.
- [LOW] Full transcript path sent to the remote error endpoint (
copilot/hooks/unbound.py:5168)- What: The new message
log_error(f"transcript missing: {transcript_path}", 'transcript')puts the full local file path from the hook payload intoerror.logand into the remote error report. - Why: The path usually has the user's OS username and the conversation or session ID in it, for example
/Users/<name>/.copilot/session-state/<conversation_id>/events.jsonl. That is personal data leaving the machine only to explain why a turn failed. The other three new messages avoid this: they log only the exception type or a count. - Fix: Don't log the raw path. Log whether it was empty or just not on disk yet, for example
"transcript missing: path_empty=%s exists=False", plus thefallback_session_idif you need to link it to a session. If you need the path, send only its base name or a hash of it. - Flagged by: Claude; also raised in the earlier Greptile review. No SAST scanner flagged it.
- What: The new message
Pre-existing (not introduced by this PR)
- Semgrep
insecure-file-permissionsatcopilot/hooks/unbound.py:5529is on a line this diff does not touch. It is also a false positive:0o700gives access to the owner only, which is stricter than the0o644that Semgrep suggests. No action needed.
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head e61bdf85 · 2026-09-14T11:11Z
Narrowing to (OSError, UnicodeDecodeError) let RecursionError from deeply nested transcript JSON escape the function. build_exchange_from_transcript is called at 5917 but complete_pending_turns at 5958, so an escaping exception skipped pending-turn completion that previously still ran after the None return. Keeping `except Exception` preserves that path exactly; the log line is what this PR was for. The missing-transcript log carried the full path, which includes the user's home directory. Basename only: it still correlates to a conversation without putting a username in telemetry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uc9SimrhSKoJVj1WynML1a
| except Exception as e: | ||
| log_error(f"transcript unreadable: {type(e).__name__}", 'transcript') |
There was a problem hiding this comment.
Unexpected Errors Lose Context
This handler catches every exception, including unexpected programming failures, but logs only the exception class before returning an empty exchange. This makes new transcript-processing bugs look like expected read failures and violates the repository requirement that caught errors include diagnostic context and a stack trace. This requirement must be satisfied before merging by limiting the handler to expected read or parsing failures, or by preserving full exception details.
Context Used: P0 — Critical (must block merge) Django / Backend — Database N+1 queries. Flag any DB query inside a loop, list/dict comprehension, or per-row serializer method. Flag related-object access (obj.fk.field, obj.related_set.all()) inside loops or DRF Ser... (source)
vigneshsubbiah16
left a comment
There was a problem hiding this comment.
✅ Security consensus: no issues found. (reviewers: Claude, Semgrep, Gitleaks)
🤖 consensus review · reviewers: Claude, Semgrep, Gitleaks · head 364e763f · 2026-09-14T11:31Z


Why
build_exchange_from_transcripthas four exits that returnNone, and none of them logs anything (grep -c log_errorover the function returns 0). ANoneexchange then fails the gate in the Stop handler:send_to_apiis never called and the hook exits 0. The turn is never attempted live and never reported as missing — from our side it simply never existed.Backfill later reads the same transcript files from disk and recovers it, which is why usage reappears days later tagged
backfilled=true.Evidence (Xome, org 3604)
38 applications have 574 backfilled rows dated after their live traffic had already started. If backfill only seeded history, that set would be empty.
Backfill genuinely carries historical conversations — for app 13559, live rows land 0.1 h after the conversation, backfilled rows land 212 h later (max 425 h).
Two distinct causes:
api_callerrors (30d)The first two track send failures (the 413s). The last two have no hook errors at all — nothing failed because nothing was tried. That is the path this PR makes visible.
What
A distinct
log_error(..., 'transcript')at each of the four exits:The bare
except Exceptionat 5182 is narrowed to(OSError, UnicodeDecodeError). Anything else now propagates tomain's handler, which logs it and still prints{}, so Copilot is unaffected either way — the difference is that a real bug becomes visible instead of being silently swallowed.What this does not do
Diagnosis only. No behaviour change to when a turn is sent. Once we know which of the four dominates, the actual fix follows — likely a retry when the transcript is not yet flushed, if that turns out to be the common case. Guessing before the data arrives is how we would pick the wrong one.
Reaches machines via
SCRIPT_URL, which points at main, so this needs promoting past staging.Testing
tests/copilot— 371 passed. The 2 failures are identical on cleanstaging(verified in a separate worktree, same counts), so they are pre-existing and unrelated.🤖 Generated with Claude Code
The PR appears safe to merge, although the non-blocking transcript error diagnostic still lacks enough context to debug unexpected exceptions.
Findings
Summary
Reviews (3) · Last reviewed commit: "Keep the broad except and log the basena..."